覆盖 AC-11/12/29/30/31/42/44(BR-11/29/30/41/43、E-20/21/22)。 后端: - contracts/simulation.py:Simulation/SimulationAssumption/ScenarioDimensionState。 契约层直接禁止 production 域携带演示估计(E-21);ready 必须有结果、 failed 必须带错误码且不得显示结果、计算中不得预告乐观增幅。 - services/prescreen_sandbox.py:演示情景表(DEMO-TABLE-v1)与推演入口。 组合查表不是单项加和(assert_not_additive 把这条写成可执行断言); 只改被假设的维度,其余维度原样保留;失败保留基线并标未完成。 - services/prescreen_store.py:运行视图只读存储,复用 assistant_objects 新 kind prescreen_run,不新建表;只提供保存与读取,要改结果必须产生新修订。 - api/prescreen.py:新增 3 个只读端点(运行列表、运行视图、可用假设动作) 与 1 个情景端点;请求体里没有任何概率字段。越域返回 E-21、冲突假设 E-22、 过期修订 409。 - contracts/prescreen.py:补 E-20/21/22 三个错误码与其 HTTP 映射。 前端(挂进既有工作台「材料与问题」页,不新建路由、不新增样式表): - src/lib/prescreen-run.ts:客户端契约、呈现判定(同刻度 3–8 维才画雷达)、 演示值域判定、来源与数据域徽标(文字+图标+样式,去色仍可区分)。 - src/components/xingbang/PrescreenPanel.tsx:主审意见、演示值(紧邻固定说明)、 维度雷达与等价文字列表、维度依据抽屉、假设勾选即时推演。 旧请求晚到或结果与当前选择不一致即丢弃;请求失败保留基线并标未完成。 - scripts/test_prescreen_ui.cjs:界面源码守卫,已注册进 scripts/verify.py。 未做:运行装配结果的写入方仍是执行链路(T10 范围),当前由测试写入; 沙盘尚未接入真实执行,界面数值全部为服务端演示表值。 验证:backend 全量 unittest 469 项 OK;前端 npm run build 通过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
195 lines
8.9 KiB
Python
195 lines
8.9 KiB
Python
"""T09 推进沙盘测试:AC-11/12/30/31/44(BR-11/29/30、E-21/22)。
|
||
|
||
覆盖两条主线:
|
||
1. 情景是独立对象——基线副本随行,真实运行与它的快照/概率字段全程不变。
|
||
2. 演示值查表且有版本——组合不是单项加和,越域直接拒绝,说明文字固定。
|
||
"""
|
||
|
||
import copy
|
||
import unittest
|
||
|
||
from contracts.prescreen import ErrorCode
|
||
from contracts.simulation import Simulation
|
||
from services import judgment_provider as jp
|
||
from services import prescreen, prescreen_policy
|
||
from services import prescreen_sandbox as sandbox
|
||
|
||
DIMENSIONS = [
|
||
{"id": "DC-CASH", "component_refs": [{"id": "corporate.cashflow"}]},
|
||
{"id": "DC-TITLE", "component_refs": [{"id": "corporate.title"}]},
|
||
{"id": "DC-DEAL", "component_refs": [{"id": "corporate.deal"}]},
|
||
]
|
||
|
||
FINDINGS = [
|
||
{"code": "corporate.cashflow", "result": "unknown", "stage": 1,
|
||
"evidence_ids": ["EV-1"]},
|
||
{"code": "corporate.title", "result": "unknown", "stage": 1,
|
||
"evidence_ids": ["EV-2"]},
|
||
{"code": "corporate.deal", "result": "checked", "stage": 2,
|
||
"evidence_ids": ["EV-3"]},
|
||
]
|
||
|
||
|
||
def frozen_plan():
|
||
return {
|
||
"plan": {"id": "corporate.default", "version": "1.0.0"},
|
||
"skills": [{"id": "corporate.cashflow", "version": "1.0.0"}],
|
||
"rules": [],
|
||
"template": {"id": "template.ai-pre-review", "version": "1.0.0"},
|
||
"content_hash": "abc123",
|
||
"dimensions": DIMENSIONS,
|
||
}
|
||
|
||
|
||
def run_view(data_domain="demo"):
|
||
"""用真实装配入口产出一条运行视图,避免测试自造第二份形状。"""
|
||
decision = prescreen_policy.decide(FINDINGS)
|
||
decision["findings"] = FINDINGS
|
||
decision["missing_inputs"] = ["近六个月银行流水", "设备权属材料"]
|
||
return prescreen.assemble(
|
||
{"id": "run-9", "project_id": "DEMO-026", "created_at": "2026-09-30T01:00:00+00:00",
|
||
"input_snapshot_id": "snap-9", "revision": 3},
|
||
frozen_plan(), decision, jp.resolve_config(False), data_domain=data_domain)
|
||
|
||
|
||
class BaselineIsolationTests(unittest.TestCase):
|
||
"""AC-11:模拟不修改原运行,也不动它的快照。"""
|
||
|
||
def test_run_view_is_not_mutated_by_simulation(self):
|
||
view = run_view()
|
||
before = copy.deepcopy(view)
|
||
sandbox.simulate(view, ["assume_bank_flow", "assume_title"])
|
||
self.assertEqual(view, before, "情景推演不得改写真实运行对象")
|
||
|
||
def test_simulation_carries_its_own_baseline_copy(self):
|
||
view = run_view()
|
||
result = sandbox.simulate(view, ["assume_bank_flow"])
|
||
baseline = result["baseline_probability_view"]
|
||
self.assertEqual(baseline["demo_estimate"]["value"], 0.42)
|
||
self.assertIsNone(baseline["real_estimate"])
|
||
self.assertIsNone(result["probability_view"]["real_estimate"],
|
||
"演示情景的真实估计必须为空")
|
||
sandbox.assert_baseline_untouched(view, result)
|
||
|
||
def test_real_run_probability_stays_empty(self):
|
||
view = run_view()
|
||
self.assertIsNone(view["probability"])
|
||
self.assertIsNone(view["probability_view"]["real_estimate"])
|
||
self.assertIsNone(view["probability_view"].get("demo_estimate"),
|
||
"真实运行不得携带演示值")
|
||
|
||
|
||
class DemoTableTests(unittest.TestCase):
|
||
"""AC-30/12:演示值必须有表版本与固定说明,且组合不是加和。"""
|
||
|
||
def test_table_matches_the_agreed_prototype_values(self):
|
||
values = {key: row["value"] for key, row in sandbox.DEMO_SCENARIOS.items()}
|
||
self.assertEqual(values, {"00": 42, "10": 58, "01": 51, "11": 64})
|
||
|
||
def test_combination_is_not_the_sum_of_single_deltas(self):
|
||
sandbox.assert_not_additive()
|
||
baseline = sandbox.DEMO_SCENARIOS["00"]["value"]
|
||
single = (sandbox.DEMO_SCENARIOS["10"]["value"] - baseline) \
|
||
+ (sandbox.DEMO_SCENARIOS["01"]["value"] - baseline)
|
||
self.assertEqual(baseline + single, 67)
|
||
self.assertEqual(sandbox.DEMO_SCENARIOS["11"]["value"], 64,
|
||
"组合值必须来自查表,不是 42+16+9")
|
||
|
||
def test_every_demo_estimate_is_labeled_and_versioned(self):
|
||
result = sandbox.simulate(run_view(), ["assume_title"])
|
||
demo = result["probability_view"]["demo_estimate"]
|
||
self.assertEqual(demo["source"], "synthetic_demo")
|
||
self.assertEqual(demo["scenario_table_version"], sandbox.DEMO_TABLE_VERSION)
|
||
self.assertEqual(demo["scenario_key"], "01")
|
||
self.assertIn("人工设定", demo["label"])
|
||
|
||
def test_production_domain_is_refused_before_any_result(self):
|
||
with self.assertRaises(sandbox.DemoEstimateForbidden):
|
||
sandbox.simulate(run_view(data_domain="production"), ["assume_bank_flow"])
|
||
|
||
def test_contract_itself_refuses_demo_estimate_outside_demo_domain(self):
|
||
result = sandbox.simulate(run_view(), ["assume_bank_flow"])
|
||
broken = dict(result, data_domain="production")
|
||
with self.assertRaises(ValueError):
|
||
Simulation(**broken)
|
||
self.assertEqual(ErrorCode.DEMO_ESTIMATE_FORBIDDEN.value,
|
||
"DEMO_ESTIMATE_FORBIDDEN")
|
||
|
||
|
||
class AssumptionGuardTests(unittest.TestCase):
|
||
"""AC-31:冲突假设拒绝;组合摘要随选择变化。"""
|
||
|
||
def test_unregistered_assumption_is_rejected(self):
|
||
with self.assertRaises(sandbox.AssumptionConflict):
|
||
sandbox.simulate(run_view(), ["assume_everything"])
|
||
|
||
def test_assumption_for_missing_dimension_is_rejected(self):
|
||
view = run_view()
|
||
view["dimension_results"] = [item for item in view["dimension_results"]
|
||
if item["id"] != "DC-TITLE"]
|
||
with self.assertRaises(sandbox.AssumptionConflict) as ctx:
|
||
sandbox.simulate(view, ["assume_title"])
|
||
self.assertIn("DC-TITLE", str(ctx.exception))
|
||
|
||
def test_selection_hash_follows_the_full_combination(self):
|
||
view = run_view()
|
||
first = sandbox.simulate(view, ["assume_bank_flow"])
|
||
again = sandbox.simulate(view, ["assume_bank_flow"])
|
||
other = sandbox.simulate(view, ["assume_bank_flow", "assume_title"])
|
||
self.assertEqual(first["selection_hash"], again["selection_hash"])
|
||
self.assertNotEqual(first["selection_hash"], other["selection_hash"])
|
||
|
||
def test_new_revision_changes_the_selection_hash(self):
|
||
view = run_view()
|
||
self.assertNotEqual(sandbox.selection_hash("run-9", 3, ["assume_bank_flow"]),
|
||
sandbox.selection_hash("run-9", 4, ["assume_bank_flow"]))
|
||
|
||
|
||
class DimensionOverlayTests(unittest.TestCase):
|
||
"""AC-44/13-P-17:只改被假设的维度,其余维度原样保留并标明未变。"""
|
||
|
||
def test_only_assumed_dimensions_change(self):
|
||
result = sandbox.simulate(run_view(), ["assume_bank_flow"])
|
||
rows = {item["id"]: item for item in result["dimensions"]}
|
||
self.assertEqual(rows["DC-CASH"]["baseline_stage"], 1)
|
||
self.assertEqual(rows["DC-CASH"]["scenario_stage"], 3)
|
||
self.assertTrue(rows["DC-CASH"]["assumed"])
|
||
self.assertEqual(rows["DC-TITLE"]["scenario_stage"],
|
||
rows["DC-TITLE"]["baseline_stage"])
|
||
self.assertFalse(rows["DC-TITLE"]["assumed"])
|
||
|
||
def test_unchanged_dimensions_are_still_listed(self):
|
||
result = sandbox.simulate(run_view(), ["assume_bank_flow"])
|
||
body = Simulation(**result)
|
||
self.assertEqual(sorted(body.unchanged_dimensions()), ["DC-DEAL", "DC-TITLE"])
|
||
|
||
def test_no_assumption_returns_the_baseline_scenario(self):
|
||
result = sandbox.simulate(run_view(), [])
|
||
self.assertEqual(result["probability_view"]["demo_estimate"]["value"], 0.42)
|
||
self.assertEqual(result["probability_view"]["demo_estimate"]["scenario_key"], "00")
|
||
self.assertTrue(all(not item["assumed"] for item in result["dimensions"]))
|
||
|
||
|
||
class FailureKeepsBaselineTests(unittest.TestCase):
|
||
"""AC-31:失败保留基线并标未完成,不得回退成演示表默认值。"""
|
||
|
||
def test_failed_simulation_keeps_baseline_and_marks_incomplete(self):
|
||
result = sandbox.failed(run_view(), ["assume_bank_flow"],
|
||
"情景计算未完成,已保留基线与原有结论",
|
||
ErrorCode.JUDGMENT_UNAVAILABLE)
|
||
self.assertEqual(result["status"], "failed")
|
||
self.assertEqual(result["error_code"], ErrorCode.JUDGMENT_UNAVAILABLE)
|
||
self.assertIsNone(result["probability_view"], "失败不得显示任何情景数值")
|
||
self.assertEqual(result["baseline_probability_view"]["demo_estimate"]["value"], 0.42)
|
||
self.assertEqual(result["remaining_inputs"], ["近六个月银行流水", "设备权属材料"])
|
||
|
||
def test_contract_refuses_optimistic_values_while_computing(self):
|
||
result = sandbox.simulate(run_view(), ["assume_bank_flow"])
|
||
pending = dict(result, status="computing")
|
||
with self.assertRaises(ValueError):
|
||
Simulation(**pending)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|