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_sidebar_monthly_settlement_serializes_canonical_page(monkeypatch): query = {"page": "요약", "month": "2026-06"} 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": "monthly_settlement", "month": "2026-06"} 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.overview_gateway, "get_ledger_db_status", lambda **kwargs: {"success": True}) 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.overview_gateway, "get_ledger_db_status", lambda **kwargs: {"success": True}) 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))