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>
This commit is contained in:
2026-07-07 09:44:05 -04:00
parent 4eefbfdd74
commit 3005f24577
2 changed files with 818 additions and 0 deletions
+427
View File
@@ -0,0 +1,427 @@
"""定点补丁引擎:把进化输出的离散 edit 逐条应用到文本,逐条出状态报告。
借鉴 SkillOpt skill.py 的 apply 语义;守 P5:找不到锚点不静默乱改、不裸 except。
冻结区按全文坐标区间判定;append/退化追加插到最早冻结区之前(无则 EOF)。
"""
from __future__ import annotations
from loguru import logger
APPENDIX_START = "<!-- APPENDIX_START -->"
APPENDIX_END = "<!-- APPENDIX_END -->"
APPENDIX_MAX_CHARS = 2000 # appendix 区软上限(守设计「长度上限+warning,不做去重」)
MOMENTUM_START = "<!-- MOMENTUM_START -->"
MOMENTUM_END = "<!-- MOMENTUM_END -->"
MOMENTUM_MAX_CHARS = 2000 # momentum 区软上限(与 appendix 一致:超限 warning 不截断)
MOMENTUM_HEADING = (
"## 动量指导(每轮重写,勿手改)" # replace_momentum 写入的固定标题行
)
def momentum_region_bounds(text: str) -> tuple[int, int] | None:
"""定位 momentum 受保护区的字符区间,并对损坏态显式报错(P5)。
momentum marker 由 replace_momentum 在 epoch 末反复重写,guidance 又来自 LLM
外部输入,因此 marker 可能出现损坏态。本函数是 momentum 路径的唯一边界判定入口,
把配对校验集中在一处:
- START 与 END 各恰好出现一次且 START 在 END 之前 → 返回 (start_idx, end_idx)
end_idx 指向 END marker 结束位置(即 content[start:end] 含完整两 marker)。
- 两 marker 都不出现 → 返回 None(合法的"无区"态,调用方据此新建)。
- 其余皆为损坏态(仅一个 marker、END 在 START 前、任一 marker 重复)→ raise
ValueError,拒绝静默新建/跳过,要求人工修复。
参数:
text: 待检测的文本(skill 全文)。
返回:
(start_idx, end_idx) 表示区间,或 None 表示无 momentum 区。
异常:
ValueError: momentum marker 损坏/不配对。
"""
start_count = text.count(MOMENTUM_START)
end_count = text.count(MOMENTUM_END)
if start_count == 0 and end_count == 0:
return None
if start_count != 1 or end_count != 1:
raise ValueError(
f"momentum marker 损坏/不配对:MOMENTUM_START 出现 {start_count} 次、"
f"MOMENTUM_END 出现 {end_count} 次(各须恰好 1 次),需人工修复"
)
start_idx = text.index(MOMENTUM_START)
end_idx = text.index(MOMENTUM_END) + len(MOMENTUM_END)
if start_idx >= text.index(MOMENTUM_END):
raise ValueError(
"momentum marker 损坏/不配对:MOMENTUM_END 出现在 MOMENTUM_START 之前,需人工修复"
)
return start_idx, end_idx
def momentum_inner(content: str) -> str:
"""返回 momentum 受保护区的内层文本(去掉两 marker),无区返回空串。
与 _momentum_span(含 marker 的整段)的区别:本函数只取两 marker 之间的内层正文,
供 run_slow_momentum 的 prev_guidance 使用。prev_guidance 在 LLM 解析失败时会被
run_slow_momentum 原样返回、再喂给 replace_momentumreplace_momentum 禁止 guidance
含 marker 字面量,故 prev_guidance 必须是无 marker 的内层文本,否则一旦解析回退即
在 replace_momentum 抛 ValueError。
边界判定与配对校验统一委托 momentum_region_boundsmarker 损坏/不配对时由其 raise
ValueError,本函数不把损坏态静默当作"无区"
参数:
content: skill 全文。
返回:
momentum 区两 marker 之间的内层文本(已 strip);无区返回空串。
异常:
ValueError: momentum marker 损坏/不配对。
"""
bounds = momentum_region_bounds(content)
if bounds is None:
return ""
start, end = bounds
inner = content[start + len(MOMENTUM_START) : end - len(MOMENTUM_END)].strip()
# 去掉 replace_momentum 写入的固定标题行,只回传纯指导文本,使其等价于上一轮
# 传给 replace_momentum 的 guidance(解析回退时原样回传不会引入重复标题)。
if inner.startswith(MOMENTUM_HEADING):
inner = inner[len(MOMENTUM_HEADING) :].lstrip("\n")
return inner.strip()
def append_to_appendix(content: str, notes: list[str]) -> str:
"""把 LAPSE 提醒追加到文件尾的 appendix 受保护区;区不存在则创建。
护栏:appendix 区超过 APPENDIX_MAX_CHARS 时 logger.warning(不静默截断,
提示人工压缩;不做自动去重——YAGNI,见设计)。
参数:
content: 原文。
notes: 待追加的提醒文本列表。
返回:
含 appendix 区的新文本。
"""
if not notes:
return content
bullet = "\n".join(f"- {n.strip()}" for n in notes if n.strip())
if not bullet:
return content
if APPENDIX_START in content and APPENDIX_END in content:
head, rest = content.split(APPENDIX_START, 1)
inner, tail = rest.split(APPENDIX_END, 1)
new_inner = f"{inner.rstrip()}\n{bullet}"
out = f"{head}{APPENDIX_START}{new_inner}\n{APPENDIX_END}{tail}"
else:
new_inner = f"\n## 执行提醒(自动累积,勿手改)\n{bullet}"
out = f"{content.rstrip()}\n\n{APPENDIX_START}{new_inner}\n{APPENDIX_END}\n"
if len(new_inner) > APPENDIX_MAX_CHARS:
logger.warning(
"appendix 区长度 {} 超过上限 {},建议人工压缩",
len(new_inner),
APPENDIX_MAX_CHARS,
)
return out
def appendix_region_bounds(text: str) -> tuple[int, int] | None:
"""定位 appendix 受保护区的字符区间,对损坏态显式报错(P5,对称 momentum_region_bounds)。
appendix marker 由 append_to_appendix 维护、consolidation 回写,可能出现损坏态。
本函数是 appendix 路径的唯一边界判定入口,把配对校验集中一处:
- START 与 END 各恰好一次且 START 在 END 之前 → 返回 (start_idx, end_idx)
end_idx 指向 END marker 结束位置(content[start:end] 含完整两 marker)。
- 两 marker 都不出现 → 返回 None(合法的「无区」态)。
- 其余(仅一个 marker、END 在 START 前、任一 marker 重复)→ raise ValueError
拒绝静默按字符串切片处理而误拼/吞掉区外正文。
参数:
text: 待检测文本(skill 全文)。
返回:
(start_idx, end_idx) 表示区间,或 None 表示无 appendix 区。
异常:
ValueError: appendix marker 损坏/不配对。
"""
start_count = text.count(APPENDIX_START)
end_count = text.count(APPENDIX_END)
if start_count == 0 and end_count == 0:
return None
if start_count != 1 or end_count != 1:
raise ValueError(
f"appendix marker 损坏/不配对:APPENDIX_START 出现 {start_count} 次、"
f"APPENDIX_END 出现 {end_count} 次(各须恰好 1 次),需人工修复"
)
start_idx = text.index(APPENDIX_START)
end_idx = text.index(APPENDIX_END) + len(APPENDIX_END)
if start_idx >= text.index(APPENDIX_END):
raise ValueError(
"appendix marker 损坏/不配对:APPENDIX_END 出现在 APPENDIX_START 之前,需人工修复"
)
return start_idx, end_idx
def extract_appendix_notes(content: str) -> list[str]:
"""从 appendix 受保护区解析出 bullet 提醒列表;无区返回空列表。
功能:
取 appendix 区内每行以 "- " 起头的文本为一条 note(去 "- " 前缀与首尾空白),
区内标题行(## 执行提醒…)不计。供 consolidation 读取现有 notes。
参数:
content: skill 全文。
返回:
note 字符串列表;无 appendix 区返回 []。
异常:
ValueError: appendix marker 损坏/不配对(经 appendix_region_bounds,不静默切片)。
关键实现细节:
边界判定统一委托 appendix_region_bounds,只取两 marker 之间内层正文逐行解析。
"""
bounds = appendix_region_bounds(content)
if bounds is None:
return []
start, end = bounds
inner = content[start + len(APPENDIX_START) : end - len(APPENDIX_END)]
notes: list[str] = []
for line in inner.splitlines():
stripped = line.strip()
if stripped.startswith("- "):
note = stripped[2:].strip()
if note:
notes.append(note)
return notes
def replace_appendix_notes(content: str, notes: list[str]) -> str:
"""用 notes 整体替换 appendix 区内容;notes 为空则删除整个 appendix 区。
功能:
consolidation 回写压缩后 notes 的替换语义(区别于 append_to_appendix 累积):
区存在则整体覆盖区内 bulletnotes 空则连 marker 一并删除、保留区外正文;
区不存在且 notes 非空则按 append_to_appendix 格式新建。
参数:
content: 原文(可能含 appendix 区)。
notes: 压缩后的提醒列表;空列表表示删区。
返回:
替换后的全文。
异常:
ValueError: appendix marker 损坏/不配对(经 appendix_region_bounds)。
关键实现细节:
边界经 appendix_region_bounds 显式校验,按 (start,end) 切出 head/tail 拼接,
不做两次独立 split(避免损坏态误拼/吞掉区外正文)。
"""
bounds = appendix_region_bounds(content)
if bounds is not None:
start, end = bounds
head = content[:start]
tail = content[end:]
if not notes:
return head.rstrip() + ("\n" + tail.lstrip("\n") if tail.strip() else "\n")
bullet = "\n".join(f"- {n.strip()}" for n in notes if n.strip())
new_inner = f"\n## 执行提醒(自动累积,勿手改)\n{bullet}"
return f"{head}{APPENDIX_START}{new_inner}\n{APPENDIX_END}{tail}"
if not notes:
return content
return append_to_appendix(content, notes)
def replace_momentum(content: str, guidance: str) -> str:
"""把「动量指导」整体写入文件尾的 momentum 受保护区;区不存在则创建。
与 append_to_appendix 的累积语义不同,momentum 是**替换**语义:慢更新周期每
epoch 末整体重写一段动量指导,旧指导被完全覆盖(不保留历史)。momentum 区与
appendix 区独立共存——本函数只触碰 momentum marker,不破坏已有 appendix 区。
护栏:momentum 区超过 MOMENTUM_MAX_CHARS 时 logger.warning(不静默截断,与
appendix 对齐)。
关键实现细节:
- 替换非追加:区已存在时用 guidance 整体覆盖 marker 内 inner,旧动量不残留。
- 创建位置在文件尾(append_to_appendix 同样在文件尾,但两区 marker 不同,
split 按各自 marker 定位,互不干扰)。
空 guidance 决策:与 appendix 的累积语义不同,momentum 是「每轮整体重写」,空
guidance 表示「本轮无动量指导」,属合法语义——照常写入(区内仅留标题,旧动量被清空),
而非返回原文保留旧动量。
参数:
content: 原文(可能已含 appendix 区)。
guidance: 本轮动量指导全文(整体覆盖旧动量)。
返回:
含 momentum 区的新文本。
异常:
ValueError: guidance 含 momentum marker 字面量(外部输入注入),或原文 momentum
marker 损坏/不配对。
"""
if MOMENTUM_START in guidance or MOMENTUM_END in guidance:
raise ValueError(
"guidance 不得包含 momentum marker 字面量"
f"{MOMENTUM_START} / {MOMENTUM_END}),否则会破坏 marker 配对"
)
bounds = momentum_region_bounds(content)
new_inner = f"\n## 动量指导(每轮重写,勿手改)\n{guidance.strip()}"
if bounds is not None:
start_idx, end_idx = bounds
head = content[:start_idx]
tail = content[end_idx:]
out = f"{head}{MOMENTUM_START}{new_inner}\n{MOMENTUM_END}{tail}"
else:
out = f"{content.rstrip()}\n\n{MOMENTUM_START}{new_inner}\n{MOMENTUM_END}\n"
if len(new_inner) > MOMENTUM_MAX_CHARS:
logger.warning(
"momentum 区长度 {} 超过上限 {},建议人工压缩",
len(new_inner),
MOMENTUM_MAX_CHARS,
)
return out
def _protected_ranges(content: str, spans: list[str]) -> list[tuple[int, int]]:
"""把冻结文本块映射成 content 中的 [start, end) 坐标区间。"""
ranges: list[tuple[int, int]] = []
for span in spans:
idx = content.find(span)
if idx != -1:
ranges.append((idx, idx + len(span)))
return ranges
def _in_ranges(pos: int, ranges: list[tuple[int, int]]) -> bool:
"""判断位置 pos 是否落在任意冻结区间内。"""
return any(start <= pos < end for start, end in ranges)
def _append_at(content: str, ranges: list[tuple[int, int]]) -> int:
"""append/退化追加落点:最早一个 start>0 的冻结区之前;无则文末(头部 frontmatter 不计)。"""
starts = [start for start, _ in ranges if start > 0]
return min(starts) if starts else len(content)
def _insert_at(content: str, at: int, payload: str) -> str:
"""在 at 位置插入 payload,自动补换行保持段落格式。"""
head, tail = content[:at].rstrip(), content[at:].lstrip("\n")
if tail:
return head + "\n\n" + payload + "\n\n" + tail
return head + "\n\n" + payload + "\n"
def _do_append(
content: str, payload: str, ranges: list[tuple[int, int]]
) -> tuple[str, str]:
"""执行 append 操作,返回更新后内容与状态字符串。"""
return _insert_at(content, _append_at(content, ranges), payload), "applied_append"
def _do_insert_after(
content: str, target: str, payload: str, ranges: list[tuple[int, int]]
) -> tuple[str, str]:
"""执行 insert_after 操作,处理退化追加与冻结区跳过。"""
pos = content.find(target) if target else -1
if pos == -1:
logger.warning("insert_after 锚点缺失,退化为追加 target={}", target[:80])
return (
_insert_at(content, _append_at(content, ranges), payload),
"applied_insert_after_fallback",
)
if _in_ranges(pos, ranges):
logger.warning("insert_after 目标在冻结区,跳过 target={}", target[:80])
return content, "skipped_protected"
at = pos + len(target)
nl = content.find("\n", at)
at = nl + 1 if nl != -1 else len(content)
return content[:at] + payload + "\n" + content[at:], "applied_insert_after"
def _do_replace_delete(
op: str,
content: str,
target: str,
payload: str,
ranges: list[tuple[int, int]],
) -> tuple[str, str]:
"""执行 replace 或 delete 操作,返回更新后内容与状态字符串。"""
if not target:
return content, "skipped_missing_target"
pos = content.find(target)
if pos == -1:
logger.warning("{} 锚点缺失,跳过 target={}", op, target[:80])
return content, "skipped_target_not_found"
if _in_ranges(pos, ranges):
logger.warning("{} 目标在冻结区,跳过 target={}", op, target[:80])
return content, "skipped_protected"
new_content = content.replace(target, payload if op == "replace" else "", 1)
return new_content, "applied_" + op
def _apply_one(
content: str, edit: dict, ranges: list[tuple[int, int]]
) -> tuple[str, dict]:
"""应用单条 edit,返回 (更新后内容, 状态报告)。"""
if not isinstance(edit, dict):
return content, {
"op": "",
"target": "",
"content_preview": "",
"status": "error",
"error": f"edit 非 dict: {type(edit).__name__}",
}
op = str(edit.get("op", ""))
target = str(edit.get("target", "") or "")
payload = str(edit.get("content", "") or "").strip()
report = {
"op": op,
"target": target[:200],
"content_preview": payload[:200],
"status": "unknown",
}
if op == "append":
content, report["status"] = _do_append(content, payload, ranges)
return content, report
if op == "insert_after":
content, report["status"] = _do_insert_after(content, target, payload, ranges)
return content, report
if op in ("replace", "delete"):
content, report["status"] = _do_replace_delete(
op, content, target, payload, ranges
)
return content, report
logger.warning("未知 op,跳过: {}", op)
report["status"] = "skipped_unknown_op"
return content, report
def apply_patch_with_report(
content: str,
edits: list[dict],
protected_spans: list[str] | None = None,
) -> tuple[str, list[dict]]:
"""顺序应用 edit 列表,返回 (新内容, 逐条状态报告)。
参数:
content: 原始文本。
edits: 每条 {op, target, content}。
protected_spans: 冻结文本块列表;目标落入其坐标区间即跳过,append 插到其前。
返回:
(应用后文本, reports)reports 每条含 op/target/content_preview/status/index。
"""
spans = protected_spans or []
reports: list[dict] = []
for i, edit in enumerate(edits, 1):
try:
ranges = _protected_ranges(content, spans)
content, report = _apply_one(content, edit, ranges)
except (KeyError, TypeError, ValueError, AttributeError) as exc:
report = {
"op": "",
"target": "",
"content_preview": "",
"status": "error",
"error": str(exc),
}
logger.exception("补丁应用异常 index={}", i)
report["index"] = i
reports.append(report)
return content, reports
+391
View File
@@ -0,0 +1,391 @@
"""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)