4 new tools (tidal_phase, deployment_briefing, catch_tidal_context, water_level_anomaly) and 2 prompts (smartpot_deployment, crab_pot_analysis) for autonomous crab pot deployment planning and catch correlation. Pure tidal phase classification in tidal.py with no MCP dependencies. 65 tests passing, lint clean.
169 lines
4.7 KiB
Python
169 lines
4.7 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, 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)
|
|
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
|