Skip to content

Commit 32ffc20

Browse files
diogodkclaude
andcommitted
refactor(mcp): apply TG-1090 round-2 review feedback
- _format_monitor_cell: precedence reordered so a positive anomaly count beats training / pending. Previously a row that surfaced via anomaly_type=X could show "training" or "pending" in the X column, hiding the value that put it in the filtered set. - get_monitor_group_summary: empty-state lookback signal restored. The outer query now COALESCEs MAX(lookback) to 0 (was 1), preserving the pre-refactor dashboard signal for "No monitor runs yet". The fabricated default_lookback param is dropped. - parse_monitor_type: accepts an optional label so the error message names the caller's public arg. list_monitored_tables exposes the arg as anomaly_type, so the error now says "Invalid anomaly_type ..." instead of leaking the helper's internal monitor_type name. - common.py: drop unused format_monitor_type helper (no callers on this branch; re-added in TG-1092 where list_monitors will use it). Tests: - list_monitored_tables: new test pinning count > 0 precedence over training / pending, and verifying error still wins over count. - get_monitor_summary: new empty-state test (lookback=0; Window start/end fields suppressed; all four cells render the full "no results yet or not configured" phrase). - parse_monitor_type: new label-override test for the anomaly_type caller path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4fda326 commit 32ffc20

5 files changed

Lines changed: 125 additions & 31 deletions

File tree

testgen/common/models/table_group.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,7 @@ def get_monitor_group_summary(
689689
)
690690
query = f"""
691691
SELECT
692-
COALESCE(MAX(lookback), :default_lookback)::INTEGER AS lookback,
692+
COALESCE(MAX(lookback), 0)::INTEGER AS lookback,
693693
MIN(lookback_start) AS lookback_start,
694694
MAX(lookback_end) AS lookback_end,
695695
COUNT(*)::INTEGER AS total_monitored_tables,
@@ -719,9 +719,10 @@ def get_monitor_group_summary(
719719
COALESCE(BOOL_AND(metric_is_pending), TRUE) AS metric_is_pending
720720
FROM ({inner_query}) AS subquery
721721
"""
722-
params = {**params, "default_lookback": lookback_override or 1}
723722
# Outer query has no GROUP BY — aggregates over zero rows still yield one
724-
# COALESCE'd row, so .first() never returns None here.
723+
# COALESCE'd row, so .first() never returns None here. ``lookback`` is 0
724+
# when the per-table CTE has no rows OR no runs against the monitor suite,
725+
# so the dashboard / MCP can render the "no monitor runs yet" state.
725726
row = get_current_session().execute(text(query), params).mappings().first()
726727
return MonitorGroupSummary(**row)
727728

testgen/mcp/tools/common.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -357,29 +357,22 @@ def next_scheduled_run(
357357
"schema": MonitorType.SCHEMA,
358358
"metric": MonitorType.METRIC,
359359
}
360-
_MONITOR_TYPE_DB_TO_USER: dict[MonitorType, str] = {v: k for k, v in _MONITOR_TYPE_USER_TO_DB.items()}
361360

362361

363-
def parse_monitor_type(value: str) -> MonitorType:
362+
def parse_monitor_type(value: str, label: str = "monitor_type") -> MonitorType:
364363
"""Validate a user-facing monitor type label and return the stored ``MonitorType``.
365364
366-
Accepts ``freshness`` / ``volume`` / ``schema`` / ``metric``.
365+
Accepts ``freshness`` / ``volume`` / ``schema`` / ``metric``. ``label`` names the
366+
caller's argument in the error message — pass ``"anomaly_type"`` when the
367+
public arg is named differently from ``monitor_type``.
367368
"""
368369
db_value = _MONITOR_TYPE_USER_TO_DB.get(value)
369370
if db_value is None:
370371
valid = ", ".join(_MONITOR_TYPE_USER_TO_DB)
371-
raise MCPUserError(f"Invalid monitor_type `{value}`. Valid values: {valid}")
372+
raise MCPUserError(f"Invalid {label} `{value}`. Valid values: {valid}")
372373
return db_value
373374

374375

