shopstack / tests /test_app_composition.py
pranaysuyash's picture
Sync ShopStack 2026-06-15: corrections panel, empty-state rewrite, market-source suppression
8294cde verified
Raw
History Blame Contribute Delete
7.78 kB
"""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."
)