Files
mini-openclaw-v2/backend/test_prescreen_sandbox.py
T
qiaoqichaoandClaude Code 22f8a37799 feat(v2): T09 预审与推进沙盘——运行只读视图、演示情景与界面守卫
覆盖 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>
2026-09-30 09:15:57 +08:00

195 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()