375-
def format_monitor_type(value: MonitorType | str) -> str:
376-
"""Map a stored monitor ``test_type`` to its user-facing short label."""
377-
try:
378-
return _MONITOR_TYPE_DB_TO_USER[MonitorType(value)]
379-
except ValueError:
380-
return str(value)
381-
382-
383376
class MonitorTableSort(StrEnum):
384377
"""User-facing values accepted for the ``sort_by`` argument on ``list_monitored_tables``.
385378

testgen/mcp/tools/monitors.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ def list_monitored_tables(
150150
validate_page(page)
151151
validate_limit(limit, 100)
152152

153-
monitor_type = parse_monitor_type(anomaly_type) if anomaly_type is not None else None
153+
monitor_type = parse_monitor_type(anomaly_type, "anomaly_type") if anomaly_type is not None else None
154154
sort = parse_monitor_table_sort(sort_by) if sort_by is not None else None
155155

156156
tg, monitor_suite = resolve_monitored_table_group(table_group_id)
@@ -245,14 +245,22 @@ def _format_monitor_cell(
245245
is_training: bool | None,
246246
has_error: bool,
247247
) -> str:
248-
"""Render a per-table, per-monitor cell (non-schema)."""
248+
"""Render a per-table, per-monitor cell (non-schema).
249+
250+
Precedence: error > positive count > pending > training > zero. A positive
251+
count wins over training/pending so anomalies stay visible when a monitor is
252+
still learning, and so a row that surfaces via ``anomaly_type=X`` actually
253+
shows the X count in its column.
254+
"""
249255
if has_error:
250256
return "error"
257+
if count > 0:
258+
return str(count)
251259
if is_pending:
252260
return "pending"
253261
if is_training:
254262
return "training"
255-
return str(count)
263+
return "0"
256264

257265

258266
def _format_schema_cell(row: MonitorTableSummary) -> str:

tests/unit/mcp/test_tools_common.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,15 @@ def test_parse_monitor_type_lists_valid_values_on_error():
873873
assert label in msg
874874

875875

876+
def test_parse_monitor_type_label_override():
877+
"""``label`` argument lets callers tailor the error to their public arg name
878+
(e.g. ``list_monitored_tables`` exposes it as ``anomaly_type``)."""
879+
from testgen.mcp.tools.common import parse_monitor_type
880+
881+
with pytest.raises(MCPUserError, match=r"Invalid anomaly_type `bogus`"):
882+
parse_monitor_type("bogus", "anomaly_type")
883+
884+
876885
@pytest.mark.parametrize(
877886
"value",
878887
["table_name", "anomaly_count_desc", "latest_update_desc", "row_count_change_desc"],
@@ -893,15 +902,6 @@ def test_parse_monitor_table_sort_rejects_unknown():
893902
assert valid in msg
894903

895904

896-
def test_format_monitor_type_round_trips():
897-
from testgen.common.enums import MonitorType
898-
from testgen.mcp.tools.common import format_monitor_type
899-
900-
for member in MonitorType:
901-
formatted = format_monitor_type(member)
902-
assert format_monitor_type(member.value) == formatted
903-
904-
905905
def test_resolve_monitored_table_group_returns_suite():
906906
from testgen.common.models.table_group import TableGroup
907907
from testgen.common.models.test_suite import TestSuite

tests/unit/mcp/test_tools_monitors.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,45 @@ def test_get_monitor_summary_lookback_out_of_range(db_session_mock):
204204
assert "between 1 and 365" in str(exc.value)
205205

206206

207+
@patch(f"{MODULE}.next_scheduled_run", return_value=None)
208+
@patch(f"{MODULE}.TableGroup")
209+
@patch(f"{MODULE}.resolve_monitored_table_group")
210+
def test_get_monitor_summary_empty_state_lookback_zero(
211+
mock_resolve, mock_tg_cls, mock_next, db_session_mock,
212+
):
213+
"""When a monitor suite is configured but no runs have happened yet, the model
214+
method returns ``lookback=0`` (preserves the pre-refactor signal the dashboard
215+
uses to render "No monitor runs yet"). The MCP output reflects the empty state
216+
rather than fabricating a one-run window."""
217+
tg = _mock_table_group()
218+
mock_resolve.return_value = (tg, _mock_monitor_suite())
219+
mock_tg_cls.get_monitor_group_summary.return_value = _group_summary(
220+
lookback=0,
221+
lookback_start=None,
222+
lookback_end=None,
223+
total_monitored_tables=0,
224+
freshness_anomalies=0, volume_anomalies=0,
225+
schema_anomalies=0, metric_anomalies=0,
226+
freshness_is_pending=True, volume_is_pending=True,
227+
schema_is_pending=True, metric_is_pending=True,
228+
freshness_is_training=False, volume_is_training=False,
229+
metric_is_training=False,
230+
)
231+
232+
from testgen.mcp.tools.monitors import get_monitor_summary
233+
234+
with _patch_perms():
235+
out = get_monitor_summary(str(tg.id))
236+
237+
assert "**Lookback:** 0 runs" in out, "must show 0, not a fabricated default like 1"
238+
# Window start / end fields are absent in the empty case (None values render as em-dash
239+
# at most, but the tool omits them when the value is falsy)
240+
assert "**Window start:**" not in out
241+
assert "**Window end:**" not in out
242+
# All per-type status cells reflect "no results"
243+
assert out.count("no results yet or not configured") == 4
244+
245+
207246
@patch(f"{MODULE}.next_scheduled_run", return_value=None)
208247
@patch(f"{MODULE}.TableGroup")
209248
@patch(f"{MODULE}.resolve_monitored_table_group")
@@ -320,11 +359,15 @@ def test_list_monitored_tables_not_monitored(mock_resolve, db_session_mock):
320359

321360

322361
def test_list_monitored_tables_invalid_anomaly_type(db_session_mock):
362+
"""Error message must reference the caller's public arg name (``anomaly_type``)
363+
rather than the helper's internal arg name (``monitor_type``)."""
323364
from testgen.mcp.tools.monitors import list_monitored_tables
324365

