Files
chemenu/tools/chemenu/tests/test_migrate_cmd.py
T
torben 177c7e9ce8
CI / verify (push) Successful in 55s
Release / release (push) Successful in 38s
feat: Prosa ist kein Identifier - Link-Taxonomie als Enum, generierte Regionen mit Markern (4.0.0)
Files changed:
- .gitea/workflows/ci.yml
- AGENTS.md
- CHANGES.md
- VERSION
- instructions/CONTRACT.md
- instructions/link-taxonomy.md
- instructions/migrations/4.0.0-link-taxonomy.md
- instructions/setup-instance.md
- kb/CONTRACT.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- kb/comparisons/COLLECTION.md
- kb/concepts/COLLECTION.md
- kb/entities/COLLECTION.md
- kb/sources/COLLECTION.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/blocks.py
- tools/chemenu/cli.py
- tools/chemenu/commands/cite_cmd.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/links_cmd.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/xref.py
- tools/chemenu/conventions.py
- tools/chemenu/corpus_diff.py
- tools/chemenu/frontmatter_io.py
- tools/chemenu/kb_collections.py
- tools/chemenu/kb_state.py
- tools/chemenu/links.py
- tools/chemenu/lint_core.py
- tools/chemenu/provenance.py
- tools/chemenu/sections.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_blocks.py
- tools/chemenu/tests/test_cite_cmd.py
- tools/chemenu/tests/test_conventions.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_migrate_cmd.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_pipeline_l0.py
- tools/chemenu/tests/test_types_cmd.py
- tools/chemenu/tests/test_xref.py
- types/concept.schema.yaml
- types/entity.md
- types/entity.schema.yaml
- types/instruction.schema.yaml
- types/type-spec.md
- work/link-taxonomy-migration/README.md
- work/link-taxonomy-migration/plan.md
2026-09-02 18:39:22 +02:00

405 lines
16 KiB
Python

