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 = ( '' '' f'{text}' ) 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"])