Skip to content

Commit 0f93ea5

Browse files
Zhonghao1995claude
andauthored
test: add license-free pytest suite; share + harden res1d helpers (#11)
Adds a real unit-test suite for the pure, license-free logic and wires it into CI, then folds two small robustness fixes the tests now cover. - tests/ (test_schema, test_units, test_registry) + pytest.ini (testpaths=tests so collection skips the gitignored _scratch model copies); CI runs `pytest`. - contracts/schema.match_columns: the res1d column-resolution rule (node `X` stays distinct from reach `X.2`) extracted from results_worker and plot_worker, which now share it instead of duplicating the matcher. - mike_results_read: compute the peak on the FULL series and splice the argmax back into the downsampled output, so a low max_points can no longer hide the true peak; response now also carries peak_value / peak_time / downsampled. Verified on the Sirius_RTC HD result: Discharge:Link_29 peak 0.79886 @ 00:05 is preserved with max_points=100 (step=14 would otherwise drop it); node exact-match read and plot still work; 14 tests pass in 0.5s. Fixes #6 Fixes #7 Fixes #8 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a32318c commit 0f93ea5

8 files changed

Lines changed: 171 additions & 12 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,8 @@ jobs:
2626
2727
- name: Smoke test (license-free tool discovery)
2828
run: python scripts/smoke_test.py
29+
30+
- name: Unit tests (license-free)
31+
run: |
32+
pip install pytest
33+
pytest

mikeplus_mcp/contracts/schema.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ def parse_column(col: str) -> dict:
3131
return {"quantity": quantity, "element_id": element_id, "chainage": chainage}
3232

3333

34+
def match_columns(columns, quantity: str, element: str) -> list:
35+
"""res1d columns whose ``Quantity:ElementId`` matches ``quantity``/``element``.
36+
37+
Matches an exact node series (``Quantity:Element``) OR a reach series carrying a
38+
chainage suffix (``Quantity:Element:chainage``). The trailing ``':'`` guard keeps a
39+
node ``X`` distinct from a reach ``X.2`` (the reach id contains the dot, so it never
40+
matches the ``X:`` prefix). Returns the original column objects, in input order.
41+
"""
42+
base = f"{quantity}:{element}"
43+
return [c for c in columns if str(c) == base or str(c).startswith(base + ":")]
44+
45+
3446
def summarize(df, quantities=None) -> list[dict]:
3547
"""Per-quantity peak summary (canonical). ``df`` = mikeio1d ``res.read()``."""
3648
cols_by_q: dict[str, list] = {}

mikeplus_mcp/workers/plot_worker.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def main() -> None:
1212
out = payload["__out"]
1313
try:
1414
import mikeio1d
15-
from mikeplus_mcp.contracts import plot_style
15+
from mikeplus_mcp.contracts import plot_style, schema
1616
from mikeplus_mcp.contracts.units import unit_for
1717

1818
action = payload.get("action", "rain_flow")
@@ -40,10 +40,7 @@ def main() -> None:
4040
df = res.read()
4141

4242
def series_for(quantity, element):
43-
base = f"{quantity}:{element}"
44-
# exact (node) OR 'base:' prefix (reach w/ chainage) — keeps node 'X'
45-
# distinct from reach 'X.2'
46-
cols = [c for c in df.columns if str(c) == base or str(c).startswith(base + ":")]
43+
cols = schema.match_columns(df.columns, quantity, element)
4744
if not cols:
4845
raise ValueError(f"no series for {quantity}:{element}")
4946
return df[cols[0]], str(cols[0])

mikeplus_mcp/workers/results_worker.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,22 @@ def main() -> None:
7474
quantity = payload["quantity"]
7575
element = payload["element"]
7676
df = res.read()
77-
base = f"{quantity}:{element}"
78-
# exact (node, no chainage) OR 'base:' prefix (reach w/ chainage); avoids
79-
# matching reach 'C14154801.2' when the node 'C14154801' was requested
80-
cols = [c for c in df.columns if str(c) == base or str(c).startswith(base + ":")]
77+
cols = schema.match_columns(df.columns, quantity, element)
8178
if not cols:
8279
raise ValueError(f"no series for quantity={quantity!r} element={element!r}")
8380
col = cols[0]
84-
s = df[col]
81+
full = df[col]
82+
# peak computed on the FULL series so downsampling can never hide it
83+
pv = full.max()
84+
has_peak = pv == pv # False only when NaN
8585
max_pts = int(payload.get("max_points", 5000))
86-
step = max(1, len(s) // max_pts)
87-
s = s.iloc[::step]
86+
step = max(1, len(full) // max_pts)
87+
s = full.iloc[::step]
88+
if step > 1 and has_peak:
89+
# stride decimation skips extrema — splice the true peak back in
90+
t_peak = full.idxmax()
91+
if t_peak not in s.index:
92+
s = full.reindex(s.index.union([t_peak]))
8893
meta = schema.parse_column(col)
8994
result = {
9095
"ok": True,
@@ -95,6 +100,9 @@ def main() -> None:
95100
"chainage": meta["chainage"],
96101
"unit": unit_for(quantity),
97102
"n_points": int(len(s)),
103+
"downsampled": bool(step > 1),
104+
"peak_value": float(pv) if has_peak else None,
105+
"peak_time": str(full.idxmax()) if has_peak else None,
98106
"times": [str(t) for t in s.index],
99107
"values": [float(v) for v in s.values],
100108
}

pytest.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[pytest]
2+
testpaths = tests

tests/test_registry.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""The auto-discovery registry must surface exactly the expected tools with valid schemas.
2+
3+
Imports stay license-free: discovery imports the ``tools/`` modules, which only pull in
4+
lightweight types + the worker launcher (never mikeplus / mikeio).
5+
"""
6+
from mikeplus_mcp.registry import discover_tools
7+
8+
EXPECTED = {
9+
"mike_model_info",
10+
"mike_get_values",
11+
"mike_set_values",
12+
"mike_run",
13+
"mike_results_list",
14+
"mike_results_summary",
15+
"mike_results_read",
16+
"mike_plot_rain_flow",
17+
"mike_plot_timeseries",
18+
"mike_plot_network",
19+
}
20+
21+
22+
def test_discovers_exactly_the_expected_tools():
23+
names = [t.name for t in discover_tools()]
24+
assert set(names) == EXPECTED
25+
26+
27+
def test_tool_names_are_unique():
28+
names = [t.name for t in discover_tools()]
29+
assert len(names) == len(set(names))
30+
31+
32+
def test_every_tool_has_a_valid_input_schema():
33+
for t in discover_tools():
34+
assert t.description.strip(), f"{t.name} has no description"
35+
schema = t.input_schema
36+
assert schema.get("type") == "object", f"{t.name} schema is not an object"
37+
assert "properties" in schema, f"{t.name} schema has no properties"
38+
# every declared required field must be a defined property
39+
for req in schema.get("required", []):
40+
assert req in schema["properties"], f"{t.name} requires undefined field {req!r}"

tests/test_schema.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Unit tests for the engine-agnostic schema helpers (no license / no mikeio)."""
2+
import numpy as np
3+
import pandas as pd
4+
5+
from mikeplus_mcp.contracts import schema
6+
7+
8+
def test_parse_column_node():
9+
assert schema.parse_column("WaterLevel:C14150801") == {
10+
"quantity": "WaterLevel",
11+
"element_id": "C14150801",
12+
"chainage": None,
13+
}
14+
15+
16+
def test_parse_column_reach_with_chainage():
17+
out = schema.parse_column("Discharge:C14150801.2:30.71")
18+
assert out["quantity"] == "Discharge"
19+
assert out["element_id"] == "C14150801.2" # the dot belongs to the reach id
20+
assert out["chainage"] == 30.71
21+
22+
23+
def test_parse_column_non_numeric_chainage_falls_back_to_string():
24+
assert schema.parse_column("X:Y:notnum")["chainage"] == "notnum"
25+
26+
27+
# real-world column shapes taken from the Sirius_RTC HD result
28+
COLS = [
29+
"WaterLevel:C14150801", # node
30+
"WaterLevel:C14150801.2:30.71", # reach (synthetic) sharing the id stem
31+
"Discharge:C14150801.2:30.71", # reach
32+
"Discharge:Link_29:33.5333", # reach with a named link
33+
]
34+
35+
36+
def test_match_columns_node_is_not_confused_with_reach():
37+
# node 'C14150801' must NOT match reach 'C14150801.2'
38+
assert schema.match_columns(COLS, "WaterLevel", "C14150801") == ["WaterLevel:C14150801"]
39+
40+
41+
def test_match_columns_reach_prefix():
42+
assert schema.match_columns(COLS, "Discharge", "C14150801.2") == ["Discharge:C14150801.2:30.71"]
43+
assert schema.match_columns(COLS, "Discharge", "Link_29") == ["Discharge:Link_29:33.5333"]
44+
45+
46+
def test_match_columns_no_match():
47+
assert schema.match_columns(COLS, "Discharge", "Nope") == []
48+
49+
50+
def _frame():
51+
idx = pd.date_range("2020-01-01", periods=5, freq="h")
52+
return pd.DataFrame(
53+
{
54+
"Discharge:Link_1:10": [0.0, 1.0, 2.0, 1.0, 0.0],
55+
"Discharge:Link_2:5": [0.0, 0.0, 5.0, 0.0, 0.0], # global Discharge peak
56+
"WaterLevel:Node_1": [1.0, 1.0, 1.0, 1.0, 1.0],
57+
"WaterLevel:Node_2": [np.nan] * 5, # all-NaN column
58+
},
59+
index=idx,
60+
)
61+
62+
63+
def test_summarize_picks_global_peak_and_time():
64+
rows = {r["quantity"]: r for r in schema.summarize(_frame())}
65+
assert set(rows) == {"Discharge", "WaterLevel"}
66+
67+
dis = rows["Discharge"]
68+
assert dis["peak_value"] == 5.0
69+
assert dis["peak_element"] == "Link_2"
70+
assert dis["peak_time"].startswith("2020-01-01 02:00")
71+
assert dis["unit"] == "m3/s"
72+
assert dis["n_series"] == 2
73+
74+
75+
def test_summarize_skips_all_nan_within_quantity():
76+
rows = {r["quantity"]: r for r in schema.summarize(_frame())}
77+
# WaterLevel:Node_2 is all-NaN; the peak must come from Node_1, not crash/NaN
78+
assert rows["WaterLevel"]["peak_value"] == 1.0
79+
80+
81+
def test_summarize_quantity_filter():
82+
rows = schema.summarize(_frame(), quantities=["Discharge"])
83+
assert [r["quantity"] for r in rows] == ["Discharge"]

tests/test_units.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Unit tests for the canonical unit vocabulary."""
2+
from mikeplus_mcp.contracts.units import unit_for
3+
4+
5+
def test_known_quantities():
6+
assert unit_for("Discharge") == "m3/s"
7+
assert unit_for("WaterLevel") == "m"
8+
assert unit_for("FlowVelocity") == "m/s"
9+
10+
11+
def test_unknown_quantity_returns_empty_string():
12+
assert unit_for("SomethingMadeUp") == ""

0 commit comments

Comments
 (0)