"""Tests for `wikitool migrate`: the KB version, the migration chain, its
ordering rule, and `verify` against real git history."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
import typer
from chemenu import config, kb_state
from chemenu.commands import migrate_cmd
from chemenu.version import Version
CHANGES = "# Changelog\n\n---\n\n## 1.0.0 - 2026-08-30 - First\n\nBody.\n"
def write_migration(
directory: Path,
target: str,
slug: str,
kind: str = "assisted",
obligation: str = "required",
) -> Path:
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{target}-{slug}.md"
path.write_text(
"---\n"
"type: types/instruction.md\n"
f"name: {target}-{slug}\n"
f"description: Migration to {target}.\n"
"manual: true\n"
f"migrates_to: {target}\n"
f"migration_kind: {kind}\n"
f"obligation: {obligation}\n"
"---\n\n# Migration\n\nSteps.\n",
encoding="utf-8",
)
return path
@pytest.fixture
def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A tree with a stack version, a changelog and three migrations, so the
chain has something to order."""
(tmp_path / "VERSION").write_text("2.0.0\n", encoding="utf-8")
(tmp_path / "CHANGES.md").write_text(CHANGES, encoding="utf-8")
instructions = tmp_path / "instructions"
migrations = instructions / "migrations"
for target, slug in (("1.4.0", "rename-field"), ("1.7.0", "split-sources"), ("2.0.0", "retype")):
write_migration(migrations, target, slug)
# Below the range and above the machinery: neither belongs in a chain.
write_migration(migrations, "1.2.0", "ancient")
write_migration(migrations, "2.1.0", "not-installed-yet")
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "INSTRUCTIONS_DIR", instructions)
monkeypatch.setenv("WIKI_AUTHOR", "Test Author")
return tmp_path
def set_kb_version(root: Path, version: str) -> None:
(root / kb_state.KB_STATE_FILENAME).write_text(
json.dumps({"schema": 1, "kb_version": version, "applied": []}), encoding="utf-8"
)
# --- the chain -------------------------------------------------------------
def test_chain_is_the_open_interval_up_to_the_installed_machinery(instance):
migrations = kb_state.load_migrations()
pending = kb_state.chain(migrations, Version(1, 3, 1), Version(2, 0, 0))
assert [str(m.target) for m in pending] == ["1.4.0", "1.7.0", "2.0.0"]
def test_a_version_with_no_migration_of_its_own_is_not_a_special_case(instance):
"""1.3.1 has no migration targeting it - it simply is not in the interval,
and the chain starts at the next one that is."""
pending = kb_state.chain(kb_state.load_migrations(), Version(1, 3, 1), Version(2, 0, 0))
assert str(pending[0].target) == "1.4.0"
def test_migrations_above_the_installed_machinery_are_excluded(instance):
"""2.1.0 exists as a document but this instance has no code for it."""
pending = kb_state.chain(kb_state.load_migrations(), Version(1, 0, 0), Version(2, 0, 0))
assert "2.1.0" not in [str(m.target) for m in pending]
def test_nothing_outstanding_when_content_matches_machinery(instance):
assert kb_state.chain(kb_state.load_migrations(), Version(2, 0, 0), Version(2, 0, 0)) == []
# --- status ----------------------------------------------------------------
def test_status_refuses_to_guess_an_undeclared_kb_version(instance):
with pytest.raises(typer.Exit):
migrate_cmd.status_command(json_out=False)
def test_status_lists_the_chain_in_order(instance, capsys):
set_kb_version(instance, "1.3.1")
migrate_cmd.status_command(json_out=True)
result = json.loads(capsys.readouterr().out)
assert result["kb_version"] == "1.3.1"
assert [m["migrates_to"] for m in result["pending"]] == ["1.4.0", "1.7.0", "2.0.0"]
def test_list_reports_every_document_sorted_by_target(instance, capsys):
migrate_cmd.list_command(json_out=True)
targets = [m["migrates_to"] for m in json.loads(capsys.readouterr().out)]
assert targets == ["1.2.0", "1.4.0", "1.7.0", "2.0.0", "2.1.0"]
# --- done: the ordering rule ----------------------------------------------
def test_done_advances_the_kb_version_and_records_the_entry(instance):
set_kb_version(instance, "1.3.1")
migrate_cmd.done_command(version="1.4.0", pages=38, dry_run=False)
state = kb_state.read_kb_state()
assert state["kb_version"] == "1.4.0"
assert state["applied"][-1]["migration"] == "1.4.0-rename-field"
assert state["applied"][-1]["pages"] == 38
def test_done_refuses_a_migration_that_is_not_next(instance):
"""Skipping a link leaves the corpus in a shape no version describes."""
set_kb_version(instance, "1.3.1")
with pytest.raises(typer.Exit):
migrate_cmd.done_command(version="2.0.0", dry_run=False)
assert kb_state.read_kb_version() == Version(1, 3, 1)
def test_an_interrupted_upgrade_resumes_where_it_stopped(instance):
set_kb_version(instance, "1.3.1")
migrate_cmd.done_command(version="1.4.0", pages=None, dry_run=False)
pending = kb_state.chain(kb_state.load_migrations(), Version(1, 4, 0), Version(2, 0, 0))
assert [str(m.target) for m in pending] == ["1.7.0", "2.0.0"]
def test_done_dry_run_writes_nothing(instance):
set_kb_version(instance, "1.3.1")
migrate_cmd.done_command(version="1.4.0", pages=None, dry_run=True)
assert kb_state.read_kb_version() == Version(1, 3, 1)
def test_done_without_a_declared_kb_version_is_refused(instance):
with pytest.raises(typer.Exit):
migrate_cmd.done_command(version="1.4.0", pages=None, dry_run=False)
# --- baseline --------------------------------------------------------------
def test_baseline_declares_the_version_once(instance):
migrate_cmd.baseline_command(version="1.3.1", force=False)
assert kb_state.read_kb_version() == Version(1, 3, 1)
def test_baseline_refuses_to_overwrite_without_force(instance):
"""Advancing after a migration is `done`, which checks the chain; baseline
does not, so it must not become the quiet way around it."""
migrate_cmd.baseline_command(version="1.3.1", force=False)
with pytest.raises(typer.Exit):
migrate_cmd.baseline_command(version="2.0.0", force=False)
assert kb_state.read_kb_version() == Version(1, 3, 1)
migrate_cmd.baseline_command(version="2.0.0", force=True)
assert kb_state.read_kb_version() == Version(2, 0, 0)
# --- verify against real git history --------------------------------------
PAGE = """---
type: types/entity.md
entity_type: system
created: 2026-07-31
provenance: sourced
---
# Aurora
Links to [[Borealis]] and again to [[Borealis]].
"""
@pytest.fixture
def git_instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
kb = tmp_path / "kb" / "entities"
kb.mkdir(parents=True)
(tmp_path / "kb" / "entities" / "COLLECTION.md").write_text("# c\n", encoding="utf-8")
page = kb / "Aurora.md"
page.write_text(PAGE, encoding="utf-8")
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.name", "T"], cwd=tmp_path, check=True)
subprocess.run(["git", "config", "user.email", "t@e.invalid"], cwd=tmp_path, check=True)
subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True)
subprocess.run(
["git", "commit", "-q", "-m", "seed"], cwd=tmp_path, check=True, capture_output=True
)
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "KB_DIR", tmp_path / "kb")
return tmp_path
def test_verify_is_clean_when_only_prose_moved(git_instance, capsys):
page = git_instance / "kb" / "entities" / "Aurora.md"
page.write_text(PAGE.replace("Links to", "Verweist auf"), encoding="utf-8")
migrate_cmd.verify_command(
from_rev="HEAD", path=None, expect_body_change=False, json_out=True, fail_on_error=True
)
result = json.loads(capsys.readouterr().out)
assert result["compared"] == 1
assert result["findings"] == []
def test_verify_catches_a_dropped_link_against_history(git_instance, capsys):
page = git_instance / "kb" / "entities" / "Aurora.md"
page.write_text(PAGE.replace(" and again to [[Borealis]]", ""), encoding="utf-8")
with pytest.raises(typer.Exit):
migrate_cmd.verify_command(
from_rev="HEAD", path=None, expect_body_change=False, json_out=True, fail_on_error=True
)
result = json.loads(capsys.readouterr().out)
assert result["findings"][0]["kind"] == "wikilinks"
assert "'Borealis' 2->1" in result["findings"][0]["detail"]
def test_verify_scopes_to_a_subtree(git_instance, capsys):
page = git_instance / "kb" / "entities" / "Aurora.md"
page.write_text(PAGE.replace(" and again to [[Borealis]]", ""), encoding="utf-8")
migrate_cmd.verify_command(
from_rev="HEAD",
path=["kb/concepts"],
expect_body_change=False,
json_out=True,
fail_on_error=True,
)
assert json.loads(capsys.readouterr().out)["compared"] == 0
def test_verify_does_not_mistake_routing_files_for_removed_pages(git_instance, capsys):
"""Regression: the historical side listed every `.md` under kb/ while the
working-tree side skipped COLLECTION.md/INDEX.md, so a clean run reported
13 phantom removals. Both sides now answer with `kb_scan.is_page_path`."""
migrate_cmd.verify_command(
from_rev="HEAD", path=None, expect_body_change=False, json_out=True, fail_on_error=True
)
result = json.loads(capsys.readouterr().out)
assert result["removed"] == []
assert result["added"] == []
assert result["compared"] == 1
def test_verify_reports_an_unknown_revision(git_instance):
with pytest.raises(typer.Exit):
migrate_cmd.verify_command(
from_rev="no-such-rev",
path=None,
expect_body_change=False,
json_out=False,
fail_on_error=False,
)
# --- obligation: required vs offered ---------------------------------------
def test_an_offered_migration_is_not_in_the_outstanding_chain(instance):
"""An offer is the stack proposing a better default for a file the instance
owns. Declining it leaves the content in a shape the machinery accepts, so
counting it as owed would make `kb_version` unreachable for an instance that
simply kept its own file."""
write_migration(
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
kind="mechanical", obligation="offered",
)
migrations = kb_state.load_migrations()
pending = kb_state.chain(migrations, Version.parse("1.3.0"), Version.parse("2.0.0"))
assert "1.9.0-nicer-template" not in [m.name for m in pending]
assert [str(m.target) for m in pending] == ["1.4.0", "1.7.0", "2.0.0"]
def test_an_offered_migration_is_listed_separately(instance):
write_migration(
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
kind="mechanical", obligation="offered",
)
offered = kb_state.offers(kb_state.load_migrations(), applied=set())
assert [m.name for m in offered] == ["1.9.0-nicer-template"]
def test_an_offer_stays_on_the_table_regardless_of_the_version(instance):
"""Offers are bounded by the applied ledger, not by kb_version - taking one
deliberately does not move the version, so the version can say nothing about
whether it was taken. Nor are they bounded above by the stack: an offer is
about a file the instance owns, not about the content shape."""
directory = instance / "instructions" / "migrations"
write_migration(directory, "1.1.0", "old-default", obligation="offered")
write_migration(directory, "2.1.0", "later-default", obligation="offered")
offered = kb_state.offers(kb_state.load_migrations(), applied=set())
assert [m.name for m in offered] == ["1.1.0-old-default", "2.1.0-later-default"]
def test_taking_an_offer_records_it_without_moving_the_version(instance):
write_migration(
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
obligation="offered",
)
set_kb_version(instance, "1.3.1")
migrate_cmd.done_command(version="1.9.0", pages=None, dry_run=False)
state = kb_state.read_kb_state()
assert state["kb_version"] == "1.3.1"
assert state["applied"][-1]["migration"] == "1.9.0-nicer-template"
assert kb_state.offers(kb_state.load_migrations(), kb_state.applied_names(state)) == []
def test_an_offer_out_of_order_is_not_refused(instance):
"""The chain's ordering rule exists because skipping a link leaves the
corpus in an undescribed shape. An offer is not a link, so there is nothing
to skip - and refusing it would make the required chain a prerequisite for
an unrelated file upgrade."""
write_migration(
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
obligation="offered",
)
set_kb_version(instance, "1.3.1") # 1.4.0 is the next *required* link
migrate_cmd.done_command(version="1.9.0", pages=None, dry_run=False)
assert kb_state.read_kb_version() == Version(1, 3, 1)
def test_obligation_defaults_to_required_when_undeclared(instance):
"""Every migration written before this axis existed is mandatory, and an
unreadable value must not silently downgrade one."""
directory = instance / "instructions" / "migrations"
path = write_migration(directory, "1.5.0", "legacy")
path.write_text(
path.read_text(encoding="utf-8").replace("obligation: required\n", ""), encoding="utf-8"
)
bogus = write_migration(directory, "1.6.0", "bogus", obligation="whatever")
assert bogus.is_file()
by_name = {m.name: m for m in kb_state.load_migrations()}
assert by_name["1.5.0-legacy"].obligation == kb_state.REQUIRED
assert by_name["1.6.0-bogus"].obligation == kb_state.REQUIRED
def test_status_never_blocks_on_an_offer(instance, capsys):
write_migration(
instance / "instructions" / "migrations", "1.9.0", "nicer-template",
obligation="offered",
)
set_kb_version(instance, "2.0.0")
migrate_cmd.status_command(json_out=True)
result = json.loads(capsys.readouterr().out)
assert result["pending"] == []
assert [m["name"] for m in result["offered"]] == ["1.9.0-nicer-template"]
# The chain is empty and the offer is listed: `status` reports both without
# the offer ever counting as owed.
# --- divergence against the release stamp ----------------------------------
def _write_stamp(root: Path, files: dict[str, str]) -> None:
import hashlib
digests = {}
for relative, content in files.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
digests[relative] = "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest()
(root / ".wikitool-release.json").write_text(
json.dumps({"schema": 1, "version": "2.0.0", "files": digests}), encoding="utf-8"
)
def test_divergent_files_tells_an_edited_file_from_a_received_one(instance):
"""The half of the release stamp that has existed since it was written and
that nothing read until offers needed it: may this file be overwritten, or
does a person have to reconcile it?"""
_write_stamp(instance, {"types/entity.md": "shipped\n", "types/concept.md": "shipped\n"})
(instance / "types" / "entity.md").write_text("locally changed\n", encoding="utf-8")
assert kb_state.divergent_files() == ["types/entity.md"]
def test_a_deleted_file_counts_as_divergent(instance):
_write_stamp(instance, {"types/entity.md": "shipped\n"})
(instance / "types" / "entity.md").unlink()
assert kb_state.divergent_files() == ["types/entity.md"]
def test_divergence_is_unanswerable_without_a_stamp(instance):
"""None, not []. A development tree carries no stamp, and reporting
"nothing diverged" there would be a fabricated answer."""
assert kb_state.divergent_files() is None