mcnoaa-tides/tests/conftest.py
Ryan Malloy 2443d0687a Add tool annotations, structured logging, sampling summaries, and client diagnostics
- ToolAnnotations (readOnlyHint, openWorldHint) on all 14 tools
- ctx.info/warning/debug logging for data quality, fetch status, assessments
- ctx.sample() summaries in conditions snapshot, deployment briefing, anomaly detection
- New test_client_capabilities diagnostic tool for MCP feature discovery
2026-02-23 15:01:07 -07:00

178 lines
4.8 KiB
Python

"""Test fixtures with mock NOAAClient injected via lifespan."""
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock
import pytest
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_async
from mcnoaa_tides import prompts, resources
from mcnoaa_tides.client import NOAAClient
from mcnoaa_tides.tools import (
charts,
conditions,
diagnostics,
meteorological,
smartpot,
stations,
tides,
)
# Realistic station fixtures
MOCK_STATIONS_RAW = [
{
"id": "8454000",
"name": "Providence",
"state": "RI",
"lat": 41.8071,
"lng": -71.4012,
"tidal": True,
"greatlakes": False,
"shefcode": "PRVD1",
},
{
"id": "8452660",
"name": "Newport",
"state": "RI",
"lat": 41.5043,
"lng": -71.3261,
"tidal": True,
"greatlakes": False,
"shefcode": "NWPR1",
},
{
"id": "8447930",
"name": "Woods Hole",
"state": "MA",
"lat": 41.5236,
"lng": -70.6714,
"tidal": True,
"greatlakes": False,
"shefcode": "WHOM3",
},
]
MOCK_PREDICTIONS = {
"predictions": [
{"t": "2026-02-21 04:30", "v": "4.521", "type": "H"},
{"t": "2026-02-21 10:42", "v": "-0.123", "type": "L"},
{"t": "2026-02-21 16:55", "v": "5.012", "type": "H"},
{"t": "2026-02-21 23:08", "v": "0.234", "type": "L"},
]
}
MOCK_WATER_LEVEL = {
"data": [
{"t": "2026-02-21 00:00", "v": "2.34", "s": "0.003", "f": "0,0,0,0", "q": "p"},
{"t": "2026-02-21 00:06", "v": "2.38", "s": "0.003", "f": "0,0,0,0", "q": "p"},
]
}
MOCK_WIND = {
"data": [
{
"t": "2026-02-21 00:00",
"s": "12.5",
"d": "225.00",
"dr": "SW",
"g": "18.2",
"f": "0,0",
}
]
}
MOCK_AIR_TEMP = {"data": [{"t": "2026-02-21 00:00", "v": "42.3", "f": "0,0,0"}]}
MOCK_WATER_TEMP = {"data": [{"t": "2026-02-21 00:00", "v": "38.7", "f": "0,0,0"}]}
MOCK_PRESSURE = {"data": [{"t": "2026-02-21 00:00", "v": "1013.2", "f": "0,0,0"}]}
# 6-minute interval predictions (no "type" field) that overlap with MOCK_WATER_LEVEL
MOCK_PREDICTIONS_6MIN = {
"predictions": [
{"t": "2026-02-21 00:00", "v": "2.50"},
{"t": "2026-02-21 00:06", "v": "2.55"},
{"t": "2026-02-21 00:12", "v": "2.60"},
]
}
MOCK_METADATA = {
"stations": [
{
"id": "8454000",
"name": "Providence",
"state": "RI",
"lat": 41.8071,
"lng": -71.4012,
"tidal": True,
"sensors": [{"name": "Water Level"}, {"name": "Air Temperature"}],
"products": {"self": "...", "tidePredictions": "..."},
"datums": {"datums": [{"name": "MLLW", "value": "0.0"}]},
}
]
}
def _build_mock_client() -> NOAAClient:
"""Build a NOAAClient with mocked HTTP but real search/nearest logic."""
from mcnoaa_tides.models import Station
client = NOAAClient()
client._stations = [Station(**s) for s in MOCK_STATIONS_RAW]
client._cache_time = float("inf") # Never expires
client._http = AsyncMock()
async def mock_get_data(station_id, product, **kwargs):
if product == "predictions":
# Return 6-min interval data when interval="6", hilo otherwise
if kwargs.get("interval") == "6":
return MOCK_PREDICTIONS_6MIN
return MOCK_PREDICTIONS
responses = {
"water_level": MOCK_WATER_LEVEL,
"wind": MOCK_WIND,
"air_temperature": MOCK_AIR_TEMP,
"water_temperature": MOCK_WATER_TEMP,
"air_pressure": MOCK_PRESSURE,
}
if product in responses:
return responses[product]
raise ValueError(f"No data was found for product: {product}")
client.get_data = mock_get_data
async def mock_get_station_metadata(station_id):
return MOCK_METADATA["stations"][0]
client.get_station_metadata = mock_get_station_metadata
return client
@asynccontextmanager
async def _test_lifespan(server: FastMCP):
client = _build_mock_client()
yield {"noaa_client": client}
def _build_test_server() -> FastMCP:
mcp = FastMCP("mcnoaa-tides-test", lifespan=_test_lifespan)
stations.register(mcp)
tides.register(mcp)
meteorological.register(mcp)
conditions.register(mcp)
smartpot.register(mcp)
charts.register(mcp)
diagnostics.register(mcp)
resources.register(mcp)
prompts.register(mcp)
return mcp
@pytest.fixture
async def mcp_client():
server = _build_test_server()
async with run_server_async(server) as url:
async with Client(StreamableHttpTransport(url)) as client:
yield client