Files
jarca-ledger-mcp/tests/test_dashboard_menu_transition.py

234 lines
9.9 KiB
Python

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))