"""S1 存储层测试:材料导入批次与事实冲突。 重点证明的是**旧实现会失败的行为**:`apply_candidate` 对字段值是后写覆盖, 本层必须让"后到的冲突来源不得覆盖已确认值"成立(BR-23 / AC-24)。 用独立临时 SQLite 库,绝不触碰 backend/xingbang_data 下的真实库。 """ import tempfile import unittest from pathlib import Path from pydantic import ValidationError from contracts.prescreen import ErrorCode from services import assistant_store as store from services import fact_conflict, material_import from services.assistant_materials import FIELDS def manifest_entry(name="a.csv", sha="a" * 64, size=100, mime="text/csv"): """客户端清单条目:只有设计允许的 4 个字段(03 §9.4)。 id / status / received_at 由服务端生成——见 test_client_cannot_declare_server_fields。 """ return {"relative_path": name, "sha256": sha, "size_bytes": size, "mime_type": mime} class StoreTestCase(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.db_path = Path(self._tmp.name) / "prescreen_test.sqlite3" store.initialize(self.db_path) self.actor = "demo-manager" with store.transaction(self.db_path) as db: self._seed_project(db) def tearDown(self): self._tmp.cleanup() @property def project_id(self): return "DEMO-A" def _seed_project(self, db): store.put(db, "project", { "id": self.project_id, "data_domain": "demo", "revision": 1, "facts": {}, "values": {}, "sources": {}, "selected_sources": {}, "document_ids": [], "pending_candidates": [], "data_revision": 1, }) def project(self, db): return store.get(db, "project", self.project_id) def candidate(self, field="facility.amount", value="1000", unit="万元", **over): row = {"id": store.uid("candidate"), "field": field, "value": value, "unit": unit, "currency": "CNY", "period": "2025", "scope": "合并", "source": "a.csv", "locator": "第2行", "document_id": "doc-1"} row.update(over) return row class ImportBatchTests(StoreTestCase): def test_create_is_queued_with_derived_total(self): with store.transaction(self.db_path) as db: batch = material_import.create( db, self.project_id, "demo", [manifest_entry(size=100), manifest_entry(name="b.csv", sha="b" * 64, size=50)], self.actor) self.assertEqual(batch["status"], "queued") self.assertEqual(batch["total_bytes"], 150) self.assertEqual(batch["revision"], 1) self.assertEqual(batch["parser_version"], material_import.PARSER_VERSION) def test_client_cannot_declare_server_fields(self): """状态机的写权不交给调用方:客户端不得自报 id / status / received_at。""" for field, value in [("status", "parsed"), ("id", "item-x"), ("received_at", store.now()), ("document_id", "doc-1")]: with self.subTest(field=field): entry = manifest_entry() entry[field] = value with store.transaction(self.db_path) as db: with self.assertRaises(ValueError) as ctx: material_import.create(db, self.project_id, "demo", [entry], self.actor) self.assertIn("不允许由客户端声明", str(ctx.exception)) def test_server_generates_item_identity_and_queued_status(self): with store.transaction(self.db_path) as db: batch = material_import.create(db, self.project_id, "demo", [manifest_entry()], self.actor) item = batch["items"][0] self.assertTrue(item["id"].startswith("item-")) self.assertEqual(item["status"], "queued") self.assertIsNotNone(item["received_at"]) # BR-02:发生时间未知就保持 null,不用导入时间冒充 self.assertIsNone(item["observed_at"]) self.assertIsNone(item["document_id"]) def test_empty_manifest_rejected(self): with store.transaction(self.db_path) as db: with self.assertRaises(ValueError) as ctx: material_import.create(db, self.project_id, "demo", [], self.actor) self.assertIn("为空", str(ctx.exception)) def test_duplicate_sha_in_one_batch_rejected(self): with store.transaction(self.db_path) as db: with self.assertRaises(ValueError) as ctx: material_import.create(db, self.project_id, "demo", [manifest_entry(), manifest_entry(name="b.csv")], self.actor) self.assertIn("哈希重复", str(ctx.exception)) def test_absolute_path_entry_reports_which_item(self): with store.transaction(self.db_path) as db: with self.assertRaises(ValueError) as ctx: material_import.create(db, self.project_id, "demo", [manifest_entry(name="C:/x.csv")], self.actor) self.assertIn("第 1 个文件", str(ctx.exception)) def test_batch_total_over_limit_rejected(self): from contracts.prescreen import MAX_ITEM_BYTES entries = [manifest_entry(name=f"f{n}.csv", sha=f"{n}" * 64, size=MAX_ITEM_BYTES) for n in range(3)] with store.transaction(self.db_path) as db: with self.assertRaises(ValueError) as ctx: material_import.create(db, self.project_id, "demo", entries, self.actor) self.assertIn("超过上限", str(ctx.exception)) def test_idempotent_replay_returns_same_batch(self): entries = [manifest_entry()] with store.transaction(self.db_path) as db: first = material_import.create(db, self.project_id, "demo", entries, self.actor, idempotency_key="k1") again = material_import.create(db, self.project_id, "demo", entries, self.actor, idempotency_key="k1") self.assertEqual(first["id"], again["id"]) def test_idempotent_key_with_different_payload_conflicts(self): with store.transaction(self.db_path) as db: material_import.create(db, self.project_id, "demo", [manifest_entry()], self.actor, idempotency_key="k2") with self.assertRaises(store.Conflict): material_import.create(db, self.project_id, "demo", [manifest_entry(sha="c" * 64)], self.actor, idempotency_key="k2") def test_status_derivation_partial_ready_failed(self): with store.transaction(self.db_path) as db: batch = material_import.create( db, self.project_id, "demo", [manifest_entry(name="ok.csv"), manifest_entry(name="bad.csv", sha="b" * 64)], self.actor) ids = [item["id"] for item in batch["items"]] batch = material_import.mark_item(db, batch["id"], ids[0], "parsed", document_id="doc-1") self.assertEqual(batch["status"], "parsing") batch = material_import.mark_item(db, batch["id"], ids[1], "failed", error_code=ErrorCode.MATERIAL_PARSE_FAILED) self.assertEqual(batch["status"], "partial") self.assertEqual(batch["revision"], 3) def test_mark_item_failed_requires_error_code(self): with store.transaction(self.db_path) as db: batch = material_import.create(db, self.project_id, "demo", [manifest_entry()], self.actor) with self.assertRaises(ValueError): material_import.mark_item(db, batch["id"], batch["items"][0]["id"], "failed") def test_retry_only_resets_failed_items(self): # AC-23 反例:已 parsed 的条目不得被重解析、不得被重写 with store.transaction(self.db_path) as db: batch = material_import.create( db, self.project_id, "demo", [manifest_entry(name="ok.csv"), manifest_entry(name="bad.csv", sha="b" * 64)], self.actor) ids = [item["id"] for item in batch["items"]] material_import.mark_item(db, batch["id"], ids[0], "parsed", document_id="doc-1") batch = material_import.mark_item(db, batch["id"], ids[1], "failed", error_code=ErrorCode.MATERIAL_PARSE_FAILED) parsed_before = next(i for i in batch["items"] if i["id"] == ids[0]) batch = material_import.retry(db, batch["id"], [ids[1]], self.actor, expected=batch["revision"]) parsed_after = next(i for i in batch["items"] if i["id"] == ids[0]) self.assertEqual(parsed_before, parsed_after) self.assertEqual(next(i for i in batch["items"] if i["id"] == ids[1])["status"], "queued") def test_retry_rejects_non_failed_item(self): with store.transaction(self.db_path) as db: batch = material_import.create( db, self.project_id, "demo", [manifest_entry(name="ok.csv"), manifest_entry(name="bad.csv", sha="b" * 64)], self.actor) ids = [item["id"] for item in batch["items"]] material_import.mark_item(db, batch["id"], ids[0], "parsed", document_id="doc-1") batch = material_import.mark_item(db, batch["id"], ids[1], "failed", error_code=ErrorCode.MATERIAL_PARSE_FAILED) with self.assertRaises(ValueError): material_import.retry(db, batch["id"], [ids[0]], self.actor, expected=batch["revision"]) def test_retry_with_stale_revision_conflicts(self): with store.transaction(self.db_path) as db: batch = material_import.create(db, self.project_id, "demo", [manifest_entry()], self.actor) material_import.mark_item(db, batch["id"], batch["items"][0]["id"], "failed", error_code=ErrorCode.MATERIAL_PARSE_FAILED) with self.assertRaises(store.Conflict): material_import.retry(db, batch["id"], [batch["items"][0]["id"]], self.actor, expected=1) def test_cancel_then_late_result_is_ignored(self): with store.transaction(self.db_path) as db: batch = material_import.create(db, self.project_id, "demo", [manifest_entry()], self.actor) batch = material_import.cancel(db, batch["id"], self.actor, expected=batch["revision"]) item_id = batch["items"][0]["id"] after = material_import.mark_item(db, batch["id"], item_id, "parsed", document_id="doc-1") self.assertEqual(after["status"], "cancelled") self.assertIsNone(after["items"][0]["document_id"]) def test_cross_project_read_is_not_found(self): with store.transaction(self.db_path) as db: batch = material_import.create(db, self.project_id, "demo", [manifest_entry()], self.actor) with self.assertRaises(KeyError): material_import.load(db, batch["id"], project_id="DEMO-B") class FactConflictTests(StoreTestCase): def _confirm(self, db, candidate): """走一遍真实链路:detect 就地改 project → 保存 → 有冲突则落库。 注意必须先取到 project 字典再保存**同一个对象**:detect 是就地修改, 若重新 store.get 一份新副本去 put,改动会被丢弃。 """ project = self.project(db) payload = fact_conflict.detect(project, candidate, self.actor, "初始确认") store.put(db, "project", project) if payload: return fact_conflict.create(db, self.project_id, payload, self.actor) return None def _resolve(self, db, conflict, selected, note="裁定说明"): project = self.project(db) row = fact_conflict.resolve(db, conflict["id"], project, selected, note, self.actor, expected=conflict["revision"]) store.put(db, "project", project) return row def _confirmed_value(self, project, field): return project["values"].get(field) def test_first_confirmation_has_no_conflict(self): with store.transaction(self.db_path) as db: self.assertIsNone(self._confirm(db, self.candidate(value="1000"))) project = self.project(db) # 不硬编码换算结果:断言"值等于被采纳证据的值",与单元/币种换算解耦 evidence = project["sources"]["facility.amount"][-1] self.assertEqual(self._confirmed_value(project, "facility.amount"), evidence["value"]) self.assertIsNotNone(self._confirmed_value(project, "facility.amount")) def test_conflicting_second_source_does_not_overwrite_and_records_evidence(self): # ★ 这是旧实现会失败的核心行为:apply_candidate 曾直接覆盖 with store.transaction(self.db_path) as db: self._confirm(db, self.candidate(value="1000")) before = self._confirmed_value(self.project(db), "facility.amount") conflict = self._confirm(db, self.candidate(value="1200", source="b.csv", locator="第9行")) self.assertIsNotNone(conflict) self.assertEqual(conflict["status"], "unresolved") self.assertEqual(conflict["field"], "facility.amount") project = self.project(db) self.assertEqual(self._confirmed_value(project, "facility.amount"), before, "已确认值被覆盖了") # 证据仍按 BR-22 留痕:两个来源都在 self.assertEqual(len(project["sources"]["facility.amount"]), 2) self.assertEqual(len(conflict["candidates"]), 2) self.assertNotEqual(conflict["candidates"][0]["value"], conflict["candidates"][1]["value"]) def test_blocked_field_refuses_confirmation(self): with store.transaction(self.db_path) as db: self._confirm(db, self.candidate(value="1000")) self._confirm(db, self.candidate(value="1200")) with self.assertRaises(fact_conflict.FactConflictUnresolved) as ctx: fact_conflict.assert_confirmable(db, self.project_id, ["facility.amount"]) self.assertIn("不一致", str(ctx.exception)) self.assertEqual(ctx.exception.code, ErrorCode.FACT_CONFLICT_UNRESOLVED) def test_other_fields_remain_confirmable(self): with store.transaction(self.db_path) as db: self._confirm(db, self.candidate(value="1000")) self._confirm(db, self.candidate(value="1200")) fact_conflict.assert_confirmable(db, self.project_id, ["financial.total_assets"]) def test_same_value_is_not_a_conflict(self): with store.transaction(self.db_path) as db: self._confirm(db, self.candidate(value="1000")) self.assertIsNone(self._confirm(db, self.candidate(value="1000", source="b.csv"))) self.assertEqual(fact_conflict.blocked_fields(db, self.project_id), set()) def test_unknown_candidate_does_not_create_conflict(self): with store.transaction(self.db_path) as db: self._confirm(db, self.candidate(value="1000")) self.assertIsNone(self._confirm(db, self.candidate(value=None))) def test_resolve_adopts_chosen_value_and_unblocks(self): with store.transaction(self.db_path) as db: self._confirm(db, self.candidate(value="1000")) conflict = self._confirm(db, self.candidate(value="1200")) chosen = conflict["candidates"][1]["evidence_id"] resolved = self._resolve(db, conflict, [chosen], "以合同金额为准") self.assertEqual(resolved["status"], "resolved") self.assertEqual(resolved["resolved_by"], self.actor) self.assertIsNotNone(resolved["resolved_at"]) project = self.project(db) # 采纳后该字段的值来自被选中的候选 self.assertEqual(str(self._confirmed_value(project, "facility.amount")), str(conflict["candidates"][1]["value"])) self.assertEqual(project["selected_sources"]["facility.amount"], chosen) self.assertEqual(fact_conflict.blocked_fields(db, self.project_id), set()) def test_resolve_can_keep_the_original_value(self): with store.transaction(self.db_path) as db: self._confirm(db, self.candidate(value="1000")) conflict = self._confirm(db, self.candidate(value="1200")) original = conflict["candidates"][0]["evidence_id"] self._resolve(db, conflict, [original], "以已确认为准") project = self.project(db) self.assertEqual(str(self._confirmed_value(project, "facility.amount")), str(conflict["candidates"][0]["value"])) def test_resolve_requires_note_and_valid_selection(self): with store.transaction(self.db_path) as db: self._confirm(db, self.candidate(value="1000")) conflict = self._confirm(db, self.candidate(value="1200")) chosen = conflict["candidates"][0]["evidence_id"] with self.assertRaises(ValueError): fact_conflict.resolve(db, conflict["id"], self.project(db), [chosen], " ", self.actor, expected=conflict["revision"]) with self.assertRaises(ValueError) as ctx: fact_conflict.resolve(db, conflict["id"], self.project(db), ["ev-999"], "说明", self.actor, expected=conflict["revision"]) self.assertIn("不属于", str(ctx.exception)) with self.assertRaises(ValueError): fact_conflict.resolve(db, conflict["id"], self.project(db), [], "说明", self.actor, expected=conflict["revision"]) # 三次被拒后冲突仍未被改动 self.assertEqual(fact_conflict.load(db, conflict["id"])["status"], "unresolved") def test_double_resolve_conflicts(self): with store.transaction(self.db_path) as db: self._confirm(db, self.candidate(value="1000")) conflict = self._confirm(db, self.candidate(value="1200")) chosen = conflict["candidates"][1]["evidence_id"] self._resolve(db, conflict, [chosen]) with self.assertRaises(store.Conflict): fact_conflict.resolve(db, conflict["id"], self.project(db), [chosen], "再裁一次", self.actor, expected=conflict["revision"]) def test_third_source_creates_new_conflict_with_supersedes(self): # AC-24:再次冲突新建对象并指向旧对象,不覆写历史 with store.transaction(self.db_path) as db: self._confirm(db, self.candidate(value="1000")) first = self._confirm(db, self.candidate(value="1200")) self._resolve(db, first, [first["candidates"][1]["evidence_id"]], "采纳1200") project = self.project(db) payload = fact_conflict.detect(project, self.candidate(value="900", source="c.csv"), self.actor, "第三来源") store.put(db, "project", project) self.assertIsNotNone(payload, "第三来源与已确认值不同,应产生冲突") second = fact_conflict.create(db, self.project_id, payload, self.actor, supersedes_id=first["id"]) self.assertEqual(second["supersedes_id"], first["id"]) self.assertNotEqual(second["id"], first["id"]) # 旧记录仍为 resolved,未被改写 old = fact_conflict.load(db, first["id"]) self.assertEqual(old["status"], "resolved") self.assertEqual(old["resolution_note"], "采纳1200") def test_non_standard_field_rejected(self): with store.transaction(self.db_path) as db: with self.assertRaises(ValueError) as ctx: fact_conflict.detect(self.project(db), self.candidate(field="not.a.real.field"), self.actor, "x") self.assertIn("不支持的字段", str(ctx.exception)) def test_legacy_path_still_adopts_value(self): # apply_candidate 的既有行为不受影响:仍然正常采用值 from services.assistant_materials import apply_candidate with store.transaction(self.db_path) as db: project = self.project(db) evidence = apply_candidate(project, self.candidate(value="1000"), self.actor, "旧路径") self.assertEqual(project["values"]["facility.amount"], evidence["value"]) self.assertEqual(project["selected_sources"]["facility.amount"], evidence["id"]) def test_adopt_false_records_evidence_without_adopting(self): from services.assistant_materials import apply_candidate with store.transaction(self.db_path) as db: project = self.project(db) evidence = apply_candidate(project, self.candidate(value="1000"), self.actor, "待裁定", adopt=False) self.assertNotIn("facility.amount", project["values"]) self.assertNotIn("facility.amount", project["facts"]) self.assertEqual(len(project["sources"]["facility.amount"]), 1) self.assertEqual(project["sources"]["facility.amount"][0]["id"], evidence["id"]) def test_listing_filters_by_status(self): with store.transaction(self.db_path) as db: self._confirm(db, self.candidate(value="1000")) conflict = self._confirm(db, self.candidate(value="1200")) self.assertEqual( len(fact_conflict.listing(db, self.project_id, status="unresolved")["items"]), 1) self.assertEqual( len(fact_conflict.listing(db, self.project_id, status="resolved")["items"]), 0) self._resolve(db, conflict, [conflict["candidates"][1]["evidence_id"]]) self.assertEqual( len(fact_conflict.listing(db, self.project_id, status="resolved")["items"]), 1) self.assertEqual( len(fact_conflict.listing(db, self.project_id, status="unresolved")["items"]), 0) def test_standard_field_whitelist_is_the_existing_one(self): # 不复制白名单:以 assistant_materials.FIELDS 为唯一来源 self.assertIn("facility.amount", fact_conflict.STANDARD_FIELDS) self.assertEqual(fact_conflict.STANDARD_FIELDS, frozenset(FIELDS.values())) def test_objects_are_persisted_through_the_generic_store(self): """复用 assistant_objects(新 kind),未新建表 —— DDL 结论的机械证明。""" with store.transaction(self.db_path) as db: batch = material_import.create(db, self.project_id, "demo", [manifest_entry()], self.actor) self._confirm(db, self.candidate(value="1000")) conflict = self._confirm(db, self.candidate(value="1200")) kinds = {row[0] for row in db.execute( "SELECT DISTINCT kind FROM assistant_objects").fetchall()} self.assertIn("import_batch", kinds) self.assertIn("fact_conflict", kinds) self.assertEqual(conflict["revision"], 1) self.assertEqual(batch["revision"], 1) # 表结构未变:仍是 S1 之前的那 5 张表 tables = {row[0] for row in db.execute( "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} self.assertTrue({"assistant_objects", "assistant_events", "assistant_idempotency", "assistant_jobs", "assistant_meta"} <= tables) class IsolationTests(StoreTestCase): def test_services_do_not_touch_the_real_data_root(self): # 本模块的测试库必须在临时目录;真实库不受影响 self.assertNotIn("xingbang_data", str(self.db_path)) self.assertTrue(str(self.db_path).startswith(tempfile.gettempdir().replace("\\", "/")) or "Temp" in str(self.db_path) or "tmp" in str(self.db_path)) def test_contracts_module_not_imported_by_services_is_false_direction_guard(self): # 方向守卫:contracts 不得导入 services(本测试从 services 侧反查) import contracts.prescreen as module with open(module.__file__, encoding="utf-8") as handle: source = handle.read() self.assertNotIn("from services", source) self.assertNotIn("import services", source) if __name__ == "__main__": unittest.main()