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:
@@ -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))
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user