368 lines
13 KiB
Python
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"])
|