|
|
|
@@ -4,8 +4,14 @@ The migration is a MOVE: content relocates, nothing is rewritten. That makes cor
|
|
|
|
|
*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.
|
|
|
|
|
**Runs against a checked-in FIXTURE, not the live corpus.** An earlier version migrated the real
|
|
|
|
|
`docs/` tree, which made it a one-shot: the moment the real migration landed, the tree was already
|
|
|
|
|
split, the harness had nothing to move, and the tests failed for a reason that had nothing to do
|
|
|
|
|
with the code under test. A fixture keeps these tests exercising the *script* rather than the
|
|
|
|
|
repo's current state, so they stay meaningful and green after the migration ships.
|
|
|
|
|
|
|
|
|
|
The one-off proof against the real corpus was run separately at migration time: 168 records before
|
|
|
|
|
and after, zero lost, zero gained, zero field mismatches, zero rationale bodies differing.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
@@ -18,6 +24,8 @@ import pytest
|
|
|
|
|
import scripts.decisions_lib as dl
|
|
|
|
|
import scripts.migrate_decisions_split as mig
|
|
|
|
|
|
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "premigration"
|
|
|
|
|
|
|
|
|
|
COMPARED_FIELDS = (
|
|
|
|
|
"key",
|
|
|
|
|
"status",
|
|
|
|
@@ -30,26 +38,17 @@ COMPARED_FIELDS = (
|
|
|
|
|
"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
|
|
|
|
|
# The fixture is static, so an exact count is a real completeness guard here rather than a number
|
|
|
|
|
# that rots every time a record lands on main.
|
|
|
|
|
EXPECTED_RECORDS = 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
|
|
|
def migrated(tmp_path_factory):
|
|
|
|
|
"""Copy the real docs/ into tmp, point decisions_lib at it, run the migration."""
|
|
|
|
|
"""Copy the fixture corpus to tmp, point decisions_lib at it, run the real 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))
|
|
|
|
|
shutil.copytree(FIXTURE, tmp / "docs")
|
|
|
|
|
|
|
|
|
|
orig = (dl.REPO_ROOT, dl.DECISIONS_MD, dl.TOPIC_DIR, dl.ARCHIVE_DIR, dl.RECORDS_DIR)
|
|
|
|
|
dl.REPO_ROOT = tmp
|
|
|
|
@@ -58,10 +57,16 @@ def migrated(tmp_path_factory):
|
|
|
|
|
dl.ARCHIVE_DIR = dl.TOPIC_DIR / "archive"
|
|
|
|
|
dl.RECORDS_DIR = dl.TOPIC_DIR / "records"
|
|
|
|
|
try:
|
|
|
|
|
before = {}
|
|
|
|
|
for f in [dl.DECISIONS_MD, *sorted(dl.TOPIC_DIR.glob("*.md")), *sorted(dl.ARCHIVE_DIR.rglob("*.md"))]:
|
|
|
|
|
for r in dl.parse_file(f):
|
|
|
|
|
if r.key and r.heading not in dl.SKIP_HEADINGS:
|
|
|
|
|
before[r.key] = (r, mig._rationale_body(r))
|
|
|
|
|
|
|
|
|
|
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 f in [*dl.RECORDS_DIR.rglob("*.md"), *dl.ARCHIVE_DIR.rglob("*.md")]:
|
|
|
|
|
for r in dl.parse_file(f):
|
|
|
|
|
if r.key:
|
|
|
|
|
after[r.key] = (r, r.body)
|
|
|
|
@@ -70,25 +75,19 @@ def migrated(tmp_path_factory):
|
|
|
|
|
dl.REPO_ROOT, dl.DECISIONS_MD, dl.TOPIC_DIR, dl.ARCHIVE_DIR, dl.RECORDS_DIR = orig
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fixture_is_actually_pre_migration(migrated):
|
|
|
|
|
"""Guard against the harness silently testing nothing if the fixture ever gets migrated."""
|
|
|
|
|
before, _, _ = migrated
|
|
|
|
|
assert len(before) == EXPECTED_RECORDS, f"fixture parsed {len(before)}, expected {EXPECTED_RECORDS}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]}"
|
|
|
|
|
f"lost: {sorted(set(before) - set(after))} / gained: {sorted(set(after) - set(before))}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_record_count_is_stable_across_the_migration(migrated):
|
|
|
|
|
"""Equality is the invariant; the floor only proves the harness isn't parsing a stub corpus.
|
|
|
|
|
|
|
|
|
|
Deliberately NOT a hardcoded total — the corpus grows whenever a record lands on main, and a
|
|
|
|
|
magic number turns every such merge into a spurious failure here.
|
|
|
|
|
"""
|
|
|
|
|
before, after, _ = migrated
|
|
|
|
|
assert len(before) == len(after)
|
|
|
|
|
assert len(before) > 150, f"only {len(before)} records parsed — harness is not seeing the corpus"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_every_metadata_field_round_trips(migrated):
|
|
|
|
|
before, after, _ = migrated
|
|
|
|
|
diffs = []
|
|
|
|
@@ -97,7 +96,7 @@ def test_every_metadata_field_round_trips(migrated):
|
|
|
|
|
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])
|
|
|
|
|
assert not diffs, "\n".join(diffs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_title_round_trips_from_the_heading(migrated):
|
|
|
|
@@ -107,13 +106,14 @@ def test_title_round_trips_from_the_heading(migrated):
|
|
|
|
|
for k, (b, _) in before.items()
|
|
|
|
|
if b.heading != after[k][0].heading
|
|
|
|
|
]
|
|
|
|
|
assert not diffs, "\n".join(diffs[:10])
|
|
|
|
|
assert not diffs, "\n".join(diffs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rationale_bodies_are_byte_identical(migrated):
|
|
|
|
|
"""Including interior blank lines and backticks — the bytes are the payload."""
|
|
|
|
|
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]}"
|
|
|
|
|
assert not diffs, f"{len(diffs)} rationale bodies changed: {diffs}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_path_matches_key(migrated):
|
|
|
|
@@ -123,7 +123,7 @@ def test_path_matches_key(migrated):
|
|
|
|
|
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])
|
|
|
|
|
assert not bad, "\n".join(bad)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_archived_records_land_in_the_archive_wing(migrated):
|
|
|
|
@@ -137,17 +137,13 @@ def test_archived_records_land_in_the_archive_wing(migrated):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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."""
|
|
|
|
|
"""Preamble prose is often the only copy — it must survive, not be deleted with the records."""
|
|
|
|
|
_, _, 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()
|
|
|
|
|
assert kept.exists(), "decisions.md was deleted"
|
|
|
|
|
assert "the only copy of it" in kept.read_text()
|
|
|
|
|
topic = tmp / "docs" / "decisions" / "topic.md"
|
|
|
|
|
assert topic.exists() and "the only copy of this narrative" in topic.read_text()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_records_remain_in_the_legacy_files(migrated):
|
|
|
|
@@ -160,3 +156,18 @@ def test_no_records_remain_in_the_legacy_files(migrated):
|
|
|
|
|
if keyed:
|
|
|
|
|
leftovers[f.name] = keyed
|
|
|
|
|
assert not leftovers, leftovers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_same_file_anchor_bullets_are_dropped(migrated):
|
|
|
|
|
"""The split makes them WRONG, not merely stale — they point at headings that left the file."""
|
|
|
|
|
_, _, tmp = migrated
|
|
|
|
|
assert "](#" not in (tmp / "docs" / "decisions.md").read_text(), "a same-file anchor now dangles"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_moved_index_resolves_to_the_new_paths(migrated):
|
|
|
|
|
"""A date-based pointer from another doc must still resolve after the split."""
|
|
|
|
|
_, _, tmp = migrated
|
|
|
|
|
text = (tmp / "docs" / "decisions.md").read_text()
|
|
|
|
|
assert mig._MOVED_HEADING in text
|
|
|
|
|
assert "[`alpha.first`](records/alpha/first.md)" in text
|
|
|
|
|
assert (tmp / "docs" / "decisions" / "records" / "alpha" / "first.md").exists()
|
|
|
|
|