325366
with _patch_perms(), pytest.raises(MCPUserError) as exc:
326367
list_monitored_tables(str(uuid4()), anomaly_type="bogus")
327-
assert "Invalid monitor_type" in str(exc.value)
368+
msg = str(exc.value)
369+
assert "Invalid anomaly_type" in msg
370+
assert "Invalid monitor_type" not in msg
328371

329372

330373
def test_list_monitored_tables_invalid_sort_by(db_session_mock):
@@ -403,15 +446,16 @@ def test_list_monitored_tables_schema_change_column(
403446
def test_list_monitored_tables_training_and_pending_cells(
404447
mock_resolve, mock_tg_cls, db_session_mock,
405448
):
449+
"""Status words render when the per-type count is zero."""
406450
tg = _mock_table_group()
407451
mock_resolve.return_value = (tg, _mock_monitor_suite())
408452
mock_tg_cls.list_monitor_table_summaries.return_value = (
409453
[
410454
_table_summary(
411455
table_name="t1",
412-
freshness_is_training=True,
413-
volume_is_pending=True,
414-
metric_error_message="boom",
456+
freshness_anomalies=0, freshness_is_training=True,
457+
volume_anomalies=0, volume_is_pending=True,
458+
metric_anomalies=0, metric_error_message="boom",
415459
),
416460
],
417461
1,
@@ -428,6 +472,54 @@ def test_list_monitored_tables_training_and_pending_cells(
428472
assert "error" in row
429473

430474

475+
@patch(f"{MODULE}.TableGroup")
476+
@patch(f"{MODULE}.resolve_monitored_table_group")
477+
def test_list_monitored_tables_count_wins_over_training_and_pending(
478+
mock_resolve, mock_tg_cls, db_session_mock,
479+
):
480+
"""A positive anomaly count must surface even when the monitor is in training
481+
or pending state — otherwise the cell hides the value that made the row match
482+
an ``anomaly_type`` filter, and the table doesn't agree with itself.
483+
484+
Precedence is error > positive count > pending > training > zero. Errors still
485+
win (the latest measurement is suspect, so the historic count is misleading).
486+
"""
487+
tg = _mock_table_group()
488+
mock_resolve.return_value = (tg, _mock_monitor_suite())
489+
mock_tg_cls.list_monitor_table_summaries.return_value = (
490+
[
491+
_table_summary(
492+
table_name="t_busy_during_learn",
493+
freshness_anomalies=5, freshness_is_training=True,
494+
volume_anomalies=3, volume_is_pending=True,
495+
metric_anomalies=2, metric_is_training=True,
496+
),
497+
_table_summary(
498+
table_name="t_error_with_count",
499+
freshness_anomalies=4, freshness_error_message="db down",
500+
),
501+
],
502+
2,
503+
)
504+
505+
from testgen.mcp.tools.monitors import list_monitored_tables
506+
507+
with _patch_perms():
508+
out = list_monitored_tables(str(tg.id))
509+
510+
# Row 1: counts visible despite training/pending
511+
row_count = next(line for line in out.splitlines() if "`t_busy_during_learn`" in line)
512+
assert " 5 " in row_count, "freshness count should render despite is_training"
513+
assert " 3 " in row_count, "volume count should render despite is_pending"
514+
assert " 2 " in row_count, "metric count should render despite is_training"
515+
assert "training" not in row_count
516+
assert "pending" not in row_count
517+
# Row 2: error still wins over count
518+
row_error = next(line for line in out.splitlines() if "`t_error_with_count`" in line)
519+
assert "error" in row_error
520+
assert " 4 " not in row_error, "error must win over count (measurement is suspect)"
521+
522+
431523
@patch(f"{MODULE}.TableGroup")
432524
@patch(f"{MODULE}.resolve_monitored_table_group")
433525
def test_list_monitored_tables_empty(mock_resolve, mock_tg_cls, db_session_mock):

0 commit comments

Comments
 (0)