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:
2026-10-13 13:09:16 +00:00
parent 616bb7a744
commit ae4383ca2c
7 changed files with 296 additions and 3 deletions
@@ -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
+87
View File
@@ -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