"""纠偏第一批端到端测试:正常发起预审 → 真实执行 → 页面可读。 覆盖 AC-46/47/48/49。**不使用注入记录**:运行由 `service.enqueue` 创建、 由 `DurableWorker.process_next` 执行,视图只能由执行链的出口产生(BR-45)。 """ import os import tempfile import unittest from pathlib import Path from unittest.mock import patch from fastapi import FastAPI from fastapi.testclient import TestClient from api import prescreen as prescreen_api from api import project_assistant as api from api import risk_agent_config as catalog from services import prescreen_store from services.assistant_store import get, listing from services.assistant_worker import DurableWorker from services.project_assistant import ProjectAssistantService HEADERS = {"X-Xingbang-Demo-Role": "manager"} PROJECT = "DEMO-B" BASE = "/api/xingbang/risk-agent/project-assistant" PRESCREEN = BASE + "/prescreen" class ExecutionChainTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) self.path = Path(self.temp.name) self.service = ProjectAssistantService(self.path / "assistant.sqlite3") self.patches = [ patch.object(api, "service", lambda: self.service), patch.object(prescreen_api, "_service", lambda: self.service), patch.object(catalog, "DB_PATH", self.path / "catalog.sqlite3"), # 不接内网模型:这样"模型不可用"是真实状态,而不是被 mock 掉。 # XINGBANG_DEMO_ROLE_LOGIN=1 是演示角色登录开关:worker 的 authorized() # 要求它,否则演示角色发起的运行会被判"权限已撤销"(见 16 §3 AC-46 说明)。 patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0", "XINGBANG_LOCAL_MODEL_URL": "", "XINGBANG_DEMO_ROLE_LOGIN": "1"}), ] for item in self.patches: item.start() self.worker = DurableWorker(self.service) app = FastAPI() app.include_router(api.router) app.include_router(prescreen_api.router) self.client = TestClient(app) def tearDown(self): self.worker.stop() self.client.close() for item in reversed(self.patches): item.stop() self.temp.cleanup() # ------------------------------------------------------------ 工具 def start_run(self, project=PROJECT): response = self.client.post(f"{BASE}/projects/{project}/runs", json={}, headers=HEADERS) self.assertIn(response.status_code, (200, 202), response.text) return response.json() def views(self, project=PROJECT): response = self.client.get(f"{PRESCREEN}/projects/{project}/runs", headers=HEADERS) self.assertEqual(response.status_code, 200, response.text) return response.json()["items"] def view(self, run_id, project=PROJECT): response = self.client.get(f"{PRESCREEN}/projects/{project}/runs/{run_id}", headers=HEADERS) self.assertEqual(response.status_code, 200, response.text) return response.json() # ------------------------------------------------------------ AC-46 def test_ordinary_run_is_executed_and_becomes_readable(self): run = self.start_run() self.assertEqual(self.views(), [], "执行前不应存在任何预审记录") self.assertTrue(self.worker.process_next(), "队列里应有一个可执行的运行") rows = self.views() self.assertEqual([item["id"] for item in rows], [run["id"]], "正常预审执行后必须出现对应记录,且由执行链产生") view = self.view(run["id"]) self.assertEqual(view["project_id"], PROJECT) self.assertEqual(view["snapshot_id"], run["input_snapshot_id"]) self.assertIn(view["status"], ("completed", "partial", "failed")) self.assertIsNone(view["probability"], "未校准就是没有概率") if view["probability_view"] is not None: self.assertIsNone(view["probability_view"]["real_estimate"]) self.assertIsNone(view["probability_view"]["demo_estimate"]) self.assertEqual(view["versions"]["trajectory_id"], run["id"], "视图必须指向本次运行的轨迹,供轨迹复盘使用") def test_view_survives_a_reload(self): run = self.start_run() self.worker.process_next() first = self.view(run["id"]) again = self.view(run["id"]) self.assertEqual(first, again, "刷新页面必须读到同一份记录") def test_run_produces_real_checks_and_reports_the_model_gap(self): """没有模型时:确定性核查照跑,模型类步骤失败留痕,运行记部分完成。""" run = self.start_run() self.worker.process_next() with self.service.connect() as db: stored = get(db, "run", run["id"]) self.assertTrue(stored["checks"], "确定性核查应当真的跑出核查项") failed = [step for step in stored["steps"] if step["status"] == "failed"] self.assertTrue(failed, "未配置模型时模型类步骤应当失败,而不是假装成功") self.assertEqual(stored["status"], "partial", "有失败步骤时运行应为部分完成") view = self.view(run["id"]) self.assertTrue(view["findings"] or view["dimension_results"], "视图必须带本次执行的结果或维度,而不是空壳") self.assertTrue(view["degradations"], "模型缺口必须记成降级项") # ------------------------------------------------------------ AC-47/48 def test_missing_inputs_and_degradations_are_visible(self): run = self.start_run() self.worker.process_next() view = self.view(run["id"]) with self.service.connect() as db: stored = get(db, "run", run["id"]) failed = [step for step in stored["steps"] if step["status"] == "failed"] if failed: self.assertEqual(view["status"], "partial", "有失败步骤时运行应为部分完成") self.assertTrue(view["degradations"], "失败必须留下降级记录") self.assertNotEqual(view["recommendation"], "advance", "有未完成核查时不得给出可推进结论") for item in view["degradations"]: self.assertTrue(item["message"], "降级记录必须可定位原因") def test_model_engine_step_failure_is_not_disguised_as_a_result(self): """没有模型时,模型类技能必须失败留痕,而不是返回编造结论。""" run = self.start_run() self.worker.process_next() with self.service.connect() as db: stored = get(db, "run", run["id"]) model_steps = [step for step in stored["steps"] if (step.get("skill") or {}).get("id") in ("corporate.analysis", "review.analysis")] for step in model_steps: if step["status"] == "failed": self.assertTrue(step["errors"], "失败步骤必须记录错误") self.assertEqual(step.get("outputs") or {}, {}, "失败不得留下'看起来有结论'的输出") view = self.view(run["id"]) self.assertIsNone(view["probability"]) # ------------------------------------------------------------ AC-49 def test_rerun_creates_a_new_record_and_keeps_the_old_one(self): first = self.start_run() self.worker.process_next() second = self.start_run() self.worker.process_next() rows = self.views() self.assertEqual(len(rows), 2, "重跑必须新增记录,不得覆盖旧记录") self.assertEqual({item["id"] for item in rows}, {first["id"], second["id"]}) old = self.view(first["id"]) self.assertEqual(old["id"], first["id"], "旧记录仍可读取") class ViewAssemblyTests(unittest.TestCase): """装配规则的单元测试(fixture 允许;验收仍走上面的真实执行链)。""" SKILLS = [{"id": "corporate.assets", "name": "设备清单存在重复项", "version": "1.0.0"}] RULES = [{"id": "demo.coverage", "name": "演示覆盖规则", "version": "1.0.0"}] def test_dimensions_come_from_executed_component_steps(self): from services import prescreen steps = [{"step_id": "corporate.assets", "skill": {"id": "corporate.assets"}, "status": "completed", "findings": [{"code": "lease.asset.duplicate", "result": "issue", "evidence_ids": ["EV-1"]}]}, {"step_id": "demo.coverage", "status": "completed", "findings": [{"code": "lease.coverage.below_demo_threshold", "result": "issue", "evidence_ids": ["EV-2"]}]}] dimensions, items = prescreen.dimensions_and_evidence(steps, self.SKILLS, self.RULES) self.assertEqual([item["id"] for item in dimensions], ["corporate.assets", "demo.coverage"]) self.assertEqual(dimensions[0]["label"], "设备清单存在重复项") refs = [ref["id"] for ref in dimensions[0]["component_refs"]] self.assertEqual(refs, ["corporate.assets", "lease.asset.duplicate"]) self.assertEqual([item["code"] for item in items][:1], ["corporate.assets"]) def test_summary_steps_are_not_dimensions(self): from services import prescreen steps = [{"step_id": "rules-corporate", "status": "completed", "findings": []}, {"step_id": "experience.applications", "status": "completed", "findings": []}, {"step_id": "corporate.analysis", "skill": {"id": "corporate.analysis"}, "status": "failed", "findings": []}] skills = self.SKILLS + [{"id": "corporate.analysis", "name": "金租对公业务综合核查", "version": "1.0.0"}] dimensions, items = prescreen.dimensions_and_evidence(steps, skills, self.RULES) self.assertEqual([item["id"] for item in dimensions], ["corporate.analysis"], "汇总类伪步骤不得成为维度") self.assertEqual(items[0]["result"], "error", "失败步骤按未完成处理") def test_steps_outside_the_frozen_plan_are_not_dimensions(self): from services import prescreen steps = [{"step_id": "experience.applications", "skill": {"id": "experience.applications", "version": "1.0.0"}, "status": "completed", "findings": []}] dimensions, items = prescreen.dimensions_and_evidence(steps, self.SKILLS, self.RULES) self.assertEqual(dimensions, [], "方案未声明的步骤不是核查维度") self.assertEqual(items, []) def test_no_executed_component_means_no_dimensions(self): from services import prescreen self.assertEqual(prescreen.dimensions_and_evidence([], self.SKILLS, self.RULES), ([], [])) def test_step_without_findings_counts_as_checked_not_as_passed_approval(self): from services import prescreen steps = [{"step_id": "corporate.assets", "skill": {"id": "corporate.assets"}, "status": "completed", "findings": []}] dimensions, items = prescreen.dimensions_and_evidence(steps, self.SKILLS, self.RULES) self.assertEqual(items[0]["result"], "pass") self.assertEqual(items[0]["stage"], 1, "没有引用证据时阶段为'仅有说明'") def test_stage_reflects_evidence_maturity_not_outcome(self): from services import prescreen self.assertEqual(prescreen.stage_from_check({"result": "issue", "evidence_ids": ["a"]}), 2) self.assertEqual(prescreen.stage_from_check({"result": "pass", "evidence_ids": ["a"]}), 2) self.assertEqual(prescreen.stage_from_check({"result": "issue"}), 1) self.assertEqual(prescreen.stage_from_check({"result": "not_applicable"}), 0) if __name__ == "__main__": unittest.main()