feat: 增加项目无关的本地清洗评审器
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from json import dumps, loads
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from mdpolish import (
|
||||
DocumentSnapshot,
|
||||
Modifier,
|
||||
Pipeline,
|
||||
ProposedChange,
|
||||
RunStatus,
|
||||
TextEdit,
|
||||
TextSpan,
|
||||
)
|
||||
from mdpolish.review import (
|
||||
ReviewDetail,
|
||||
ReviewDocument,
|
||||
ReviewParseError,
|
||||
build_review_document,
|
||||
parse_json_report,
|
||||
render_json_report,
|
||||
)
|
||||
|
||||
|
||||
def _replace_modifier(
|
||||
needle: str,
|
||||
replacement: str,
|
||||
*,
|
||||
modifier_id: str,
|
||||
reason: str = "应用虚构测试替换",
|
||||
) -> Modifier:
|
||||
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
|
||||
start = snapshot.markdown.find(needle)
|
||||
if start < 0:
|
||||
return ()
|
||||
return (
|
||||
ProposedChange(
|
||||
snapshot_sha256=snapshot.sha256,
|
||||
reason=reason,
|
||||
edits=(
|
||||
TextEdit(
|
||||
snapshot_sha256=snapshot.sha256,
|
||||
span=TextSpan(start, start + len(needle)),
|
||||
expected_text=needle,
|
||||
replacement=replacement,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return Modifier(
|
||||
modifier_id=modifier_id,
|
||||
version="1.0.0",
|
||||
parameters={"needle": needle, "replacement": replacement},
|
||||
applicability="只处理虚构 JSON reader 测试标记。",
|
||||
propose=propose,
|
||||
)
|
||||
|
||||
|
||||
def _noop_modifier(modifier_id: str = "test.noop") -> Modifier:
|
||||
return Modifier(
|
||||
modifier_id=modifier_id,
|
||||
version="1.0.0",
|
||||
parameters=(),
|
||||
applicability="不修改任何文本。",
|
||||
propose=lambda _snapshot: (),
|
||||
)
|
||||
|
||||
|
||||
def _success_review() -> ReviewDocument:
|
||||
input_markdown = "\ufeff首行\r\n🙂Cafe\u0301\r末行"
|
||||
result = Pipeline(
|
||||
(
|
||||
_noop_modifier(),
|
||||
_replace_modifier("🙂Cafe\u0301", "完成", modifier_id="test.unicode"),
|
||||
)
|
||||
).transform(input_markdown)
|
||||
assert result.status is RunStatus.SUCCESS
|
||||
return build_review_document(input_markdown, result)
|
||||
|
||||
|
||||
def _unstable_review() -> ReviewDocument:
|
||||
residual_marker = "RESIDUAL_SECRET"
|
||||
|
||||
def residual(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
|
||||
start = snapshot.markdown.find(residual_marker)
|
||||
if start < 0:
|
||||
return ()
|
||||
return (
|
||||
ProposedChange(
|
||||
snapshot_sha256=snapshot.sha256,
|
||||
reason="残留虚构候选",
|
||||
edits=(
|
||||
TextEdit(
|
||||
snapshot_sha256=snapshot.sha256,
|
||||
span=TextSpan(start, start + len(residual_marker)),
|
||||
expected_text=residual_marker,
|
||||
replacement="resolved",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
result = Pipeline(
|
||||
(
|
||||
Modifier(
|
||||
modifier_id="test.residual",
|
||||
version="1.0.0",
|
||||
parameters=(),
|
||||
applicability="只在最终复查产生候选。",
|
||||
propose=residual,
|
||||
),
|
||||
_replace_modifier("start", residual_marker, modifier_id="test.producer"),
|
||||
)
|
||||
).transform("start")
|
||||
assert result.status is RunStatus.UNSTABLE
|
||||
return build_review_document("start", result)
|
||||
|
||||
|
||||
def _error_reviews() -> tuple[ReviewDocument, ...]:
|
||||
preflight_result = Pipeline(cast(tuple[Modifier, ...], (object(),))).transform("preflight")
|
||||
|
||||
def explode(_snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
|
||||
raise RuntimeError("DIAGNOSTIC_SECRET")
|
||||
|
||||
transform_result = Pipeline(
|
||||
(
|
||||
Modifier(
|
||||
modifier_id="test.transform-error",
|
||||
version="1.0.0",
|
||||
parameters=(),
|
||||
applicability="只测试执行错误。",
|
||||
propose=explode,
|
||||
),
|
||||
)
|
||||
).transform("transform")
|
||||
|
||||
def explode_on_done(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
|
||||
if snapshot.markdown == "done":
|
||||
raise RuntimeError("FINAL_DIAGNOSTIC_SECRET")
|
||||
return ()
|
||||
|
||||
final_result = Pipeline(
|
||||
(
|
||||
Modifier(
|
||||
modifier_id="test.final-error",
|
||||
version="1.0.0",
|
||||
parameters=(),
|
||||
applicability="只测试最终复查错误。",
|
||||
propose=explode_on_done,
|
||||
),
|
||||
_replace_modifier("start", "done", modifier_id="test.final-producer"),
|
||||
)
|
||||
).transform("start")
|
||||
return (
|
||||
build_review_document("preflight", preflight_result),
|
||||
build_review_document("transform", transform_result),
|
||||
build_review_document("start", final_result),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("detail", tuple(ReviewDetail))
|
||||
def test_reader_accepts_every_official_detail(detail: ReviewDetail) -> None:
|
||||
review = _success_review()
|
||||
report = render_json_report(review, detail=detail)
|
||||
|
||||
parsed = parse_json_report(report, expected_detail=detail)
|
||||
|
||||
assert parsed == loads(report)
|
||||
assert parsed["detail"] == detail.value
|
||||
|
||||
|
||||
def test_reader_accepts_unstable_summary_without_materializing_residuals() -> None:
|
||||
parsed = parse_json_report(render_json_report(_unstable_review(), detail="summary"))
|
||||
|
||||
assert parsed["status"] == "unstable"
|
||||
assert "residual_proposals" not in parsed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("review", _error_reviews())
|
||||
@pytest.mark.parametrize("detail", tuple(ReviewDetail))
|
||||
def test_reader_accepts_each_failed_stage(review: ReviewDocument, detail: ReviewDetail) -> None:
|
||||
parsed = parse_json_report(render_json_report(review, detail=detail))
|
||||
|
||||
assert parsed["status"] == "failed"
|
||||
|
||||
|
||||
def test_reader_accepts_compatible_minor_and_unknown_object_field() -> None:
|
||||
payload = loads(render_json_report(_success_review(), detail="full"))
|
||||
payload["schema_version"] = "1.9"
|
||||
payload["future_summary"] = {"available": True}
|
||||
payload["hash_contract"]["future_hash_note"] = "compatible"
|
||||
payload["coordinate_contract"]["future_coordinate_note"] = "compatible"
|
||||
|
||||
parsed = parse_json_report(dumps(payload, ensure_ascii=False))
|
||||
|
||||
assert parsed["schema_version"] == "1.9"
|
||||
assert parsed["future_summary"] == {"available": True}
|
||||
|
||||
|
||||
def test_reader_rejects_stage_after_that_was_not_produced_by_changes() -> None:
|
||||
payload = loads(render_json_report(_success_review(), detail="full"))
|
||||
stage = payload["stages"][1]
|
||||
forged = "伪造结果"
|
||||
forged_hash = DocumentSnapshot(forged).sha256
|
||||
stage["after"] = {
|
||||
"sha256": forged_hash,
|
||||
"code_point_length": len(forged),
|
||||
"markdown": forged,
|
||||
}
|
||||
stage["changes"][0]["after_sha256"] = forged_hash
|
||||
payload["current"] = {
|
||||
"sha256": forged_hash,
|
||||
"code_point_length": len(forged),
|
||||
"markdown": forged,
|
||||
}
|
||||
|
||||
with pytest.raises(ReviewParseError, match="replayed changes do not produce"):
|
||||
parse_json_report(dumps(payload, ensure_ascii=False), expected_detail="full")
|
||||
|
||||
|
||||
def test_reader_rejects_changed_zero_change_stage() -> None:
|
||||
payload = loads(render_json_report(_success_review(), detail="full"))
|
||||
stage = payload["stages"][0]
|
||||
forged = "改变了零修改阶段"
|
||||
forged_hash = DocumentSnapshot(forged).sha256
|
||||
stage["after"] = {
|
||||
"sha256": forged_hash,
|
||||
"code_point_length": len(forged),
|
||||
"markdown": forged,
|
||||
}
|
||||
|
||||
with pytest.raises(ReviewParseError, match="zero-change stage"):
|
||||
parse_json_report(dumps(payload, ensure_ascii=False), expected_detail="full")
|
||||
|
||||
|
||||
def test_reader_rejects_conflicting_change_batch() -> None:
|
||||
payload = loads(render_json_report(_success_review(), detail="full"))
|
||||
stage = payload["stages"][1]
|
||||
duplicate = dict(stage["changes"][0])
|
||||
duplicate["edit_index"] = 1
|
||||
stage["changes"].append(duplicate)
|
||||
stage["change_count"] = 2
|
||||
payload["counts"]["change_count"] = 2
|
||||
|
||||
with pytest.raises(ReviewParseError, match="edit contract"):
|
||||
parse_json_report(dumps(payload, ensure_ascii=False), expected_detail="full")
|
||||
|
||||
|
||||
def test_reader_rejects_wrong_location_without_leaking_text() -> None:
|
||||
payload = loads(render_json_report(_success_review(), detail="full"))
|
||||
change = payload["stages"][1]["changes"][0]
|
||||
change["location"] = {"line": 99, "column": 99}
|
||||
|
||||
with pytest.raises(ReviewParseError) as error:
|
||||
parse_json_report(dumps(payload, ensure_ascii=False), expected_detail="full")
|
||||
|
||||
assert "🙂Cafe\u0301" not in str(error.value)
|
||||
assert "location does not match" in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("report", "message"),
|
||||
(
|
||||
('{"schema_name":"a","schema_name":"b"}', "keys must be unique"),
|
||||
('{"value":NaN}', "non-finite"),
|
||||
('{"schema_name":"mdpolish.review","schema_version":"2.0"}', "schema major"),
|
||||
(dumps({"schema_name": "\ud800"}), "valid Unicode"),
|
||||
("not JSON", "malformed JSON"),
|
||||
),
|
||||
)
|
||||
def test_reader_rejects_malformed_or_unsupported_json(report: str, message: str) -> None:
|
||||
with pytest.raises(ReviewParseError, match=message):
|
||||
parse_json_report(report)
|
||||
|
||||
|
||||
def test_reader_rejects_detail_mismatch_and_invalid_expected_detail() -> None:
|
||||
report = render_json_report(_success_review(), detail="summary")
|
||||
|
||||
with pytest.raises(ReviewParseError, match="does not match"):
|
||||
parse_json_report(report, expected_detail="full")
|
||||
with pytest.raises(ReviewParseError, match="expected_detail"):
|
||||
parse_json_report(report, expected_detail="unknown")
|
||||
|
||||
|
||||
def test_reader_enforces_body_exposure_boundaries() -> None:
|
||||
summary = loads(render_json_report(_success_review(), detail="summary"))
|
||||
summary["future"] = {"markdown": "SUMMARY_SECRET"}
|
||||
with pytest.raises(ReviewParseError, match="body-bearing"):
|
||||
parse_json_report(dumps(summary, ensure_ascii=False))
|
||||
|
||||
changes = loads(render_json_report(_success_review(), detail="changes"))
|
||||
changes["future"] = {"markdown": "CHANGES_SECRET"}
|
||||
with pytest.raises(ReviewParseError, match="full markdown"):
|
||||
parse_json_report(dumps(changes, ensure_ascii=False))
|
||||
Reference in New Issue
Block a user