"""Structural rules for the NiceGUI page layer. `.github/instructions/ui.instructions.md:23,31` forbids pages from owning persistence or query-building concerns, and forbids components from resolving request or application state. HIGH-07 recorded three violations of those rules; these tests keep them from coming back. """ from __future__ import annotations import ast from pathlib import Path UI_DIR = Path(__file__).resolve().parents[1] / "src" / "transcription" / "ui" PAGES_DIR = UI_DIR / "pages" COMPONENTS_DIR = UI_DIR / "components" FORBIDDEN_PAGE_MODULES = frozenset({"sqlalchemy", "sqlmodel"}) # Sensitive modules are allowlisted, not blocklisted, so newly added persistence # helpers cannot slip through by using an unlisted name. PAGE_IMPORT_ALLOWLIST: dict[tuple[str, int], frozenset[str]] = { ("db.session", 3): frozenset({"SessionFactoryDep"}), ("transcription.config", 0): frozenset({"Settings"}), } def _page_paths() -> list[Path]: return sorted(path for path in PAGES_DIR.glob("*.py") if path.stem != "__init__") def _component_paths() -> list[Path]: return sorted(COMPONENTS_DIR.rglob("*.py")) def _imports(tree: ast.Module) -> tuple[set[str], set[str]]: """Return (imported names, imported root modules).""" names: set[str] = set() modules: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.ImportFrom): if node.module and node.level == 0: modules.add(node.module.split(".")[0]) names.update(alias.name for alias in node.names) elif isinstance(node, ast.Import): for alias in node.names: modules.add(alias.name.split(".")[0]) names.add(alias.name) return names, modules def _page_allowlist_violations(tree: ast.Module) -> set[str]: violations: set[str] = set() for node in ast.walk(tree): if not isinstance(node, ast.ImportFrom) or not node.module: continue allowed = PAGE_IMPORT_ALLOWLIST.get((node.module, node.level)) if allowed is None: continue for alias in node.names: imported_name = alias.name if imported_name not in allowed: violations.add(f"{'.' * node.level}{node.module}.{imported_name}") return violations def test_page_modules_are_discovered(): """Guard the guard: the rules below are meaningless if nothing is scanned.""" discovered = {path.stem for path in _page_paths()} assert discovered >= {"documents_page", "jobs_page", "people_page", "sources_page"} def test_no_page_imports_persistence_or_process_globals(): """HIGH-07: pages orchestrate services; they do not own sessions or settings.""" violations: dict[str, list[str]] = {} for path in _page_paths(): tree = ast.parse(path.read_text(encoding="utf-8")) _, modules = _imports(tree) allowlist_violations = _page_allowlist_violations(tree) found = sorted((modules & FORBIDDEN_PAGE_MODULES) | allowlist_violations) if found: violations[path.stem] = found assert violations == {} def test_no_component_resolves_request_or_application_state(): """`ui.instructions.md:31`: components render, they do not resolve app state.""" violations: dict[str, list[str]] = {} for path in _component_paths(): names, modules = _imports(ast.parse(path.read_text(encoding="utf-8"))) forbidden_names = {"get_settings", "get_engine", "session_scope", "transaction_scope"} found = sorted((names & forbidden_names) | (modules & (FORBIDDEN_PAGE_MODULES | {"fastapi"}))) if found: violations[str(path.relative_to(COMPONENTS_DIR))] = found assert violations == {} # `build_table` owns the interactive table styling; `print_preview_page` owns the # print-only table, which must never paginate or expose a search box. TABLE_OWNERS = frozenset({"components/table/common.py", "pages/print_preview_page.py"}) def _calls_ui_table(tree: ast.Module) -> bool: for node in ast.walk(tree): if not isinstance(node, ast.Call): continue func = node.func if ( isinstance(func, ast.Attribute) and func.attr == "table" and isinstance(func.value, ast.Name) and func.value.id == "ui" ): return True return False def test_only_the_designated_owners_construct_a_raw_table(): """Review section 4: table styling lives in one place, not in every page.""" offenders = sorted( str(path.relative_to(UI_DIR)).replace("\\", "/") for path in UI_DIR.rglob("*.py") if _calls_ui_table(ast.parse(path.read_text(encoding="utf-8"))) ) assert set(offenders) == TABLE_OWNERS def _notifies_negative(tree: ast.Module) -> bool: """Return True if the module calls ``ui.notify(..., type="negative")``.""" for node in ast.walk(tree): if not isinstance(node, ast.Call): continue func = node.func if not ( isinstance(func, ast.Attribute) and func.attr == "notify" and isinstance(func.value, ast.Name) and func.value.id == "ui" ): continue for keyword in node.keywords: if keyword.arg == "type" and isinstance(keyword.value, ast.Constant) and keyword.value.value == "negative": return True return False def test_no_page_hand_rolls_error_notifications(): """HIGH-02: `ui.instructions.md:42` routes all error display through error_presenter. Hand-rolled ``ui.notify(str(exc), type="negative")`` discards the correlation ``error_id``, the canonical category, and the actionable suggestion that ``show_error`` renders, leaving the user with nothing to report. Eight such sites existed in ``home_page`` and ``people_page``; this keeps them from returning. """ offenders = sorted( path.stem for path in _page_paths() if _notifies_negative(ast.parse(path.read_text(encoding="utf-8"))) ) assert offenders == [], f"Pages must render errors via error_presenter.show_error, not ui.notify: {offenders}"