"""Regression tests for the Gradio app composition layer. Per `docs/audits/audit_03_gradio_app_architecture.md` finding 3.2: "app.py should not import from shopstack.ui.screens directly. The composition layer should be a pure seam between the Gradio framework and the domain code." These tests enforce composition discipline and verify that the tab builder registry stays in sync with the module registry. """ from __future__ import annotations import re from pathlib import Path def test_app_py_does_not_import_screens(): """app.py must not import from shopstack.ui.screens (composition seam).""" app_source = (Path(__file__).resolve().parents[1] / "app.py").read_text() # Find all 'from shopstack.ui.screens import ...' lines pattern = re.compile( r"^\s*from\s+shopstack\.ui\.screens[\w.]*\s+import\s+", re.MULTILINE, ) matches = pattern.findall(app_source) assert not matches, ( f"app.py imports from shopstack.ui.screens — this violates the " f"composition-seam discipline. The composition layer should " f"only talk to sub-builders (in shopstack/ui/tabs/, " f"shopstack/ui/household_settings.py, etc.), not screens. " f"Found: {matches}" ) def test_app_py_does_not_have_business_logic(): """app.py should not contain domain-mutating business logic. A simple heuristic: no ``tools.do_*()`` calls (those mutate inventory, shopping lists, etc.) and no ``db.create_*()`` / ``db.update_*()`` / ``db.delete_*()`` calls (those are domain mutations). Read-only calls like ``db.get_locations()`` are acceptable at the composition layer because they're needed to populate dropdowns. If you need domain mutations in app.py, they belong in a sub-builder or a service. """ app_source = (Path(__file__).resolve().parents[1] / "app.py").read_text() # Disallowed business-logic patterns forbidden = [ # tools.* mutating calls re.compile(r"^[^#]*\btools\.(get_|create_|update_|delete_|consume_|add_|record_)", re.MULTILINE), # db.* mutating calls (NOT read-only get_/list_/query_) re.compile(r"^[^#]*\bdb\.(create_|update_|delete_|save_|insert_|set_)", re.MULTILINE), ] for pat in forbidden: matches = pat.findall(app_source) assert not matches, ( f"app.py contains business-mutating calls that should " f"be in a service or sub-builder. Pattern: {pat.pattern}. " f"Found: {matches}" ) def test_app_py_under_300_lines(): """app.py must stay under 300 lines. A growing app.py is a signal that concerns are not being extracted. Tab wiring is delegated to the builder registry (``shopstack.ui.tabs.registry``), so app.py should stay lean regardless of how many tabs exist. """ app_path = Path(__file__).resolve().parents[1] / "app.py" line_count = sum(1 for _ in app_path.open()) assert line_count < 300, ( f"app.py is {line_count} lines (> 300). Consider extracting " f"the next concern into its own sub-builder module. See " f"docs/audits/audit_03_gradio_app_architecture.md." ) def test_app_py_uses_registry_for_tabs(): """app.py must use build_all_tabs() for tab wiring, not individual imports. This is the positive control that verifies the registry-driven composition pattern is being followed. app.py should import ``build_all_tabs`` from the registry, not 21 individual ``build_*_tab`` functions. """ app_source = (Path(__file__).resolve().parents[1] / "app.py").read_text() assert "from shopstack.ui.tabs.registry import build_all_tabs" in app_source, ( "app.py must import build_all_tabs from the tab registry. " "Tab wiring should be registry-driven, not hardcoded." ) # Ensure no individual build_*_tab imports (they belong in the registry) pattern = re.compile( r"^\s*from\s+shopstack\.ui\.tabs\.(today|cookbook|basket|market|reconcile|memory|scanner|timeline|find_trail|photo_map|repair_inbox|nutrition_coach|store_mode|smart_basket|analytics|consumption|recipe|parser|community|market_intel|trip_advisor)\s+import\s+build_", re.MULTILINE, ) matches = pattern.findall(app_source) assert not matches, ( f"app.py imports individual build_*_tab functions from tab modules. " f"These belong in shopstack/ui/tabs/registry.py, not app.py. " f"Found: {matches}" ) # Essential composition helpers that must remain essential_imports = [ "build_all_tabs", "build_household_settings", "build_locale_save", "mount_pwa_static", "mount_sms_webhook", ] missing = [name for name in essential_imports if name not in app_source] assert not missing, ( f"app.py does not reference these essential composition helpers: {missing}." ) def test_app_py_guards_required_handles(): """app.py must assert that required tab handles are non-None. Per audit 2026-06-14 P0 finding: if build_today_tab or build_reconcile_tab returns None (or is silently skipped by the registry), app.py would crash with AttributeError deep inside the event wiring. This is a P0 guard. The guards must: - Check handles.get("today") and handles.get("reconcile") - Raise a clear RuntimeError with diagnostic context - Mention which builder is missing and where to look """ app_source = (Path(__file__).resolve().parents[1] / "app.py").read_text() # Check for the guard pattern required_patterns = [ ('handles.get("today")', "Today tab handle extraction"), ('handles.get("reconcile")', "Reconcile tab handle extraction"), ("RuntimeError", "Explicit runtime error (not silent AttributeError)"), ] missing_patterns = [name for pattern, name in required_patterns if pattern not in app_source] assert not missing_patterns, ( f"app.py is missing required handle guards: {missing_patterns}. " f"Per audit 2026-06-14, app.py must fail fast with a clear " f"RuntimeError if today or reconcile builders return None. " f"Without these guards, a broken tab builder would crash with " f"a confusing AttributeError deep in the event wiring." ) def test_app_py_refreshes_home_flow_on_household_switch(): """The household switch wiring must refresh the new home_flow handle. Added 2026-06-15 (Home screen review). The state-aware home flow panel (Home flow state machine) must re-render when the user switches households — otherwise a new household with different data would still show the old household's hero. The wiring was extracted from app.py into ``shopstack/ui/state/household_wiring.py`` in Pass 15; both files are checked here to keep the contract visible. """ app_source = (Path(__file__).resolve().parents[1] / "app.py").read_text() # The app composition layer must call the household-wiring # sub-builder, which in turn references the home_flow handle. assert "wire_household_handlers" in app_source, ( "app.py must call wire_household_handlers() to register " "the cross-tab event handlers (household switch, " "create-household, per-render refresh, JS shims)." ) wiring_source = ( Path(__file__).resolve().parents[1] / "shopstack/ui/state/household_wiring.py" ).read_text() assert "home_flow" in wiring_source, ( "shopstack/ui/state/household_wiring.py must reference " "'home_flow' so the state-aware hero re-renders when the " "active household changes." )