Files
ersatztv/scripts/tests/test_migration_equivalence.py
T
timothy 64b65fd2db feat(610): generated where-did-it-go index on each stripped file
Live docs point into the corpus BY DATE -- "see `decisions.md` 2026-07-10" --
about 32 such references across 12 files, plus the same form in historical issue
comments. The split would dangle every one of them.

Each stripped file now ends with a generated "Records formerly in this file"
index: date, title, and a link to the record's new path. A reader following a
date pointer lands on the file it names and resolves from there. That is far
cheaper and less error-prone than rewriting 32 references by hand, and it also
covers the issue-comment breadcrumbs, which cannot be rewritten at all.

Caught while verifying it: the generated `## Records formerly in this file`
heading is itself an H2, so the record parser counted one legacy-unmigrated
record per stripped file -- the notice went 0 -> 6. Same treatment as the
existing `## Index` section: skip it by name. SKIP_HEADINGS moved to
decisions_lib as the single source of truth, since three modules now need it.

Found by reading the validator's notice output on a trial migration, not by
inspection -- the corpus still validated OK, so nothing else would have flagged it.
2026-07-25 19:08:58 +02:00

157 lines
5.3 KiB
Python

"""Equivalence harness for the #610 one-file-per-record migration.
The migration is a MOVE: content relocates, nothing is rewritten. That makes correctness
*provable* rather than reviewable — parse the corpus before, run the migration, parse it after,
and assert the two Record sets are identical field-for-field with byte-identical rationale.
These tests run the real migration against a COPY of the real corpus in tmp_path. They never touch
the working tree.
"""
from __future__ import annotations
import shutil
from pathlib import Path
import pytest
import scripts.decisions_lib as dl
import scripts.migrate_decisions_split as mig
COMPARED_FIELDS = (
"key",
"status",
"since",
"supersedes",
"superseded_by",
"stale_after",
"rule",
"signals",
"mechanics",
"sources",
)
_SKIP = dl.SKIP_HEADINGS
def _corpus_files() -> list[Path]:
files = [p for p in dl.active_files() if p.exists()]
files += [p for p in sorted(dl.ARCHIVE_DIR.glob("*.md")) if p.name not in dl._NON_DECISION_FILES]
return files
@pytest.fixture(scope="module")
def migrated(tmp_path_factory):
"""Copy the real docs/ into tmp, point decisions_lib at it, run the migration."""
tmp = tmp_path_factory.mktemp("corpus")
shutil.copytree(dl.REPO_ROOT / "docs", tmp / "docs")
before = {}
for f in _corpus_files():
for r in dl.parse_file(f):
if r.key and r.heading not in _SKIP:
before[r.key] = (r, mig._rationale_body(r))
orig = (dl.REPO_ROOT, dl.DECISIONS_MD, dl.TOPIC_DIR, dl.ARCHIVE_DIR, dl.RECORDS_DIR)
dl.REPO_ROOT = tmp
dl.DECISIONS_MD = tmp / "docs" / "decisions.md"
dl.TOPIC_DIR = tmp / "docs" / "decisions"
dl.ARCHIVE_DIR = dl.TOPIC_DIR / "archive"
dl.RECORDS_DIR = dl.TOPIC_DIR / "records"
try:
mig.main([])
after_files = [p for p in dl.RECORDS_DIR.rglob("*.md")] + [p for p in dl.ARCHIVE_DIR.rglob("*.md")]
after = {}
for f in after_files:
for r in dl.parse_file(f):
if r.key:
after[r.key] = (r, r.body)
yield before, after, tmp
finally:
dl.REPO_ROOT, dl.DECISIONS_MD, dl.TOPIC_DIR, dl.ARCHIVE_DIR, dl.RECORDS_DIR = orig
def test_every_record_survives_with_the_same_key(migrated):
before, after, _ = migrated
assert before, "harness parsed nothing before the migration — would pass vacuously"
assert set(before) == set(after), (
f"lost: {sorted(set(before) - set(after))[:5]} / gained: {sorted(set(after) - set(before))[:5]}"
)
def test_record_count_is_the_expected_166(migrated):
before, after, _ = migrated
assert len(before) == len(after) == 166
def test_every_metadata_field_round_trips(migrated):
before, after, _ = migrated
diffs = []
for key, (b, _) in before.items():
a, _ = after[key]
for f in COMPARED_FIELDS:
if getattr(b, f) != getattr(a, f):
diffs.append(f"{key}.{f}: {getattr(b, f)!r} -> {getattr(a, f)!r}")
assert not diffs, "\n".join(diffs[:10])
def test_title_round_trips_from_the_heading(migrated):
before, after, _ = migrated
diffs = [
f"{k}: {b.heading!r} -> {after[k][0].heading!r}"
for k, (b, _) in before.items()
if b.heading != after[k][0].heading
]
assert not diffs, "\n".join(diffs[:10])
def test_rationale_bodies_are_byte_identical(migrated):
before, after, _ = migrated
diffs = [k for k, (_, body) in before.items() if body != after[k][1]]
assert not diffs, f"{len(diffs)} rationale bodies changed, e.g. {diffs[:5]}"
def test_path_matches_key(migrated):
_, after, _tmp = migrated
bad = []
for key, (rec, _) in after.items():
area, _, topic = key.partition(".")
if rec.source.parent.name != area or rec.source.stem != topic:
bad.append(f"{key} -> {rec.source}")
assert not bad, "\n".join(bad[:10])
def test_archived_records_land_in_the_archive_wing(migrated):
_, after, _tmp = migrated
misfiled = [
key
for key, (rec, _) in after.items()
if (rec.status in ("superseded", "retired")) != ("archive" in rec.source.parts)
]
assert not misfiled, misfiled
def test_source_files_are_kept_and_retain_their_narrative(migrated):
"""The ~500 lines of preamble are the only copy of that prose — they must survive."""
_, _, tmp = migrated
kept = tmp / "docs" / "decisions.md"
assert kept.exists(), "decisions.md was deleted — its lifecycle schema is the only copy"
text = kept.read_text()
assert "Lifecycle, not append-only" in text
# NB: do NOT string-match on "## " here — the schema header quotes an illustrative
# "## 2026-07-17 — …" example in prose. Whether real records remain is a PARSING question,
# covered by test_no_records_remain_in_the_legacy_files.
wp = tmp / "docs" / "decisions" / "workflow-process.md"
assert wp.exists() and "extracted from" in wp.read_text()
def test_no_records_remain_in_the_legacy_files(migrated):
_, _, tmp = migrated
leftovers = {}
for f in [tmp / "docs" / "decisions.md", *sorted((tmp / "docs" / "decisions").glob("*.md"))]:
if f.name in dl._NON_DECISION_FILES:
continue
keyed = [r.key for r in dl.parse_file(f) if r.key]
if keyed:
leftovers[f.name] = keyed
assert not leftovers, leftovers