"""T03 类型化来源定位测试:parse()/material() 必须产出契约合法的 SourceLocator。 背景:T02 定义了 `contracts.prescreen.SourceLocator`,但当时**没有接上** (parse() 只产出自由字符串 locator)。本文件锁定这条链路: parse() 产出 kind + 坐标 → material() 补 document_id/document_version → 契约校验 同时锁定 `locator` 自由字符串**必须保留**:enqueue() 用它算证据 ID 的稳定哈希, 改动它会破坏 BR-13 的版本冻结。 """ import io import unittest import zipfile from pathlib import Path from contracts.prescreen import SourceLocator from services.assistant_materials import material, parse def xlsx_bytes(rows=(("field", "value"), ("facility.amount", "100"))): """构造最小可解析 XLSX(inlineStr,无需 sharedStrings)。""" ns = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"' rel = 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"' sheet_rows = "".join( "%s" % ( index, "".join( f'{cell}' for col, cell in enumerate(row) ), ) for index, row in enumerate(rows, 1) ) parts = { "xl/workbook.xml": f'', "xl/_rels/workbook.xml.rels": '' '', "xl/worksheets/sheet1.xml": f'{sheet_rows}', } buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as archive: for name, body in parts.items(): archive.writestr(name, body) return buffer.getvalue() def docx_bytes(): """构造最小可解析 DOCX:一段正文 + 一张表。""" ns = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' body = ( f'正文第一段' f'甲' f'乙' ) buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as archive: archive.writestr("word/document.xml", f"{body}") return buffer.getvalue() class TypedLocatorTests(unittest.TestCase): def locators(self, content, filename): document = material("DEMO-A", filename, content, "tester") return document, [chunk["source_locator"] for chunk in document["chunks"]] def test_csv_rows_become_table_locators(self): content = "field,value\nfacility.amount,100\n".encode("utf-8-sig") document, locators = self.locators(content, "流水.csv") first = locators[0] self.assertEqual(first["kind"], "table") self.assertEqual(first["sheet"], "流水") # 文件名去扩展名 self.assertEqual(first["cell_range"], "第1行") self.assertEqual(first["document_id"], document["id"]) self.assertEqual(first["document_version"], 1) def test_txt_rows_become_table_locators(self): content = "第一行\n第二行\n".encode("utf-8-sig") _, locators = self.locators(content, "说明.txt") self.assertEqual(locators[1]["cell_range"], "第2行") self.assertEqual(locators[1]["kind"], "table") def test_xlsx_cells_and_rows_become_table_locators(self): _, locators = self.locators(xlsx_bytes(), "台账.xlsx") kinds = {item["kind"] for item in locators} self.assertEqual(kinds, {"table"}) # 单元格级定位:A1 必须是 sheet + cell_range,而不是行号 cell = next(item for item in locators if item["cell_range"] == "A1") self.assertEqual(cell["sheet"], "Sheet1") # 行级定位也保留 self.assertTrue(any(item["cell_range"].endswith("行") for item in locators)) def test_docx_paragraph_is_manual_and_table_is_table(self): _, locators = self.locators(docx_bytes(), "说明.docx") by_kind = {} for item in locators: by_kind.setdefault(item["kind"], []).append(item) self.assertIn("manual", by_kind, "DOCX 正文段应归 manual(三值枚举里没有段落子类型)") self.assertIn("table", by_kind, "DOCX 表格应归 table") self.assertEqual(by_kind["manual"][0]["note"], "正文第1段") self.assertEqual(by_kind["table"][0]["sheet"], "表1") def test_every_locator_validates_against_the_contract(self): # material() 已按契约校验;这里再独立复核一遍,防止校验被绕过 for content, name in [(xlsx_bytes(), "台账.xlsx"), (docx_bytes(), "说明.docx"), ("a,b\n".encode("utf-8-sig"), "t.csv")]: with self.subTest(name=name): _, locators = self.locators(content, name) self.assertTrue(locators) for item in locators: SourceLocator(**item) # 不合法会抛 ValidationError def test_free_string_locator_is_preserved(self): """locator 必须保留:enqueue() 依赖它计算证据 ID 的稳定哈希(BR-13)。""" content = "field,value\nfacility.amount,100\n".encode("utf-8-sig") document = material("DEMO-A", "流水.csv", content, "tester") strings = [chunk["locator"] for chunk in document["chunks"]] self.assertIn("第1行", strings) self.assertIn("第2行", strings) # 每个 chunk 同时有两种形态 for chunk in document["chunks"]: self.assertIn("locator", chunk) self.assertIn("source_locator", chunk) self.assertIsInstance(chunk["locator"], str) def test_parse_without_material_fillup_has_no_document_id(self): """parse() 本身不知道 document_id——这正是补齐放在 material() 的原因。""" chunks, _ = parse("a\n".encode("utf-8-sig"), "t.txt") raw = chunks[0]["source_locator"] self.assertNotIn("document_id", raw) self.assertEqual(raw["kind"], "table") def test_document_version_is_recorded_not_assumed(self): content = "a,b\n".encode("utf-8-sig") document = material("DEMO-A", "v.csv", content, "tester", version=7) self.assertEqual(document["version"], 7) self.assertEqual(document["chunks"][0]["source_locator"]["document_version"], 7) def test_locator_cannot_cross_documents(self): """两个文档的定位互不串用:document_id 是各自的。""" a = material("DEMO-A", "a.csv", "x,y\n".encode("utf-8-sig"), "tester") b = material("DEMO-A", "b.csv", "x,y\n".encode("utf-8-sig"), "tester") self.assertNotEqual(a["id"], b["id"]) self.assertEqual(a["chunks"][0]["source_locator"]["document_id"], a["id"]) self.assertEqual(b["chunks"][0]["source_locator"]["document_id"], b["id"]) # 文档 a 的定位不可能出现在文档 b 里 self.assertNotIn(a["id"], [c["source_locator"]["document_id"] for c in b["chunks"]]) def test_missing_typed_locator_is_rejected(self): """防御性断言:若某条解析路径漏了 source_locator,material() 必须失败而不是静默产半成品。""" import services.assistant_materials as materials from unittest.mock import patch original = materials.parse def stripped(content, filename): chunks, warnings = original(content, filename) for chunk in chunks: chunk.pop("source_locator", None) return chunks, warnings with patch.object(materials, "parse", stripped): with self.assertRaises(ValueError) as ctx: materials.material("DEMO-A", "t.csv", "a,b\n".encode("utf-8-sig"), "tester") self.assertIn("类型化定位", str(ctx.exception)) class ReferenceValidationTests(unittest.TestCase): """AC-08:无效或跨项目引用必须被拒,且不暴露无权原文。""" def setUp(self): import tempfile from services.project_assistant import ProjectAssistantService self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) self.service = ProjectAssistantService(Path(self.temp.name) / "assistant.sqlite3") def tearDown(self): self.temp.cleanup() def test_candidate_from_another_project_is_rejected(self): payload = "field,value,currency,unit,period,scope\nfacility.amount,100,CNY,万元,2025,合并\n".encode("utf-8-sig") document = self.service.upload("DEMO-A", "流水.csv", payload, "demo-manager") self.assertTrue(document["candidates"], "解析应产出候选") candidate_id = document["candidates"][0]["id"] with self.assertRaises(ValueError) as ctx: self.service.supplement("DEMO-B", "demo-manager", {}, [], self.service.get_project("DEMO-B")["revision"], "跨项目引用测试", candidate_ids=[candidate_id]) self.assertIn("不属于项目", str(ctx.exception)) def test_candidate_reference_is_project_scoped(self): """同项目可用、跨项目不可用——证明拒绝来自项目范围而不是候选不存在。""" payload = "field,value,currency,unit,period,scope\nfacility.amount,100,CNY,万元,2025,合并\n".encode("utf-8-sig") document = self.service.upload("DEMO-A", "流水.csv", payload, "demo-manager") candidate_id = document["candidates"][0]["id"] project = self.service.get_project("DEMO-A") result = self.service.supplement("DEMO-A", "demo-manager", {}, [], project["revision"], "同项目确认来源", candidate_ids=[candidate_id]) self.assertEqual(result["project_id"], "DEMO-A") self.assertIn("facility.amount", result["changed_fields"]) def test_unknown_candidate_id_is_also_rejected(self): project = self.service.get_project("DEMO-A") with self.assertRaises(ValueError): self.service.supplement("DEMO-A", "demo-manager", {}, [], project["revision"], "伪造引用", candidate_ids=["candidate-does-not-exist"]) if __name__ == "__main__": unittest.main()