fix(dashboard): keep shell during menu transitions

Replace document navigation with native Streamlit state transitions, preserve dashboard contracts, and add regression plus authenticated browser evidence for Issue #9.
This commit is contained in:
2026-10-05 00:49:28 +00:00
parent ffd943fe65
commit 96e11812ce
13 changed files with 2166 additions and 51 deletions
+219
View File
@@ -0,0 +1,219 @@
from __future__ import annotations
from pathlib import Path
import sys
import pytest
from streamlit.testing.v1 import AppTest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dashboard import app, authentication, common
from scripts.ci_fixture_db import create_ci_fixture_db
PAGES = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
def test_dashboard_menu_labels_and_order_are_preserved(monkeypatch):
calls = {}
def radio(label, options, **kwargs):
calls.update(label=label, options=list(options), kwargs=kwargs)
return "요약"
monkeypatch.setattr(common.st, "radio", radio)
assert common.render_sidebar_nav(PAGES, "요약") == "요약"
assert calls["options"] == PAGES
assert calls["kwargs"]["key"] == "dashboard_page"
assert calls["kwargs"]["label_visibility"] == "collapsed"
def test_sidebar_selection_updates_only_page_query_parameter(monkeypatch):
query = {"page": "요약", "month": "2026-06", "account_id": "checking"}
state = {"dashboard_page": "거래내역"}
callback = {}
monkeypatch.setattr(common.st, "query_params", query)
monkeypatch.setattr(common.st, "session_state", state)
monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"])
common.render_sidebar_nav(PAGES, "요약")
callback["fn"]()
assert query == {"page": "거래내역", "month": "2026-06", "account_id": "checking"}
def test_missing_or_unknown_page_falls_back_to_overview():
assert common.validate_dashboard_page(None, PAGES) == "요약"
assert common.validate_dashboard_page("not-a-page", PAGES) == "요약"
assert common.validate_dashboard_page("거래내역", PAGES) == "거래내역"
def test_direct_page_query_access_renders_requested_page(monkeypatch):
selected = {}
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True)
monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3")
monkeypatch.setattr(app, "inject_app_css", lambda: None)
monkeypatch.setattr(app.st, "query_params", {"page": "계좌"})
monkeypatch.setattr(app.st, "session_state", {"dashboard_page": "요약"})
def capture_sidebar_page(pages, page):
selected["sidebar_page"] = page
selected["session_page"] = app.st.session_state["dashboard_page"]
return page
monkeypatch.setattr(app, "render_sidebar_nav", capture_sidebar_page)
monkeypatch.setattr(app.st, "sidebar", pytest.importorskip("contextlib").nullcontext())
monkeypatch.setattr(app.st, "button", lambda *args, **kwargs: False)
monkeypatch.setattr(app, "page_accounts", lambda db: selected.setdefault("db", db))
for name in PAGES:
if name != "계좌":
monkeypatch.setattr(app, {"월정산": "page_monthly_settlement", "요약": "page_overview", "자산/마감": "page_assets", "거래내역": "page_transactions", "확인필요 큐": "page_cross_transfer_review", "미분류": "page_uncategorized", "분류 규칙": "page_rules", "카테고리": "page_categories", "Import": "page_imports"}[name], lambda db: None)
monkeypatch.setattr(app, "render_dashboard_body", lambda page, renderer: renderer())
app.main()
assert selected == {
"sidebar_page": "계좌",
"session_page": "계좌",
"db": "fixture.sqlite3",
}
def test_streamlit_menu_click_updates_body_and_preserves_page_query_siblings(tmp_path, monkeypatch):
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
settings = authentication.load_auth_settings()
assert settings is not None
token = authentication.issue_persistent_token(settings, remember_me=False).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
at = AppTest.from_file(str(app_path), default_timeout=10)
at.query_params["page"] = "요약"
at.query_params["month"] = "2026-06"
at.run()
at.radio(key="dashboard_page").set_value("분류 규칙").run()
assert not at.exception
assert at.query_params["page"] == ["분류 규칙"]
assert at.query_params["month"] == ["2026-06"]
body_text = "\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption))
assert "현재 분류 규칙" in body_text
def test_slow_transition_clears_previous_body_and_shows_target_loading(monkeypatch):
events = []
monkeypatch.setattr(common.st, "empty", lambda: _FakePlaceholder(events))
monkeypatch.setattr(common.st, "container", lambda **kwargs: _FakeKeyedContainer(events, kwargs["key"]))
monkeypatch.setattr(common.st, "info", lambda message: events.append(("info", message)))
common.render_dashboard_body("거래내역", lambda: events.append(("render", "거래내역")))
assert events[0] == ("empty", "placeholder")
assert ("open", "dashboard_body") in events
assert ("open", "dashboard_loading") in events
assert ("open", "dashboard_content") in events
assert events.index(("info", "거래내역 불러오는 중…")) < events.index(("render", "거래내역"))
assert events.index(("render", "거래내역")) < len(events) - 1
assert events.count(("empty", "placeholder")) >= 2
def test_body_error_is_local_and_retry_rerenders_target(monkeypatch):
events = []
monkeypatch.setattr(common.st, "empty", lambda: _FakePlaceholder(events))
monkeypatch.setattr(common.st, "container", lambda **kwargs: _FakeKeyedContainer(events, kwargs["key"]))
monkeypatch.setattr(common.st, "info", lambda message: events.append(("info", message)))
monkeypatch.setattr(common.st, "error", lambda message: events.append(("error", message)))
monkeypatch.setattr(common.st, "button", lambda *args, **kwargs: events.append(("retry", args[0])) or True)
monkeypatch.setattr(common.st, "rerun", lambda: events.append(("rerun", "target")))
common.render_dashboard_body("요약", lambda: (_ for _ in ()).throw(RuntimeError("secret details")))
assert ("error", "페이지를 불러오지 못했습니다. 다시 시도해 주세요.") in events
assert ("rerun", "target") in events
assert all("secret details" not in str(event) for event in events)
def test_rapid_menu_clicks_commit_latest_selection_only(monkeypatch):
query = {"page": "요약", "filter": "pending"}
state = {"dashboard_page": "요약"}
callback = {}
monkeypatch.setattr(common.st, "query_params", query)
monkeypatch.setattr(common.st, "session_state", state)
monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"])
common.render_sidebar_nav(PAGES, "요약")
for page in ("거래내역", "자산/마감", "Import"):
state["dashboard_page"] = page
callback["fn"]()
assert query == {"page": "Import", "filter": "pending"}
def test_refresh_control_preserves_cache_clear_contract(monkeypatch):
events = []
class ExpectedRerun(BaseException):
pass
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True)
monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3")
monkeypatch.setattr(app, "inject_app_css", lambda: None)
monkeypatch.setattr(app.st, "query_params", {"page": "요약"})
monkeypatch.setattr(app, "render_sidebar_nav", lambda pages, page: page)
monkeypatch.setattr(app.st, "sidebar", pytest.importorskip("contextlib").nullcontext())
monkeypatch.setattr(app.st, "button", lambda *args, **kwargs: True)
monkeypatch.setattr(app, "clear_cache", lambda: events.append("clear_cache"))
def rerun():
events.append("rerun")
raise ExpectedRerun
monkeypatch.setattr(app.st, "rerun", rerun)
with pytest.raises(ExpectedRerun):
app.main()
assert events == ["clear_cache", "rerun"]
class _FakePlaceholder:
def __init__(self, events):
self.events = events
def container(self):
return _FakeContainer(self.events)
def info(self, message):
self.events.append(("info", message))
def empty(self):
self.events.append(("empty", "placeholder"))
class _FakeContainer:
def __init__(self, events):
self.events = events
def __enter__(self):
self.events.append(("container", "open"))
return self
def __exit__(self, exc_type, exc, tb):
self.events.append(("container", "close"))
return False
class _FakeKeyedContainer:
def __init__(self, events, key):
self.events = events
self.key = key
def __enter__(self):
self.events.append(("open", self.key))
return self
def __exit__(self, exc_type, exc, tb):
self.events.append(("close", self.key))
return False
def empty(self):
self.events.append(("empty", self.key))
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
import json
import ssl
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import streamlit_browser_evidence as evidence
SECRET_VALUES = ("synthetic-password", "a" * 64, "cookie-value")
def test_redact_secrets_removes_values_from_nested_manifest() -> None:
payload = {"url": "https://127.0.0.1/?token=cookie-value", "nested": ["synthetic-password", {"key": "a" * 64}]}
redacted = evidence.redact_secrets(payload, SECRET_VALUES)
serialized = json.dumps(redacted)
assert all(secret not in serialized for secret in SECRET_VALUES)
assert redacted["nested"][0] == "[REDACTED]"
def test_transition_requires_synthetic_fixture_database(tmp_path: Path) -> None:
with pytest.raises(SystemExit, match="fixture.sqlite3"):
evidence.assert_synthetic_db_path(tmp_path / "operating.sqlite3")
def test_validate_transition_manifest_requires_observations() -> None:
manifest = {
"mode": "transition",
"success": True,
"observations": {"frames": [], "navigation_events": []},
}
with pytest.raises(ValueError, match="frames"):
evidence.validate_transition_manifest(manifest)
def complete_manifest() -> dict[str, object]:
return {
"mode": "transition",
"success": True,
"observations": {
"frames": [
{
"at_ms": 0,
"selected_menu": "요약",
"body_text_hash": "hash",
"loading": False,
"prior_body_visible": True,
},
{
"at_ms": 100,
"selected_menu": "분류 규칙",
"body_text_hash": "hash2",
"loading": True,
"prior_body_visible": False,
},
],
"document_navigation_events": [],
"history_events": [{"type": "pushState", "url": "/?page=rules"}],
"stable_shell": {
"document_marker": {"pre": "doc-1", "post": "doc-1"},
"sidebar_marker": {"pre": "side-1", "post": "side-1"},
"background": {"pre": "rgb(255, 255, 255)", "post": "rgb(255, 255, 255)"},
},
"loading_observed": True,
"prior_body_removed": True,
"target_page": "분류 규칙",
"final_agreement": True,
},
"runtime": {
"viewport": {"width": 1440, "height": 1400},
"interval_ms": 50,
"host_architecture": "aarch64",
"browser_user_agent": "Synthetic Browser",
},
"source_revision": evidence.current_git_revision(),
"source_revision_kind": "git-head-base",
"source_worktree_dirty": evidence.git_worktree_dirty(("dashboard/app.py", "dashboard/common.py")),
"source_files": evidence.source_file_digests(("dashboard/app.py", "dashboard/common.py")),
"evidence_command": ["scripts/streamlit_browser_evidence.py", "--mode", "transition"],
"command": {"exit_code": 0, "teardown": "complete"},
"limitations": [],
}
def test_validate_transition_manifest_accepts_complete_secret_free_shape() -> None:
evidence.validate_transition_manifest(complete_manifest())
def test_wait_for_http_uses_verified_tls_by_default_and_fixture_context_opt_in() -> None:
response = type("Response", (), {"status": 200, "__enter__": lambda self: self, "__exit__": lambda *args: None})()
with patch.object(evidence.urllib.request, "urlopen", return_value=response) as urlopen:
evidence.wait_for_http("https://example.test")
assert urlopen.call_args.kwargs["context"] is None
fixture_context = ssl._create_unverified_context()
with patch.object(evidence.urllib.request, "urlopen", return_value=response) as urlopen:
evidence.wait_for_http("https://127.0.0.1", ssl_context=fixture_context)
assert urlopen.call_args.kwargs["context"] is fixture_context
def test_validate_transition_manifest_rejects_fabricated_navigation_event() -> None:
manifest = complete_manifest()
manifest["observations"]["document_navigation_events"] = [{"type": "load", "url": "/"}]
with pytest.raises(ValueError, match="document navigation"):
evidence.validate_transition_manifest(manifest)
def test_validate_transition_manifest_requires_observed_loading_without_prior_body() -> None:
no_loading = complete_manifest()
no_loading["observations"]["loading_observed"] = False
with pytest.raises(ValueError, match="loading"):
evidence.validate_transition_manifest(no_loading)
stale_body = complete_manifest()
stale_body["observations"]["frames"][1]["prior_body_visible"] = True
with pytest.raises(ValueError, match="prior body"):
evidence.validate_transition_manifest(stale_body)
def test_validate_transition_manifest_requires_pre_post_shell_identity() -> None:
manifest = complete_manifest()
manifest["observations"]["stable_shell"] = {"sidebar": True, "background": "#fff"}
with pytest.raises(ValueError, match="shell"):
evidence.validate_transition_manifest(manifest)
def test_validate_transition_manifest_rejects_unselected_target_body_agreement() -> None:
manifest = complete_manifest()
manifest["observations"]["final_agreement"] = False
with pytest.raises(ValueError, match="agreement"):
evidence.validate_transition_manifest(manifest)
def test_validate_transition_manifest_rejects_attestation_before_teardown() -> None:
manifest = complete_manifest()
manifest["command"] = {"exit_code": 0, "teardown": "running"}
with pytest.raises(ValueError, match="teardown"):
evidence.validate_transition_manifest(manifest)
def test_validate_transition_manifest_requires_exact_source_and_runtime_identity() -> None:
manifest = complete_manifest()
manifest.pop("source_files")
with pytest.raises(ValueError, match="source files"):
evidence.validate_transition_manifest(manifest)
manifest = complete_manifest()
manifest["runtime"].pop("browser_user_agent")
with pytest.raises(ValueError, match="runtime identity"):
evidence.validate_transition_manifest(manifest)
manifest = complete_manifest()
manifest["source_files"]["dashboard/app.py"] = "0" * 64
with pytest.raises(ValueError, match="source file digests"):
evidence.validate_transition_manifest(manifest)
def test_transition_manifest_rejects_secret_text() -> None:
manifest = {"mode": "transition", "success": False, "error": "synthetic-password"}
with pytest.raises(ValueError, match="secret"):
evidence.validate_transition_manifest(manifest, SECRET_VALUES)