"""Tests for the deterministic regulation differ. These tests pin the behaviour that the previous cosine-similarity implementation could not deliver: real regulatory edits (numeric limits, deontic modals) must be detected, and inserting a paragraph must not report unrelated paragraphs as changed. Everything here runs offline — no LLM, no network, no embeddings. """ from __future__ import annotations from app.infrastructure.perception.regulation_differ import RegulationDiffer def _differ() -> RegulationDiffer: """Build a differ with an explicit ratio so tests never depend on .env.""" return RegulationDiffer(min_change_ratio=0.02) def test_identical_documents_report_no_changes(): """An unchanged regulation must produce an empty change list.""" text = "第一条 车辆制动系统应在时速50公里条件下于30米内完全停止。\n第二条 驾驶员座椅面料的阻燃性能应符合附录B的规定。" assert _differ().diff(text, text) == [] def test_numeric_tightening_is_detected(): """A changed numeric limit is the case cosine similarity scored 0.9153 and missed.""" old = "第一条 车辆制动系统应在时速50公里条件下于30米内完全停止。" new = "第一条 车辆制动系统应在时速50公里条件下于20米内完全停止。" changes = _differ().diff(old, new) assert len(changes) == 1 change = changes[0] assert change.change_type == "modified" assert change.numeric_changed is True assert change.needs_llm is True def test_deontic_relaxation_is_detected(): """Weakening 应当 to 宜 changes the legal force and must be flagged.""" old = "第三条 生产企业应当每年开展一次安全评估。" new = "第三条 生产企业宜每年开展一次安全评估。" changes = _differ().diff(old, new) assert len(changes) == 1 assert changes[0].deontic_changed is True assert changes[0].needs_llm is True def test_prohibition_removal_is_detected(): """Dropping 不得 flips a prohibition into a permission.""" old = "第四条 车辆不得使用未经认证的电池组。" new = "第四条 车辆可以使用经备案的电池组。" changes = _differ().diff(old, new) assert len(changes) == 1 assert changes[0].deontic_changed is True def test_inserted_paragraph_does_not_shift_the_rest(): """Regression test for positional alignment. The previous implementation compared old[i] to new[i], so inserting one paragraph at the top reported every following paragraph as changed. With sequence alignment only the inserted paragraph is new. """ old = "\n".join([ "第一条 本标准规定了车辆制动系统的技术要求。", "第二条 车辆制动系统应在时速50公里条件下于30米内完全停止。", "第三条 驾驶员座椅面料的阻燃性能应符合附录B的规定。", ]) new = "\n".join([ "第零条 本标准适用于所有M1类车辆。", "第一条 本标准规定了车辆制动系统的技术要求。", "第二条 车辆制动系统应在时速50公里条件下于30米内完全停止。", "第三条 驾驶员座椅面料的阻燃性能应符合附录B的规定。", ]) changes = _differ().diff(old, new) assert len(changes) == 1, f"expected only the inserted paragraph, got {changes}" assert changes[0].change_type == "added" assert "第零条" in changes[0].new_text assert changes[0].old_text == "" def test_deleted_paragraph_is_reported_once(): """Removing a provision yields exactly one 'removed' record.""" old = "\n".join([ "第一条 本标准规定了车辆制动系统的技术要求。", "第二条 车辆制动系统应在时速50公里条件下于30米内完全停止。", "第三条 驾驶员座椅面料的阻燃性能应符合附录B的规定。", ]) new = "\n".join([ "第一条 本标准规定了车辆制动系统的技术要求。", "第三条 驾驶员座椅面料的阻燃性能应符合附录B的规定。", ]) changes = _differ().diff(old, new) assert len(changes) == 1 assert changes[0].change_type == "removed" assert "第二条" in changes[0].old_text assert changes[0].new_text == "" assert changes[0].needs_llm is True def test_trivial_edit_is_not_sent_to_the_llm(): """A cosmetic edit with no number or modal change must not cost an LLM call.""" old = "第五条 本标准由全国汽车标准化技术委员会归口管理。" new = "第五条 本标准由全国汽车标准化技术委员会归口管理" changes = _differ().diff(old, new) for change in changes: assert change.numeric_changed is False assert change.deontic_changed is False assert change.needs_llm is False, f"trivial edit was gated to the LLM: {change}" def test_large_rewrite_is_sent_to_the_llm(): """A substantial rewrite clears the change-ratio gate even without numbers or modals.""" old = "第六条 本标准参考了国际同类标准的相关内容。" new = "第六条 本条款描述了完全不同的主题内容,涉及整车认证流程与型式试验的组织安排。" changes = _differ().diff(old, new) assert len(changes) == 1 assert changes[0].change_ratio >= 0.02 assert changes[0].needs_llm is True def test_empty_old_text_yields_no_changes(): """A first crawl has no baseline, so there is nothing to diff.""" assert _differ().diff("", "第一条 任意内容。") == [] def test_unchanged_paragraphs_are_never_returned(): """Only changed paragraphs appear; equal ones are dropped.""" old = "\n".join([ "第一条 保持不变的条款。", "第二条 车辆制动距离不得超过30米。", "第三条 另一条保持不变的条款。", ]) new = "\n".join([ "第一条 保持不变的条款。", "第二条 车辆制动距离不得超过20米。", "第三条 另一条保持不变的条款。", ]) changes = _differ().diff(old, new) assert len(changes) == 1 assert changes[0].numeric_changed is True assert "第二条" in changes[0].old_text