Files
Video-Tree-TRM5/tests/unit/test_patch.py
T
iomgaa 3005f24577 feat(evolution): patch.py — 补丁引擎移植(算法 #9),51 测试全通过
从 TRM4 core/harness/patch.py (427 行) 零修改移植到 core/evolution/patch.py。

包含:
- 7 个常量(APPENDIX/MOMENTUM marker + MAX_CHARS + HEADING)
- appendix 区:追加/提取/替换/边界检测,损坏态 ValueError
- momentum 区:替换/提取/边界检测,注入防护
- apply_patch_with_report:4 种 op(append/insert_after/replace/delete)
- 冻结区坐标判定(每条 edit 重算)、report 1-based index
- 51 个单元测试覆盖全部公共 API 及边缘场景

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-07 09:44:05 -04:00

392 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 不 strippayload 做 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)