From c17c58003ce0940acc06f7b160752d774220dc88 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sun, 6 Sep 2026 18:41:42 -0400 Subject: [PATCH] test(harness): keep the visual double's signature tied to production Moves set_scrolling_state's frame_hold into the test double here, where DisplayManager gains it, rather than in #534 where it arrived a PR early. CodeRabbit flagged the #534 version correctly: a double that accepts an argument production does not lets the call pass every harness run and raise TypeError on the panel, which is the one failure a safety harness exists to prevent. The drift has now gone both ways across two branches -- double behind production on this branch, double ahead of it on #534 -- so it is pinned instead of remembered. test_display_double_parity.py compares the two signatures and fails with the direction of the drift named. It reads the files with ast rather than importing them, because display_manager imports rgbmatrix at module scope and this check should hold on a laptop and in CI as well as on a Pi. Plugins begin passing frame_hold in ledmatrix-plugins#462, which is why production and the double both need it before that lands. Full suite: 3889 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- .../testing/visual_display_manager.py | 13 +++- test/test_display_double_parity.py | 69 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 test/test_display_double_parity.py diff --git a/src/plugin_system/testing/visual_display_manager.py b/src/plugin_system/testing/visual_display_manager.py index 5211a226..55bb99ea 100644 --- a/src/plugin_system/testing/visual_display_manager.py +++ b/src/plugin_system/testing/visual_display_manager.py @@ -503,9 +503,18 @@ class VisualTestDisplayManager: # Scrolling state (no-op interface compat) # ------------------------------------------------------------------ - def set_scrolling_state(self, is_scrolling: bool): - """Set the current scrolling state (no-op for testing).""" + def set_scrolling_state(self, is_scrolling: bool, frame_hold: int = 1): + """Set the current scrolling state (no-op for testing). + + ``frame_hold`` mirrors the DisplayManager signature this change adds. + The two are kept in step deliberately: a double that accepts arguments + production does not lets a call pass every harness run and then raise + TypeError on the panel, and a double that lacks one production has + fails every render of a plugin that legitimately paces its scroll. + Plugins begin passing it in ledmatrix-plugins#462. + """ self._scrolling_state['is_scrolling'] = is_scrolling + self._scrolling_state['frame_hold'] = frame_hold if is_scrolling: self._scrolling_state['last_scroll_activity'] = time.time() diff --git a/test/test_display_double_parity.py b/test/test_display_double_parity.py new file mode 100644 index 00000000..84506848 --- /dev/null +++ b/test/test_display_double_parity.py @@ -0,0 +1,69 @@ +"""The visual-test double must not drift from the real DisplayManager. + +Both directions of drift are silent and both are damaging: + + * the double accepts an argument production does not -- the call passes + every harness run and raises TypeError on the panel, which is precisely + the failure a safety harness exists to prevent; + * the double lacks an argument production has -- every plugin that + legitimately uses it fails every render, and the harness blames the + plugin. + +`set_scrolling_state` has been each of those in turn across two branches, so +the parity is asserted rather than remembered. + +Read with ast rather than imported: src/display_manager.py imports rgbmatrix +at module scope, which is absent anywhere without the panel library, and this +check should hold on a laptop and in CI as well as on a Pi. +""" + +import ast +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +REAL = ROOT / "src" / "display_manager.py" +DOUBLE = ROOT / "src" / "plugin_system" / "testing" / "visual_display_manager.py" + +#: Methods a plugin calls on whichever manager it is handed. +SHARED_METHODS = ["set_scrolling_state", "is_currently_scrolling"] + + +def _signature(path: Path, class_hint: str, method: str): + """(name, default-repr) pairs for `method`, or None if it is absent.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef) or class_hint not in node.name: + continue + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == method: + args = [a.arg for a in item.args.args if a.arg != "self"] + pad = [None] * (len(args) - len(item.args.defaults)) + defaults = pad + [ast.unparse(d) for d in item.args.defaults] + return list(zip(args, defaults)) + return None + + +@pytest.mark.parametrize("method", SHARED_METHODS) +def test_the_double_matches_production(method): + real = _signature(REAL, "DisplayManager", method) + double = _signature(DOUBLE, "DisplayManager", method) + + assert real is not None, f"DisplayManager lost {method}" + assert double is not None, \ + f"the test double is missing {method}, so every plugin using it fails to render" + assert double == real, ( + f"{method} has drifted: production takes {real}, the double takes {double}. " + "A double that is more permissive hides a production TypeError; one that " + "is less permissive fails plugins that are actually correct." + ) + + +def test_frame_hold_is_accepted_by_both(): + """The specific argument that has drifted twice.""" + for path, label in ((REAL, "DisplayManager"), (DOUBLE, "VisualTestDisplayManager")): + params = dict(_signature(path, "DisplayManager", "set_scrolling_state") or []) + assert "frame_hold" in params, f"{label} does not accept frame_hold" + assert params["frame_hold"] == "1", \ + f"{label} must default frame_hold to 1 so existing callers are unaffected"