Replace document navigation with native Streamlit state transitions, preserve dashboard contracts, and add regression plus authenticated browser evidence for Issue #9.
220 lines
9.1 KiB
Python
220 lines
9.1 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_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))
|