baodan/tests/output_reconciliation_test.py
2026-08-04 10:44:34 +08:00

368 lines
13 KiB
Python

import sys
import zipfile
import hashlib
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "api"))
def _pptx(path: Path, text: str):
xml = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
f'<a:t>{text}</a:t></p:sld>'
)
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("ppt/slides/slide1.xml", xml)
def test_pptx_reconciliation_passes_formatted_equivalent_numbers(tmp_path):
from insurance.generation.reconciliation import reconcile_pptx
output = tmp_path / "ok.pptx"
_pptx(output, "第10年 HK$ 650,000")
report = reconcile_pptx({"slides": [{"text": "第10年 HK$ 650000"}]}, str(output))
assert report["status"] == "passed"
assert report["missingNumbers"] == []
def test_pptx_reconciliation_fails_closed_on_missing_amount(tmp_path):
from insurance.generation.reconciliation import reconcile_pptx
output = tmp_path / "bad.pptx"
_pptx(output, "第10年 HK$ 600,000")
report = reconcile_pptx({"slides": [{"text": "第10年 HK$ 650,000"}]}, str(output))
assert report["status"] == "failed"
assert "OUTPUT_AMOUNT_UNEXPECTED" in report["errors"]
assert "650000" in report["missingNumbers"]
assert "600000" in report["unexpectedNumbers"]
def test_migration_039_adds_task_freeze_and_audit_columns():
from flask import Flask
from sqlalchemy import inspect, text
from insurance.db.database import db
from insurance.db.migrate_039 import migrate
app = Flask(__name__)
app.config.update(
SQLALCHEMY_DATABASE_URI="sqlite:///:memory:",
SQLALCHEMY_TRACK_MODIFICATIONS=False,
)
db.init_app(app)
with app.app_context():
db.session.execute(text("CREATE TABLE insurance_generation_tasks (id VARCHAR(36) PRIMARY KEY)"))
db.session.commit()
migrate()
columns = {item["name"] for item in inspect(db.engine).get_columns("insurance_generation_tasks")}
assert {
"snapshot_id", "snapshot_hash", "renderer_version", "submit_revision",
"output_reconciliation_json", "visual_check_json", "result_state",
} <= columns
def test_poster_render_document_reconciliation_blocks_invented_amount():
from insurance.generation.reconciliation import reconcile_render_document
projection = {"currency": "HKD", "annual_premium": 100000, "surrender_value_10": 650000}
valid = reconcile_render_document(projection, {
"copy": {"title": "年缴 HK$ 100,000"},
"benefits": [{"year": 10, "value": 650000}],
})
invalid = reconcile_render_document(projection, {
"copy": {"title": "年缴 HK$ 120,000"},
})
assert valid["status"] == "passed"
assert invalid["status"] == "failed"
assert "120000" in invalid["unexpectedNumbers"]
def test_manifest_reconciliation_reports_missing_field_key(tmp_path):
from insurance.generation.reconciliation import reconcile_pptx
output = tmp_path / "missing.pptx"
_pptx(output, "方案摘要")
report = reconcile_pptx({
"reconciliationManifest": [{
"key": "products.0.policy.annualPremium",
"fieldPath": "policy.annualPremium",
"pageTypes": ["policy_summary"],
"currency": "HKD",
"value": 100000,
}],
}, str(output))
assert report["status"] == "failed"
assert "OUTPUT_AMOUNT_MISSING" in report["errors"]
assert report["missingEntries"][0]["fieldPath"] == "policy.annualPremium"
def test_manifest_reconciliation_allows_frozen_derived_amounts(tmp_path):
from insurance.generation.reconciliation import reconcile_pptx
output = tmp_path / "derived.pptx"
_pptx(output, "USD 100,000 USD 50,000")
contract = {
"products": [{"policy": {"currency": "USD", "annualPremium": 100000}}],
"allowedDerivedValues": [{"code": "portfolioAnnualRemainder", "value": 50000}],
"reconciliationManifest": [{
"key": "products.0.policy.annualPremium",
"fieldPath": "policy.annualPremium",
"pageTypes": ["policy_summary"],
"currency": "USD",
"value": 100000,
}],
}
report = reconcile_pptx(contract, str(output))
assert report["status"] == "passed"
assert report["unexpectedNumbers"] == []
_pptx(output, "USD 100,000 USD 60,000")
invalid = reconcile_pptx(contract, str(output))
assert invalid["status"] == "failed"
assert "60000" in invalid["unexpectedNumbers"]
def test_deck_contract_freezes_comprehensive_plan_derived_amounts():
from insurance.ppt.renderer import _build_deck_contract
savings = {
"kind": "savings",
"policy": {"currency": "USD", "contractualTotalPremium": 750000},
"benefitRows": [{
"policyYear": 10,
"reversionaryBonus": 120000,
"terminalDividend": 80000,
}],
"withdrawalRows": [{
"policyYear": 10,
"annualWithdrawal": 200000,
"cumulativeWithdrawal": 400000,
"surrenderValueAfter": 600000,
}],
}
iul = {
"kind": "iul",
"policy": {
"currency": "USD",
"annualPremium": 150000,
"contractualTotalPremium": 1500000,
},
"benefitRows": [],
"withdrawalRows": [],
}
deck = _build_deck_contract(savings, all_products=[savings, iul])
derived = {item["code"]: item["value"] for item in deck["allowedDerivedValues"]}
assert derived["nonGuaranteedBenefit"] == 200000
assert derived["withdrawalEconomicTotal"] == 1000000
assert derived["aggregateTotalPremium"] == 2250000
assert derived["portfolioAnnualRemainder"] == 50000
def test_comprehensive_ppt_derived_amounts_pass_output_reconciliation(tmp_path):
pytest.importorskip("pptx")
from insurance.generation.reconciliation import reconcile_pptx
from insurance.ppt.renderer import _build_deck_contract
from insurance.ppt.scripts.fast_pptx_renderer import render_deck
years = [1, 5, 10, 20, 30]
savings = {
"kind": "savings",
"productName": "储蓄计划",
"insured": {"name": "客户", "age": 45, "gender": "male"},
"policy": {
"currency": "USD",
"annualPremium": 150000,
"payYears": 5,
"contractualTotalPremium": 750000,
},
"benefitRows": [{
"policyYear": year,
"age": 45 + year,
"totalPremiumPaid": min(year, 5) * 150000,
"guaranteedCashValue": year * 70000,
"reversionaryBonus": year * 10000,
"terminalDividend": year * 5000,
"totalSurrenderValue": year * 120000,
"sourcePage": 8,
} for year in years],
"withdrawalRows": [{
"policyYear": year,
"age": 45 + year,
"annualWithdrawal": 200000,
"cumulativeWithdrawal": year * 200000,
"surrenderValueAfter": year * 80000,
} for year in [10, 20, 30]],
}
iul = {
"kind": "iul",
"productName": "IUL 计划",
"insured": {"name": "客户", "age": 45, "gender": "male"},
"policy": {
"currency": "USD",
"annualPremium": 150000,
"payYears": 10,
"contractualTotalPremium": 1500000,
"sumInsured": 2000000,
},
"benefitRows": [{
"policyYear": year,
"age": 45 + year,
"totalPremiumPaid": min(year, 10) * 150000,
"guaranteedCashValue": year * 60000,
"totalSurrenderValue": year * 100000,
"nonGuaranteedDeathBenefit": 2000000 + year * 10000,
"sourcePage": 10,
} for year in years],
"withdrawalRows": [],
}
deck = _build_deck_contract(
savings,
all_products=[savings, iul],
scenario="savings_iul_comprehensive",
generation_mode="portfolio",
)
output = tmp_path / "comprehensive.pptx"
result = render_deck(deck, str(output))
report = reconcile_pptx(deck, str(output))
assert result["ok"] is True
assert report["status"] == "passed", report
def test_final_poster_export_reconciles_dom_sections_text_and_png_size():
from insurance.generation.reconciliation import reconcile_poster_export
visible_text = "专属保障方案 35岁 HKD 100,000 联系顾问"
document = {
"formatId": "single_2_3",
"revision": 1,
"copy": {"headline": "专属保障方案", "call_to_action": "联系顾问"},
"summary": {"age": 35, "currency": "HKD", "annual_premium": 100000},
"sections": [
{"id": "hero", "visible": True},
{"id": "summary", "visible": True},
{"id": "cta", "visible": True},
],
}
manifest = {
"version": "poster-export-manifest-v1",
"formatId": "single_2_3",
"documentRevision": 1,
"visibleText": visible_text,
"visibleTextSha256": hashlib.sha256(visible_text.encode("utf-8")).hexdigest(),
"canvas": {
"scrollWidth": 512, "scrollHeight": 768, "pixelRatio": 2,
},
"sections": [
{"id": "hero", "present": True, "textLength": 6},
{"id": "summary", "present": True, "textLength": 16},
{"id": "cta", "present": True, "textLength": 4},
],
"overflowIssues": [],
}
report = reconcile_poster_export(
{"age": 35, "currency": "HKD", "annual_premium": 100000},
document,
manifest,
{"width": 1024, "height": 1536, "format": "PNG"},
)
assert report["status"] == "passed"
assert report["errors"] == []
assert report["domOutput"] == {"width": 1024, "height": 1536}
def test_final_poster_export_does_not_require_hidden_optional_benefits():
from insurance.generation.reconciliation import reconcile_poster_export
visible_text = "专属保障方案 35岁 HKD 100,000 联系顾问"
document = {
"formatId": "single_2_3",
"revision": 1,
"copy": {"headline": "专属保障方案", "call_to_action": "联系顾问"},
"summary": {"age": 35, "currency": "HKD", "annual_premium": 100000},
"sections": [
{"id": "hero", "visible": True},
{"id": "summary", "visible": True},
{"id": "benefits", "visible": False},
{"id": "features", "visible": False},
{"id": "cta", "visible": True},
],
}
manifest = {
"version": "poster-export-manifest-v1",
"formatId": "single_2_3",
"documentRevision": 1,
"visibleText": visible_text,
"visibleTextSha256": hashlib.sha256(visible_text.encode("utf-8")).hexdigest(),
"canvas": {"scrollWidth": 512, "scrollHeight": 768, "pixelRatio": 2},
"sections": [
{"id": "hero", "present": True, "textLength": 6},
{"id": "summary", "present": True, "textLength": 16},
{"id": "cta", "present": True, "textLength": 4},
],
"overflowIssues": [],
}
report = reconcile_poster_export(
{"age": 35, "currency": "HKD", "annual_premium": 100000},
document,
manifest,
{"width": 1024, "height": 1536, "format": "PNG"},
)
assert report["status"] == "passed"
assert report["missingSections"] == []
def test_final_poster_export_blocks_missing_section_overflow_and_wrong_size():
from insurance.generation.reconciliation import reconcile_poster_export
visible_text = "标题 999,999"
report = reconcile_poster_export(
{"annual_premium": 100000},
{
"formatId": "single_2_3",
"revision": 2,
"copy": {"headline": "标题"},
"summary": {"annual_premium": 100000},
"sections": [{"id": "hero", "visible": True}, {"id": "summary", "visible": True}],
},
{
"version": "poster-export-manifest-v1",
"formatId": "single_2_3",
"documentRevision": 2,
"visibleText": visible_text,
"visibleTextSha256": hashlib.sha256(visible_text.encode("utf-8")).hexdigest(),
"canvas": {"scrollWidth": 512, "scrollHeight": 700, "pixelRatio": 2},
"sections": [{"id": "hero", "present": True, "textLength": 2}],
"overflowIssues": [{"sectionId": "hero", "tagName": "h1"}],
},
{"width": 1024, "height": 1536},
)
assert report["status"] == "failed"
assert {
"REQUIRED_SECTION_MISSING",
"DOM_CONTENT_OVERFLOW",
"DOM_OUTPUT_SIZE_MISMATCH",
"OUTPUT_AMOUNT_UNEXPECTED",
"OUTPUT_AMOUNT_MISSING",
} <= set(report["errors"])