首次用真实 Chrome 打开 /trajectory-center(新增可选脚本 scripts/browser-e2e.cjs),
暴露 5 个源码守卫与生产构建都看不出的缺陷,全部修复:
1. 三个模块页读 `list.items`,而 /projects 返回的是 {projects, identity}——
项目列表恒为空,prescreen-workbench / experience-center / trajectory-center
整页渲染不出任何面板,只显示"当前身份没有可访问的项目"这种看着正常的空态。
(第二/三批引入)改为 list.projects。
2. TrajectoryPanel 没被传 identity,canRecord 恒假 → 会议结果录入表单永不出现。
3. ImprovementPanel 草稿下拉以组件 id 作 key/value,同一组件多版本草稿时撞 React key,
且选不准要发布哪一版;改以 `组件id@版本` 为键值。
4. JSX 文本里的 `**强调**` 会原样显示星号(JSX 不渲染 Markdown),共 9 处;
改为 <b>(字符串上下文处直接去掉标记)。
5. 深色导航模块名被 flex 压缩折行("AI 预审"断成两行);
加 flex:0 0 auto + white-space:nowrap。
另修一个既有测试脆弱点:test_trajectory.py 的更正用例断言"哪条排前面",
两条记录同刻度时排序翻转,曾让 verify.py 偶发失败;改为断言"两条都在、更正指向旧记录"。
回归防线:第 5 项补进 test_module_shell_ui.cjs 源码断言;
test_ui_readability.cjs 的导航字号断言改为不把 min-height 钉在规则首位。
验证:scripts/verify.py 14/14 PASS(backend-unit 572 项),
证据 test-evidence/20261001-030127-ff59d7e/;scripts/browser-e2e.cjs 19/19 PASS
(截图在 test-evidence/e2e-batch4/)。该 E2E 脚本刻意不接入 verify.py。
Co-Authored-By: Claude Code <noreply@anthropic.com>
295 lines
14 KiB
Python
295 lines
14 KiB
Python
"""T10 轨迹回放与会议结果复盘测试。
|
||
|
||
覆盖 AC-32(一个 run 多 step、失败保留、可追溯、不伪造隐藏推理)
|
||
与 AC-33(unmatched 单列、更正追加并引用旧版、不改原预测),
|
||
以及 06 §5 要求的"成本未知不得伪造"。
|
||
|
||
同时验证只读性:回放与追加会议结果都不改动既有 run。
|
||
"""
|
||
|
||
import os
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.testclient import TestClient
|
||
|
||
from api import prescreen as api
|
||
from contracts.trajectory import ReviewOutcome, TrajectoryStep
|
||
from services import assistant_store as store
|
||
from services import trajectory
|
||
from services.project_assistant import ProjectAssistantService
|
||
|
||
HEADERS = {"X-Xingbang-Demo-Role": "manager"}
|
||
PROJECT = "DEMO-B"
|
||
|
||
|
||
def step(step_id, status="completed", **over):
|
||
body = {"run_id": "run-1", "step_id": step_id, "name": f"步骤 {step_id}",
|
||
"skill": {"id": step_id, "version": "1.0.0"}, "status": status,
|
||
"input_snapshot_id": "snap-1", "started_at": "2026-09-30T01:00:00+00:00",
|
||
"finished_at": "2026-09-30T01:00:03+00:00", "attempt": 1,
|
||
"outputs": {"requirement_response": "已核查"},
|
||
"findings": [{"code": f"{step_id}.check", "result": "issue",
|
||
"evidence_ids": ["EV-1"]}],
|
||
"errors": [], "missing_inputs": [], "output_sources": {}}
|
||
body.update(over)
|
||
return body
|
||
|
||
|
||
def run_body(steps=None, **over):
|
||
body = {"id": "run-1", "project_id": PROJECT, "status": "completed",
|
||
"input_snapshot_id": "snap-1", "revision": 1,
|
||
"created_at": "2026-09-30T01:00:00+00:00",
|
||
"steps": steps if steps is not None else [step("corporate.cashflow")]}
|
||
body.update(over)
|
||
return body
|
||
|
||
|
||
class TrajectoryProjectionTests(unittest.TestCase):
|
||
def test_one_run_becomes_one_trajectory_with_many_steps(self):
|
||
rows = trajectory.project_steps(run_body(steps=[
|
||
step("corporate.cashflow"), step("corporate.title"),
|
||
step("corporate.deal", status="failed",
|
||
errors=[{"code": "SKILL_TIMEOUT", "message": "模型超时"}])]))
|
||
self.assertEqual([item["sequence"] for item in rows], [0, 1, 2])
|
||
self.assertEqual({item["run_id"] for item in rows}, {"run-1"})
|
||
self.assertEqual(len({item["id"] for item in rows}), 3)
|
||
|
||
def test_failed_step_is_preserved_with_a_code(self):
|
||
rows = trajectory.project_steps(run_body(steps=[
|
||
step("a"), step("b", status="failed",
|
||
errors=[{"code": "SKILL_TIMEOUT", "message": "超时"}])]))
|
||
failed = [item for item in rows if item["status"] == "failed"]
|
||
self.assertEqual(len(failed), 1, "失败的步骤不得被过滤掉")
|
||
self.assertEqual(failed[0]["error_code"], "SKILL_TIMEOUT")
|
||
|
||
def test_failed_step_without_error_detail_still_carries_a_code(self):
|
||
rows = trajectory.project_steps(run_body(steps=[step("b", status="failed")]))
|
||
self.assertTrue(rows[0]["error_code"], "失败必须留痕,不能只有一条看不出原因的记录")
|
||
|
||
def test_partial_is_not_reported_as_fully_checked(self):
|
||
rows = trajectory.project_steps(run_body(steps=[step("a", status="partial",
|
||
missing_inputs=["银行流水"])]))
|
||
self.assertIn("部分完成", rows[0]["reason_summary"])
|
||
self.assertIn("银行流水", rows[0]["reason_summary"])
|
||
|
||
def test_steps_expose_only_observable_fields(self):
|
||
rows = trajectory.project_steps(run_body())
|
||
allowed = set(TrajectoryStep.model_fields)
|
||
for row in rows:
|
||
self.assertTrue(set(row) <= allowed)
|
||
for banned in ("thought", "reasoning", "chain_of_thought", "scratchpad",
|
||
"hidden_state", "prompt"):
|
||
self.assertNotIn(banned, rows[0], "轨迹不得包含模型内部推理字段")
|
||
|
||
def test_cost_is_unknown_not_zero(self):
|
||
rows = trajectory.project_steps(run_body())
|
||
self.assertIsNone(rows[0]["cost"], "没有成本口径时应留空,不得填 0 或估计值")
|
||
self.assertIsNone(rows[0]["cost_currency"])
|
||
|
||
def test_evidence_and_versions_are_traceable(self):
|
||
rows = trajectory.project_steps(run_body())
|
||
self.assertEqual(rows[0]["evidence_ids"], ["EV-1"])
|
||
self.assertEqual(rows[0]["component_ref"], {"id": "corporate.cashflow",
|
||
"version": "1.0.0"})
|
||
self.assertEqual(rows[0]["input_refs"], ["snap-1"])
|
||
|
||
|
||
class TrajectoryStoreTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||
self.service = ProjectAssistantService(Path(self.temp.name) / "assistant.sqlite3")
|
||
with store.transaction(self.service.db_path) as db:
|
||
store.put(db, "run", run_body(steps=[step("a"), step("b", status="failed")]))
|
||
|
||
def tearDown(self):
|
||
self.temp.cleanup()
|
||
|
||
def test_replay_is_read_only_and_paginated(self):
|
||
with store.transaction(self.service.db_path) as db:
|
||
before = store.get(db, "run", "run-1")
|
||
page = trajectory.replay(db, PROJECT, "run-1", limit=1)
|
||
after = store.get(db, "run", "run-1")
|
||
self.assertEqual(len(page["steps"]), 1)
|
||
self.assertEqual(page["next_cursor"], "1")
|
||
self.assertTrue(page["read_only"])
|
||
self.assertEqual(before, after, "回放不得改动运行")
|
||
|
||
def test_replay_rejects_foreign_project(self):
|
||
with store.transaction(self.service.db_path) as db:
|
||
with self.assertRaises(KeyError):
|
||
trajectory.replay(db, "OTHER-9", "run-1")
|
||
|
||
|
||
class ReviewOutcomeTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||
self.service = ProjectAssistantService(Path(self.temp.name) / "assistant.sqlite3")
|
||
|
||
def tearDown(self):
|
||
self.temp.cleanup()
|
||
|
||
def record(self, **over):
|
||
body = {"raw_code": "通过", "meeting_id": "M-1",
|
||
"occurred_at": "2026-09-29T02:00:00+00:00",
|
||
"reviewed_snapshot_id": "snap-1", "evidence_ids": ["EV-1"]}
|
||
body.update(over)
|
||
with store.transaction(self.service.db_path) as db:
|
||
return trajectory.record(db, PROJECT, body, "reviewer-1",
|
||
known_snapshots=["snap-1"])
|
||
|
||
def test_unknown_snapshot_is_unmatched_and_has_no_label(self):
|
||
saved = self.record(reviewed_snapshot_id=None)
|
||
self.assertEqual(saved["status"], "unmatched")
|
||
self.assertIsNone(saved["label"], "未匹配材料版本时不得给出二元标签")
|
||
self.assertIsNone(saved["reviewed_snapshot_id"])
|
||
|
||
def test_conditional_is_never_labeled_as_approved(self):
|
||
saved = self.record(raw_code="附条件通过")
|
||
self.assertEqual(saved["normalized"], "conditional")
|
||
self.assertIsNone(saved["label"], "附条件不等于通过(BR-21)")
|
||
|
||
def test_withdrawn_is_recorded_but_excluded_from_denominator(self):
|
||
self.record(raw_code="撤回")
|
||
self.record(raw_code="通过")
|
||
with store.transaction(self.service.db_path) as db:
|
||
listed = trajectory.listing(db, PROJECT)
|
||
self.assertEqual(listed["counts_toward_denominator"]["observed"], 1)
|
||
excluded = listed["counts_toward_denominator"]["excluded"]
|
||
self.assertEqual([item[1] for item in excluded], ["撤回"])
|
||
|
||
def test_correction_appends_and_references_the_old_record(self):
|
||
first = self.record(raw_code="通过")
|
||
corrected = self.record(raw_code="否决", supersedes_id=first["id"])
|
||
self.assertEqual(corrected["supersedes_id"], first["id"])
|
||
self.assertGreater(corrected["revision"], first["revision"])
|
||
with store.transaction(self.service.db_path) as db:
|
||
rows = trajectory.listing(db, PROJECT)["items"]
|
||
self.assertEqual(len(rows), 2, "更正必须追加,不得覆盖旧记录")
|
||
# 不断言"哪条排前面":两条记录可能落在同一时钟刻度,`listing()` 按 recorded_at 排序
|
||
# 时顺序就取决于底层 rowid 顺序。那是与本用例无关的偶然假设,且曾让全量验证偶发失败。
|
||
# 本用例要锁的是"旧记录仍在、更正指向它",与排序无关。
|
||
by_code = {row["raw_code"]: row for row in rows}
|
||
self.assertEqual(sorted(by_code), ["否决", "通过"], "两条结果都必须在,追加不得覆盖")
|
||
self.assertEqual(by_code["否决"]["supersedes_id"], first["id"])
|
||
self.assertIsNone(by_code["通过"]["supersedes_id"])
|
||
|
||
def test_correction_must_point_to_an_existing_record_of_the_project(self):
|
||
with self.assertRaises(ValueError):
|
||
self.record(supersedes_id="outcome-missing")
|
||
|
||
def test_supersede_chains_always_terminate(self):
|
||
first = self.record(raw_code="通过")
|
||
second = self.record(raw_code="否决", supersedes_id=first["id"])
|
||
self.record(raw_code="附条件", supersedes_id=second["id"])
|
||
with store.transaction(self.service.db_path) as db:
|
||
rows = trajectory.listing(db, PROJECT)["items"]
|
||
by_id = {item["id"]: item for item in rows}
|
||
for item in rows:
|
||
seen, current = set(), item["id"]
|
||
while current:
|
||
self.assertNotIn(current, seen, "更正链不得出现循环")
|
||
seen.add(current)
|
||
current = (by_id.get(current) or {}).get("supersedes_id")
|
||
self.assertEqual(len(rows), 3)
|
||
|
||
def test_missing_raw_code_is_rejected(self):
|
||
with self.assertRaises(ValueError):
|
||
self.record(raw_code="")
|
||
|
||
def test_recording_never_touches_runs(self):
|
||
with store.transaction(self.service.db_path) as db:
|
||
store.put(db, "run", run_body())
|
||
before = None
|
||
with store.transaction(self.service.db_path) as db:
|
||
before = store.get(db, "run", "run-1")
|
||
self.record(raw_code="否决")
|
||
with store.transaction(self.service.db_path) as db:
|
||
after = store.get(db, "run", "run-1")
|
||
self.assertEqual(before, after, "会议结果不得改写历史预测")
|
||
|
||
def test_contract_refuses_label_without_mapping(self):
|
||
with self.assertRaises(ValueError):
|
||
ReviewOutcome(id="o-1", project_id=PROJECT, meeting_id="M-1", revision=1,
|
||
occurred_at="2026-09-29T02:00:00+00:00",
|
||
recorded_at="2026-09-29T02:00:00+00:00",
|
||
reviewed_snapshot_id="snap-1", raw_code="通过",
|
||
normalized="approved", label=True, status="matched",
|
||
recorded_by="reviewer-1")
|
||
|
||
|
||
class TrajectoryApiTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||
self.service = ProjectAssistantService(Path(self.temp.name) / "assistant.sqlite3")
|
||
self.patches = [
|
||
patch.object(api, "_service", lambda: self.service),
|
||
patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0",
|
||
"XINGBANG_LOCAL_MODEL_URL": ""}),
|
||
]
|
||
for item in self.patches:
|
||
item.start()
|
||
app = FastAPI()
|
||
app.add_exception_handler(RequestValidationError, api.validation_handler)
|
||
app.include_router(api.router)
|
||
self.client = TestClient(app)
|
||
self.base = "/api/xingbang/risk-agent/project-assistant/prescreen"
|
||
with store.transaction(self.service.db_path) as db:
|
||
store.put(db, "run", run_body(steps=[step("a"), step("b", status="failed",
|
||
errors=[{"code": "SKILL_TIMEOUT"}])]))
|
||
|
||
def tearDown(self):
|
||
self.client.close()
|
||
for item in reversed(self.patches):
|
||
item.stop()
|
||
self.temp.cleanup()
|
||
|
||
def test_trajectory_replay_over_http(self):
|
||
response = self.client.get(f"{self.base}/projects/{PROJECT}/runs/run-1/trajectory",
|
||
headers=HEADERS)
|
||
self.assertEqual(response.status_code, 200)
|
||
body = response.json()
|
||
self.assertEqual([item["status"] for item in body["steps"]],
|
||
["completed", "failed"])
|
||
self.assertTrue(body["read_only"])
|
||
|
||
def test_trajectory_of_missing_run_is_404(self):
|
||
response = self.client.get(f"{self.base}/projects/{PROJECT}/runs/run-x/trajectory",
|
||
headers=HEADERS)
|
||
self.assertEqual(response.status_code, 404)
|
||
|
||
def test_outcome_round_trip(self):
|
||
created = self.client.post(
|
||
f"{self.base}/projects/{PROJECT}/review-outcomes",
|
||
json={"raw_code": "附条件通过", "meeting_id": "M-1"},
|
||
headers=HEADERS)
|
||
self.assertEqual(created.status_code, 201)
|
||
self.assertEqual(created.json()["status"], "unmatched")
|
||
self.assertIsNone(created.json()["label"])
|
||
|
||
listed = self.client.get(f"{self.base}/projects/{PROJECT}/review-outcomes",
|
||
headers=HEADERS)
|
||
self.assertEqual(len(listed.json()["unmatched"]), 1)
|
||
self.assertEqual(listed.json()["counts_toward_denominator"]["observed"], 0)
|
||
|
||
def test_outcome_without_raw_code_is_rejected(self):
|
||
response = self.client.post(
|
||
f"{self.base}/projects/{PROJECT}/review-outcomes",
|
||
json={"meeting_id": "M-1"}, headers=HEADERS)
|
||
self.assertEqual(response.status_code, 422)
|
||
self.assertEqual(response.json()["error"]["code"], "INVALID_REQUEST")
|
||
|
||
def test_unknown_outcome_status_filter_is_rejected(self):
|
||
response = self.client.get(
|
||
f"{self.base}/projects/{PROJECT}/review-outcomes?status=weird",
|
||
headers=HEADERS)
|
||
self.assertEqual(response.status_code, 422)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|