fix: address settlement display and documentation review findings
Rework Issue 18 / PR 19, source review 76: C11-F01 and D02-F02/D03-F03/D04-F04. Preserve the approved contract and add named display, schema, policy, ownership and signature regressions.
This commit is contained in:
@@ -252,3 +252,99 @@ def test_authenticated_dashboard_renders_saved_comprehensive_snapshot(tmp_path,
|
||||
assert conn.execute("SELECT name, sql FROM sqlite_master ORDER BY type,name").fetchall() == before
|
||||
assert conn.execute("SELECT * FROM asset_snapshot_runs ORDER BY id").fetchall() == before_rows
|
||||
assert conn.execute("SELECT * FROM settlement_cashflow_snapshots ORDER BY id").fetchall() == before_detail
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
def test_draft_cashflow_blocker_renders_safe_transaction_id_and_reason(monkeypatch):
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
|
||||
|
||||
# Public review contract: cashflow_blockers is a list of safe dictionaries;
|
||||
# these fields are emitted by the service, not renderer-invented `code`.
|
||||
draft = {"id": "red-draft", "month": "2026-09", "basis_date": "2026-09-30",
|
||||
"status": "active", "version": 1, "cashflow_policy": {"savings_categories": [], "category_decisions": []}}
|
||||
blocker = {"transaction_id": "txn-safe-17", "reason": "unknown cashflow classification requires an explicit decision"}
|
||||
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts",
|
||||
lambda *a, **k: {"success": True, "drafts": [draft]})
|
||||
monkeypatch.setattr(settlement_gateway, "get_settlement_draft",
|
||||
lambda *a, **k: {"success": True, "draft": draft, "items": [], "review": {"review_fingerprint": "fp"}})
|
||||
monkeypatch.setattr(settlement_gateway, "review_settlement_draft",
|
||||
lambda *a, **k: {"success": True, "version": 1, "review_fingerprint": "fp",
|
||||
"cashflow_blockers": [blocker], "cashflow_items": [], "missing_items": []})
|
||||
app = AppTest.from_string(
|
||||
"import streamlit as st\n"
|
||||
"from dashboard.page_views.settlement_drafts import render_settlement_drafts\n"
|
||||
"render_settlement_drafts(db_path='synthetic-unused.sqlite3', month='2026-09')",
|
||||
default_timeout=30,
|
||||
)
|
||||
app.run()
|
||||
assert not app.exception, f"synthetic renderer fixture failed before assertion: {app.exception}"
|
||||
next(button for button in app.button if button.label == "검토 / 새로고침 대상").click().run()
|
||||
assert not app.exception
|
||||
assert next(button for button in app.button if button.label == "검토 결과 승인").disabled
|
||||
shown = "\n".join(str(x.value) for x in list(app.warning) + list(app.error) + list(app.caption))
|
||||
assert "txn-safe-17" in shown and blocker["reason"] in shown, (
|
||||
"public renderer must show safe transaction_id and reason from each blocker; "
|
||||
"a failure here is the renderer dropping the declared public fields"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("comprehensive", [False, True])
|
||||
def test_saved_monthly_snapshot_renders_close_legacy_and_asset_evidence(monkeypatch, comprehensive):
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, reconciliation_gateway, settlement_gateway
|
||||
|
||||
# Mirrors get_monthly_settlement_snapshot's declared response envelope/keys.
|
||||
# Legacy/partial record is read-only; excluded assets carry reason in `items`.
|
||||
snapshot = {"source": "settlement_snapshot", "run_id": "saved-run-9", "snapshot_run_id": "saved-run-9",
|
||||
"settlement_month": "2026-09", "basis_date": "2026-09-30", "is_closed_snapshot": True,
|
||||
"is_comprehensive": False, "legacy": True, "aggregation_version": None,
|
||||
"snapshot_schema_version": None, "close_status": "legacy_read_only", "cashflow_items": [],
|
||||
"items": [{"source_type": "manual_asset", "source_id": "asset-opaque-3", "name": "Synthetic asset",
|
||||
"exclusion_reason": "valuation_missing", "verification_status": "unverified"}],
|
||||
"source_snapshot": {"transactions": [], "assets": []}, "totals": {},
|
||||
"income_total": None, "expense_total": None, "net_cashflow": None,
|
||||
"total_assets": None, "total_liabilities": None, "net_worth": None,
|
||||
"correction": {"is_correction": False, "correction_of_run_id": "", "correction_reason": ""}}
|
||||
if comprehensive:
|
||||
snapshot.update(is_comprehensive=True, legacy=False, aggregation_version="comprehensive-v1",
|
||||
snapshot_schema_version=1, close_status="conditional")
|
||||
for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"):
|
||||
snapshot[key] = 0
|
||||
snapshot["totals"] = {key:snapshot[key] for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth")}
|
||||
snapshot["source_snapshot"]["assets"] = [
|
||||
{"source_type":"account", "source_id":"missing-source-7", "status":"missing",
|
||||
"amount":None, "applied_amount":None, "valuation_id":None,
|
||||
"verification_status":"unverified", "exclusion_reason":""},
|
||||
{"source_type":"account", "source_id":"excluded-source-8", "status":"excluded",
|
||||
"amount":10, "applied_amount":None, "exclusion_reason":"synthetic exclusion",
|
||||
"unexpected_evidence":"not-public-evidence"},
|
||||
]
|
||||
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *a, **k: {"success": True, "month": "2026-09", "reports": {}})
|
||||
monkeypatch.setattr(overview_gateway, "list_cross_transfer_review_queue", lambda *a, **k: {"success": True, "items": []})
|
||||
monkeypatch.setattr(overview_gateway, "list_ledger_import_batches", lambda *a, **k: {"success": True, "import_batches": []})
|
||||
monkeypatch.setattr(overview_gateway, "get_asset_snapshot_report", lambda *a, **k: {"success": False})
|
||||
monkeypatch.setattr(reconciliation_gateway, "get_review_operating_indicators", lambda *a, **k: {"success": True, "indicators": {}})
|
||||
monkeypatch.setattr(reconciliation_gateway, "get_balance_reconciliation_report", lambda *a, **k: {"success": True, "items": []})
|
||||
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_snapshot", lambda *a, **k: {"success": True, **snapshot})
|
||||
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
|
||||
app = AppTest.from_string(
|
||||
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
|
||||
"page_monthly_settlement('synthetic-unused.sqlite3')", default_timeout=30)
|
||||
app.run()
|
||||
assert not app.exception, f"synthetic renderer fixture failed before assertion: {app.exception}"
|
||||
import json
|
||||
body = "\n".join(str(x.value) for x in list(app.caption) + list(app.markdown) + list(app.text) + list(app.warning) + list(app.info))
|
||||
body += "\n" + json.dumps([frame.value.to_dict("records") for frame in app.dataframe], ensure_ascii=False, default=str)
|
||||
assert snapshot["close_status"] in body and (comprehensive or "legacy" in body.lower()), (
|
||||
"saved snapshot renderer must expose close_status and legacy/partial provenance"
|
||||
)
|
||||
assert "asset-opaque-3" in body and "valuation_missing" in body and "제외" in body, (
|
||||
"saved snapshot renderer must expose each missing/excluded asset status and reason"
|
||||
)
|
||||
if comprehensive:
|
||||
assert "missing-source-7" in body and "missing" in body and "누락" in body
|
||||
assert "excluded-source-8" in body and "synthetic exclusion" in body
|
||||
assert "not-public-evidence" not in body
|
||||
assert not app.exception
|
||||
|
||||
@@ -182,3 +182,90 @@ def test_settlement_draft_runtime_cancel_and_recovery_contract() -> None:
|
||||
source = ast.parse(text("jarca_ledger_mcp/application/settlement_draft_service.py"))
|
||||
cancel = next(n for n in source.body if isinstance(n, ast.FunctionDef) and n.name == "cancel_settlement_draft")
|
||||
assert [arg.arg for arg in cancel.args.args] == ["draft_id", "expected_version", "cancelled_by", "reason", "db_path"]
|
||||
|
||||
|
||||
def _comprehensive_section(doc, heading):
|
||||
assert heading in doc, f"missing documented contract section: {heading}"
|
||||
return doc.split(heading, 1)[1].split("\n### ", 1)[0]
|
||||
|
||||
|
||||
def _comprehensive_table(section):
|
||||
rows = {}
|
||||
for line in section.splitlines():
|
||||
if not line.startswith("|") or re.match(r"^\|\s*:?-+", line):
|
||||
continue
|
||||
cells = [c.strip().strip("` ") for c in line.strip().strip("|").split("|")]
|
||||
if len(cells) == 4 and cells[0] not in {"Column", "Field", "field"}:
|
||||
rows[cells[0]] = cells[1:]
|
||||
return rows
|
||||
|
||||
|
||||
def test_settlement_cashflow_model_documents_schema_and_legacy_reproduction(tmp_path):
|
||||
import sqlite3
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
doc = (ROOT / "docs/data-model.md").read_text(encoding="utf-8")
|
||||
names = {
|
||||
"asset_snapshot_runs": ("settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"),
|
||||
"settlement_drafts": ("cashflow_policy_json", "aggregation_version"),
|
||||
"settlement_cashflow_snapshots": ("id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"),
|
||||
}
|
||||
database = tmp_path / "synthetic-doc-schema.sqlite3"
|
||||
init_db(str(database))
|
||||
with sqlite3.connect(database) as conn:
|
||||
for table, columns in names.items():
|
||||
section = _comprehensive_section(doc, f"### {table} cashflow field contract")
|
||||
observed = _comprehensive_table(section)
|
||||
ddl = {r[1]: [r[2], "NOT NULL" if r[3] else "NULL", r[4] if r[4] is not None else "none"] for r in conn.execute(f"PRAGMA table_info({table})") if r[1] in columns}
|
||||
assert observed == ddl, f"field types/nullability/defaults differ from actual DDL: {table}"
|
||||
reproduction = _comprehensive_section(doc, "### Comprehensive snapshot evidence inputs")
|
||||
for field in ("source_snapshot_json", "transactions", "assets", "transaction_date", "originalcashflow_kind", "applied_amount", "valuation_id", "valuation_date", "authority_source", "cashflow_policy_json", "legacy", "NULL"):
|
||||
assert field in reproduction, field
|
||||
for constraint in ("ON DELETE RESTRICT", "UNIQUE", "transaction_count > 0", "idx_settlement_cashflow_snapshots_run"):
|
||||
assert constraint in doc, constraint
|
||||
|
||||
|
||||
def test_settlement_cashflow_interface_specifies_valid_policy_review_and_response_examples():
|
||||
from jarca_ledger_mcp.application.settlement_cashflow_service import _normalize_cashflow_policy
|
||||
doc = (ROOT / "docs/interface-contract.md").read_text(encoding="utf-8")
|
||||
policy_section = _comprehensive_section(doc, "### Comprehensive cashflow policy input")
|
||||
match = re.search(r"```json\s*(.*?)\s*```", policy_section, re.S)
|
||||
assert match, "policy input needs an executable JSON example"
|
||||
policy = json.loads(match.group(1))
|
||||
assert _normalize_cashflow_policy(policy) == policy
|
||||
assert set(policy["savings_categories"][0]) == {"category", "subcategory"}
|
||||
assert set(policy["category_decisions"][0]) == {"category", "subcategory", "cashflow_kind", "reason"}
|
||||
for field in ("income", "expense", "excluded", "duplicate", "nonempty", "expected_version", "review_fingerprint", "aggregation_version", "comprehensive-v1"):
|
||||
assert field in policy_section, field
|
||||
outputs = _comprehensive_section(doc, "### Comprehensive settlement response contract")
|
||||
totals = _comprehensive_table(outputs)
|
||||
keys = ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth")
|
||||
assert set(keys) <= set(totals)
|
||||
for key in keys:
|
||||
assert totals[key] == ["integer", "required", "null"], key
|
||||
for field in ("cashflow_items", "cashflow_blockers", "transaction_id", "reason", "validation_error", "cashflow review blockers must be resolved", "final_response", "cashflow_policy", "source_snapshot", "refund"):
|
||||
assert field in outputs, field
|
||||
examples = [json.loads(s) for s in re.findall(r"```json\s*(.*?)\s*```", outputs, re.S)]
|
||||
review = next((x for x in examples if isinstance(x, dict) and "cashflow_blockers" in x and "review_fingerprint" in x), None)
|
||||
assert review is not None, "review response example must use actual root fields"
|
||||
assert review["success"] is True and all(type(review[key]) is int for key in keys)
|
||||
assert review["expense_total"] < 0, "signed-refund example must retain a negative expense total"
|
||||
assert all(set(x) == {"transaction_id", "reason"} for x in review["cashflow_blockers"])
|
||||
saved = next((x for x in examples if isinstance(x, dict) and x.get("source") == "settlement_snapshot"), None)
|
||||
assert saved is not None and saved["success"] is True and "run_id" in saved
|
||||
assert all(key in saved for key in keys)
|
||||
|
||||
|
||||
def test_settlement_cashflow_private_service_has_explicit_ownership_and_test_mapping():
|
||||
doc = (ROOT / "docs/project-structure.md").read_text(encoding="utf-8")
|
||||
rows = [line for line in doc.splitlines() if line.startswith("|") and "settlement_cashflow_service.py" in line]
|
||||
assert len(rows) == 1, "private aggregation owner must have an explicit responsibility row"
|
||||
for field in ("private", "_normalize_cashflow_policy", "_review_cashflow", "settlement_draft_service.py", "test_settlement_cashflow_policy.py", "test_comprehensive_settlement_adapters.py"):
|
||||
assert field in rows[0], field
|
||||
|
||||
def test_cashflow_policy_documented_signature_matches_public_api():
|
||||
from jarca_ledger_mcp import service
|
||||
import inspect
|
||||
section = _comprehensive_section((ROOT / "docs/interface-contract.md").read_text(), "### Comprehensive cashflow policy input")
|
||||
assert list(inspect.signature(service.set_settlement_cashflow_policy).parameters) == ["draft_id", "expected_version", "updated_by", "cashflow_policy", "db_path"]
|
||||
assert "set_settlement_cashflow_policy(draft_id, expected_version, updated_by, cashflow_policy, db_path=None)" in section
|
||||
assert "set_settlement_draft_cashflow_policy(" not in section
|
||||
|
||||
Reference in New Issue
Block a user