"""patch.py 补丁引擎单元测试。 覆盖四大区域: 1. TestRegionBounds — appendix/momentum 边界定位与损坏态检测 2. TestAppendix — 追加/提取/替换 appendix 区 3. TestMomentum — 替换/提取 momentum 区 4. TestApplyPatch — apply_patch_with_report 的 4 种 op + 冻结区 + 异常 """ from __future__ import annotations import pytest from core.evolution.patch import ( APPENDIX_END, APPENDIX_MAX_CHARS, APPENDIX_START, MOMENTUM_END, MOMENTUM_HEADING, MOMENTUM_MAX_CHARS, MOMENTUM_START, append_to_appendix, appendix_region_bounds, apply_patch_with_report, extract_appendix_notes, momentum_inner, momentum_region_bounds, replace_appendix_notes, replace_momentum, ) # ── TestRegionBounds ────────────────────────────────────────────── class TestRegionBounds: """appendix_region_bounds / momentum_region_bounds 边界与损坏态检测。""" # -- appendix -- def test_appendix_both_absent_returns_none(self) -> None: assert appendix_region_bounds("no markers here") is None def test_appendix_normal_pair(self) -> None: text = f"head\n{APPENDIX_START}\nnotes\n{APPENDIX_END}\ntail" start, end = appendix_region_bounds(text) # type: ignore[misc] assert text[start:end].startswith(APPENDIX_START) assert text[start:end].endswith(APPENDIX_END) def test_appendix_only_start_raises(self) -> None: with pytest.raises(ValueError, match="不配对"): appendix_region_bounds(f"head\n{APPENDIX_START}\nno end") def test_appendix_only_end_raises(self) -> None: with pytest.raises(ValueError, match="不配对"): appendix_region_bounds(f"head\n{APPENDIX_END}\nno start") def test_appendix_repeated_start_raises(self) -> None: text = f"{APPENDIX_START}\n{APPENDIX_START}\n{APPENDIX_END}" with pytest.raises(ValueError, match="不配对"): appendix_region_bounds(text) def test_appendix_reversed_raises(self) -> None: text = f"{APPENDIX_END}\nbody\n{APPENDIX_START}" with pytest.raises(ValueError, match="不配对"): appendix_region_bounds(text) # -- momentum -- def test_momentum_both_absent_returns_none(self) -> None: assert momentum_region_bounds("no markers here") is None def test_momentum_normal_pair(self) -> None: text = f"head\n{MOMENTUM_START}\nguidance\n{MOMENTUM_END}\ntail" start, end = momentum_region_bounds(text) # type: ignore[misc] assert text[start:end].startswith(MOMENTUM_START) assert text[start:end].endswith(MOMENTUM_END) def test_momentum_only_start_raises(self) -> None: with pytest.raises(ValueError, match="不配对"): momentum_region_bounds(f"head\n{MOMENTUM_START}\nno end") def test_momentum_only_end_raises(self) -> None: with pytest.raises(ValueError, match="不配对"): momentum_region_bounds(f"head\n{MOMENTUM_END}\nno start") def test_momentum_repeated_end_raises(self) -> None: text = f"{MOMENTUM_START}\n{MOMENTUM_END}\n{MOMENTUM_END}" with pytest.raises(ValueError, match="不配对"): momentum_region_bounds(text) def test_momentum_reversed_raises(self) -> None: text = f"{MOMENTUM_END}\nbody\n{MOMENTUM_START}" with pytest.raises(ValueError, match="不配对"): momentum_region_bounds(text) # ── TestAppendix ────────────────────────────────────────────────── class TestAppendix: """append_to_appendix / extract_appendix_notes / replace_appendix_notes。""" def test_append_creates_region(self) -> None: out = append_to_appendix("# Skill\nbody", ["reminder A"]) assert APPENDIX_START in out assert APPENDIX_END in out assert "- reminder A" in out def test_append_accumulates(self) -> None: step1 = append_to_appendix("# Skill\nbody", ["note 1"]) step2 = append_to_appendix(step1, ["note 2"]) assert "- note 1" in step2 assert "- note 2" in step2 def test_append_empty_notes_returns_unchanged(self) -> None: original = "# Skill\nbody" assert append_to_appendix(original, []) is original def test_append_all_whitespace_notes_returns_unchanged(self) -> None: original = "# Skill\nbody" assert append_to_appendix(original, [" ", "\t", ""]) == original def test_extract_notes_roundtrip(self) -> None: content = append_to_appendix("# Skill\nbody", ["aaa", "bbb"]) notes = extract_appendix_notes(content) assert notes == ["aaa", "bbb"] def test_extract_notes_no_region(self) -> None: assert extract_appendix_notes("no region") == [] def test_replace_notes_overwrites(self) -> None: content = append_to_appendix("# Skill\nbody", ["old"]) replaced = replace_appendix_notes(content, ["new1", "new2"]) notes = extract_appendix_notes(replaced) assert notes == ["new1", "new2"] assert "old" not in replaced def test_replace_notes_empty_deletes_region(self) -> None: content = append_to_appendix("# Skill\nbody", ["old"]) replaced = replace_appendix_notes(content, []) assert APPENDIX_START not in replaced assert APPENDIX_END not in replaced def test_replace_notes_no_region_creates(self) -> None: replaced = replace_appendix_notes("# Skill\nbody", ["fresh"]) assert "- fresh" in replaced assert APPENDIX_START in replaced def test_replace_notes_no_region_empty_notes_unchanged(self) -> None: original = "# Skill\nbody" assert replace_appendix_notes(original, []) == original def test_append_warns_on_exceeding_max_chars(self, caplog: pytest.LogCaptureFixture) -> None: """appendix 区超长时 loguru warning,不截断。""" long_note = "x" * (APPENDIX_MAX_CHARS + 100) with caplog.at_level("WARNING"): out = append_to_appendix("body", [long_note]) assert long_note in out # 不截断 # ── TestMomentum ────────────────────────────────────────────────── class TestMomentum: """replace_momentum / momentum_inner。""" def test_replace_creates_region(self) -> None: out = replace_momentum("# Skill\nbody", "focus on X") assert MOMENTUM_START in out assert MOMENTUM_END in out assert "focus on X" in out def test_replace_overwrites(self) -> None: step1 = replace_momentum("# Skill\nbody", "old guidance") step2 = replace_momentum(step1, "new guidance") assert "new guidance" in step2 assert "old guidance" not in step2 def test_replace_empty_guidance_clears(self) -> None: """空 guidance 合法:清空旧动量、保留标题和 marker。""" step1 = replace_momentum("# Skill\nbody", "old guidance") step2 = replace_momentum(step1, "") assert MOMENTUM_START in step2 assert MOMENTUM_END in step2 assert "old guidance" not in step2 assert MOMENTUM_HEADING in step2 def test_replace_rejects_marker_in_guidance(self) -> None: with pytest.raises(ValueError, match="marker"): replace_momentum("body", f"bad {MOMENTUM_START} injection") def test_replace_rejects_end_marker_in_guidance(self) -> None: with pytest.raises(ValueError, match="marker"): replace_momentum("body", f"bad {MOMENTUM_END} injection") def test_momentum_inner_returns_guidance(self) -> None: content = replace_momentum("# Skill\nbody", "focus on X") inner = momentum_inner(content) assert inner == "focus on X" def test_momentum_inner_no_region(self) -> None: assert momentum_inner("no region") == "" def test_momentum_inner_strips_heading(self) -> None: content = replace_momentum("body", "some guidance") inner = momentum_inner(content) assert MOMENTUM_HEADING not in inner assert inner == "some guidance" def test_replace_warns_on_exceeding_max_chars(self, caplog: pytest.LogCaptureFixture) -> None: """momentum 区超长时 loguru warning,不截断。""" long_guidance = "y" * (MOMENTUM_MAX_CHARS + 100) with caplog.at_level("WARNING"): out = replace_momentum("body", long_guidance) assert long_guidance in out # 不截断 def test_coexists_with_appendix(self) -> None: """momentum 与 appendix 独立共存。""" content = append_to_appendix("# Skill\nbody", ["note A"]) content = replace_momentum(content, "focus on X") assert APPENDIX_START in content assert APPENDIX_END in content assert MOMENTUM_START in content assert MOMENTUM_END in content notes = extract_appendix_notes(content) assert notes == ["note A"] inner = momentum_inner(content) assert inner == "focus on X" # ── TestApplyPatch ──────────────────────────────────────────────── class TestApplyPatch: """apply_patch_with_report 的 4 种 op + 冻结区 + 异常处理。""" def test_append(self) -> None: content = "# Title\n\nbody text" edits = [{"op": "append", "target": "", "content": "new section"}] out, reports = apply_patch_with_report(content, edits) assert "new section" in out assert reports[0]["status"] == "applied_append" assert reports[0]["index"] == 1 def test_insert_after_success(self) -> None: content = "# Title\n\nanchor line\n\nrest" edits = [{"op": "insert_after", "target": "anchor line", "content": "inserted"}] out, reports = apply_patch_with_report(content, edits) assert "inserted" in out assert reports[0]["status"] == "applied_insert_after" def test_insert_after_missing_target_fallback(self) -> None: content = "# Title\n\nbody" edits = [{"op": "insert_after", "target": "nonexistent", "content": "payload"}] out, reports = apply_patch_with_report(content, edits) assert "payload" in out assert reports[0]["status"] == "applied_insert_after_fallback" def test_insert_after_protected_skip(self) -> None: content = "# Title\n\nFROZEN BLOCK\n\nrest" edits = [{"op": "insert_after", "target": "FROZEN BLOCK", "content": "nope"}] out, reports = apply_patch_with_report( content, edits, protected_spans=["FROZEN BLOCK"] ) assert out == content assert reports[0]["status"] == "skipped_protected" def test_replace(self) -> None: content = "# Title\n\nold text\n\nrest" edits = [{"op": "replace", "target": "old text", "content": "new text"}] out, reports = apply_patch_with_report(content, edits) assert "new text" in out assert "old text" not in out assert reports[0]["status"] == "applied_replace" def test_replace_protected_skip(self) -> None: content = "# Title\n\nprotected\n\nrest" edits = [{"op": "replace", "target": "protected", "content": "nope"}] out, reports = apply_patch_with_report( content, edits, protected_spans=["protected"] ) assert "protected" in out assert reports[0]["status"] == "skipped_protected" def test_delete(self) -> None: content = "# Title\n\nremove me\n\nrest" edits = [{"op": "delete", "target": "remove me", "content": ""}] out, reports = apply_patch_with_report(content, edits) assert "remove me" not in out assert reports[0]["status"] == "applied_delete" def test_unknown_op(self) -> None: edits = [{"op": "magic", "target": "x", "content": "y"}] out, reports = apply_patch_with_report("body", edits) assert reports[0]["status"] == "skipped_unknown_op" def test_non_dict_edit(self) -> None: edits = ["not a dict"] # type: ignore[list-item] out, reports = apply_patch_with_report("body", edits) assert reports[0]["status"] == "error" assert "非 dict" in reports[0].get("error", "") def test_missing_target_for_replace(self) -> None: edits = [{"op": "replace", "target": "", "content": "payload"}] out, reports = apply_patch_with_report("body", edits) assert reports[0]["status"] == "skipped_missing_target" def test_missing_target_for_delete(self) -> None: edits = [{"op": "delete", "target": "", "content": ""}] out, reports = apply_patch_with_report("body", edits) assert reports[0]["status"] == "skipped_missing_target" def test_report_index_is_1_based(self) -> None: edits = [ {"op": "append", "target": "", "content": "a"}, {"op": "append", "target": "", "content": "b"}, {"op": "append", "target": "", "content": "c"}, ] _, reports = apply_patch_with_report("body", edits) assert [r["index"] for r in reports] == [1, 2, 3] def test_report_truncation(self) -> None: long_target = "x" * 300 long_content = "y" * 300 edits = [{"op": "replace", "target": long_target, "content": long_content}] _, reports = apply_patch_with_report(long_target, edits) assert len(reports[0]["target"]) == 200 assert len(reports[0]["content_preview"]) == 200 def test_ranges_recalculated_each_edit(self) -> None: """冻结区坐标在每条 edit 后重新计算。""" protected = "FREEZE" content = f"AAA\n{protected}\nBBB" edits = [ {"op": "append", "target": "", "content": "prefix text"}, {"op": "replace", "target": protected, "content": "nope"}, ] out, reports = apply_patch_with_report( content, edits, protected_spans=[protected] ) # append 在 FREEZE 之前插入,坐标右移后 replace 仍能检测冻结区 assert reports[1]["status"] == "skipped_protected" def test_append_before_earliest_protected(self) -> None: """append 插到 start>0 的最早冻结区之前。""" content = f"---\nfrontmatter\n---\n\nbody\n\n{APPENDIX_START}\nold\n{APPENDIX_END}" edits = [{"op": "append", "target": "", "content": "INSERTED"}] out, reports = apply_patch_with_report( content, edits, protected_spans=[f"{APPENDIX_START}\nold\n{APPENDIX_END}"] ) app_pos = out.find(APPENDIX_START) ins_pos = out.find("INSERTED") assert ins_pos < app_pos, "append 应在冻结区之前" def test_payload_stripped_target_not_stripped(self) -> None: """target 不 strip,payload 做 strip。""" content = " spaced target \nrest" edits = [ { "op": "replace", "target": " spaced target ", "content": " trimmed ", } ] out, reports = apply_patch_with_report(content, edits) # payload stripped → "trimmed" assert "trimmed" in out assert " trimmed " not in out assert reports[0]["status"] == "applied_replace" def test_replace_count_one(self) -> None: """replace 只替换第一次出现。""" content = "dup\ndup\ndup" edits = [{"op": "replace", "target": "dup", "content": "REPLACED"}] out, _ = apply_patch_with_report(content, edits) assert out.count("REPLACED") == 1 assert out.count("dup") == 2 def test_multiple_edits_sequential(self) -> None: """多条 edit 顺序执行。""" content = "line1\nline2\nline3" edits = [ {"op": "replace", "target": "line1", "content": "LINE_ONE"}, {"op": "delete", "target": "line2", "content": ""}, {"op": "append", "target": "", "content": "TAIL"}, ] out, reports = apply_patch_with_report(content, edits) assert "LINE_ONE" in out assert "line2" not in out assert "TAIL" in out assert all(r["status"].startswith("applied") for r in reports)