hermes-hub/tests/test_view_startup_contract.py
Hermes Team a9c04b75a2 fix(ui): repair startup crash in TeamView and tighten the headless guard
Launching the app died with "'dict' object has no attribute 'readiness'".
TeamView.__init__ forwarded its legacy app_state dict into update_data(snapshot),
whose guard only handled None, so {} reached snapshot.readiness. TeamView is the
default view, so the window never appeared.

Fixes the call site and hardens every snapshot guard to fall back on anything
that is not a HubSnapshot. Adds tests/test_view_startup_contract.py, which
checks both conditions statically and therefore runs headless; verified to fail
on the pre-fix sources.

The GUI-import invariant previously matched the literal string "customtkinter",
which missed modules pulling it transitively. Rewritten over the AST, it
immediately found test_codex_opencode_wizard.py importing router.ui.components
without pytest.importorskip — the same defect that took the release gate down
once before, hidden until now behind conftest's name-based skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:22:19 +07:00

71 lines
3.1 KiB
Python

"""Startup contract for UI views — runs without customtkinter.
Guards the crash that took the app down on launch:
TeamView.__init__ forwarded its legacy ``app_state`` dict into
``update_data(snapshot)``. The guard there only handled ``None``, so ``{}``
slipped through and ``snapshot.readiness`` raised
``'dict' object has no attribute 'readiness'``. "Команда" is the default
view, so the failure happened before the window ever appeared.
These checks are static: they read the view sources rather than build widgets,
so they run in headless environments where the GUI toolkit is absent.
"""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
VIEWS_DIR = Path(__file__).resolve().parent.parent / "src" / "antigravity_provider" / "router" / "ui" / "views"
def _view_files() -> list[Path]:
return sorted(VIEWS_DIR.glob("*_view.py"))
def _self_update_data_calls(tree: ast.AST, inside: str) -> list[ast.Call]:
"""Return self.update_data(...) calls made from the named method."""
calls: list[ast.Call] = []
for cls in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
for fn in (n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == inside):
for node in ast.walk(fn):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "update_data"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "self"
):
calls.append(node)
return calls
@pytest.mark.unit
@pytest.mark.parametrize("view_path", _view_files(), ids=lambda p: p.stem.replace("_view", ""))
def test_constructor_does_not_forward_app_state_to_update_data(view_path: Path) -> None:
"""A view constructor must not pass its legacy app_state into update_data."""
tree = ast.parse(view_path.read_text(encoding="utf-8"))
for call in _self_update_data_calls(tree, inside="__init__"):
assert not call.args and not call.keywords, (
f"{view_path.name}: __init__ calls self.update_data(...) with an argument. "
"update_data expects a HubSnapshot; constructors hold app_state dicts. "
"Call self.update_data() and let it pull the current snapshot."
)
@pytest.mark.unit
@pytest.mark.parametrize("view_path", _view_files(), ids=lambda p: p.stem.replace("_view", ""))
def test_update_data_guard_rejects_non_snapshot(view_path: Path) -> None:
"""update_data must fall back on anything that is not a HubSnapshot, not just None."""
source = view_path.read_text(encoding="utf-8")
if "def update_data" not in source or "snapshot" not in source:
pytest.skip("view has no snapshot-driven update_data")
if "HubStateStore.get().get_snapshot()" not in source:
pytest.skip("view does not read the snapshot store")
assert "isinstance(snapshot, HubSnapshot)" in source, (
f"{view_path.name}: update_data guards with `snapshot is None` only. "
"A legacy dict passes that check and then fails on attribute access."
)