31662dc3ff
Files changed: - CHANGES.md - INSTALL.md - VERSION - instructions/dev/stack-dev/SKILL.md - instructions/dev/version-parts.md - tools/CONTRACT.md - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_version_cmd.py - tools/chemenu/version.py
426 lines
16 KiB
Python
426 lines
16 KiB
Python
"""Tests for the stack version: parsing and the compatibility rule, reading
|
|
`VERSION`/the release stamp, `version bump`'s two writes, changelog extraction,
|
|
and `version check` against a stubbed feed (never a real network)."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.error
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import typer
|
|
|
|
from chemenu import config, version as version_mod
|
|
from chemenu.commands import version_cmd
|
|
from chemenu.version import Version, VersionError
|
|
|
|
CHANGES_HEADER = "# Changelog\n\nPreamble.\n\n---\n\n"
|
|
|
|
|
|
@pytest.fixture
|
|
def tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""A tree with the files the version machinery reads, plus an empty
|
|
migrations directory so the boundary gate has somewhere to look."""
|
|
(tmp_path / "VERSION").write_text("1.0.0\n", encoding="utf-8")
|
|
(tmp_path / "CHANGES.md").write_text(
|
|
CHANGES_HEADER + "## 1.0.0 - 2026-08-30 - First\n\n**Author:** Someone\n\nBody.\n",
|
|
encoding="utf-8",
|
|
)
|
|
instructions = tmp_path / "instructions"
|
|
(instructions / "migrations").mkdir(parents=True)
|
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
|
monkeypatch.setattr(config, "INSTRUCTIONS_DIR", instructions)
|
|
monkeypatch.setenv("WIKI_AUTHOR", "Test Author")
|
|
return tmp_path
|
|
|
|
|
|
# --- the version itself ----------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("text", ["1.2.3", " 1.2.3 ", "v1.2.3", "1.2.3\n"])
|
|
def test_parse_accepts_the_forms_a_tag_or_a_file_produces(text):
|
|
assert Version.parse(text) == Version(1, 2, 3)
|
|
|
|
|
|
@pytest.mark.parametrize("text", ["1.2", "1.2.3.4", "x.y.z", "", "1.2.3-rc1"])
|
|
def test_parse_rejects_anything_else(text):
|
|
with pytest.raises(VersionError):
|
|
Version.parse(text)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"part,expected",
|
|
[("major", "2.0.0"), ("minor", "1.3.0"), ("patch", "1.2.4")],
|
|
)
|
|
def test_bump_resets_everything_to_the_right_of_it(part, expected):
|
|
assert str(Version(1, 2, 3).bumped(part)) == expected
|
|
|
|
|
|
def test_compat_key_is_the_leftmost_nonzero_prefix():
|
|
"""This stack starts at 1.0.0, so in practice the boundary is MAJOR. The
|
|
rule is stated generally anyway - one uniform comparison rather than a
|
|
version-range special case - and the 0.x rows pin that generality down."""
|
|
assert Version(0, 1, 3).compat_key == (0, 1)
|
|
assert Version(0, 1, 9).compat_key == (0, 1)
|
|
assert Version(0, 2, 0).compat_key == (0, 2)
|
|
assert Version(1, 2, 3).compat_key == (1,)
|
|
assert Version(2, 0, 0).compat_key == (2,)
|
|
assert Version(0, 0, 4).compat_key == (0, 0, 4)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"local,latest,state",
|
|
[
|
|
("0.1.0", "0.1.0", "current"),
|
|
("0.1.0", "0.1.4", "update"),
|
|
("0.1.0", "0.2.0", "migration"),
|
|
("1.4.0", "1.9.2", "update"),
|
|
("1.4.0", "2.0.0", "migration"),
|
|
("0.2.0", "0.1.0", "ahead"),
|
|
],
|
|
)
|
|
def test_compare_separates_a_compatible_update_from_a_migration(local, latest, state):
|
|
assert version_mod.compare(Version.parse(local), Version.parse(latest)) == state
|
|
|
|
|
|
def test_a_migration_headline_says_so_rather_than_just_being_louder():
|
|
status = version_mod.UpdateStatus(Version(0, 1, 0), Version(0, 2, 0), "migration")
|
|
assert "migration" in status.headline.lower()
|
|
|
|
|
|
# --- reading the tree ------------------------------------------------------
|
|
|
|
|
|
def test_read_version_reads_the_file(tree):
|
|
assert version_mod.read_version() == Version(1, 0, 0)
|
|
|
|
|
|
def test_a_missing_version_file_raises_rather_than_guessing(tree):
|
|
(tree / "VERSION").unlink()
|
|
with pytest.raises(VersionError):
|
|
version_mod.read_version()
|
|
|
|
|
|
def test_no_stamp_is_a_normal_answer_for_a_dev_tree(tree):
|
|
assert version_mod.read_stamp() is None
|
|
|
|
|
|
def test_a_malformed_stamp_raises(tree):
|
|
(tree / version_mod.RELEASE_STAMP_FILENAME).write_text("{not json", encoding="utf-8")
|
|
with pytest.raises(VersionError):
|
|
version_mod.read_stamp()
|
|
|
|
|
|
def test_update_url_prefers_env_then_stamp_then_default(tree, monkeypatch):
|
|
stamp = {"update_url": "https://stamp.example/feed"}
|
|
assert version_mod.update_url(None) == version_mod.DEFAULT_UPDATE_URL
|
|
assert version_mod.update_url(stamp) == "https://stamp.example/feed"
|
|
monkeypatch.setenv(version_mod.UPDATE_URL_ENV, "https://env.example/feed")
|
|
assert version_mod.update_url(stamp) == "https://env.example/feed"
|
|
|
|
|
|
# --- the changelog ---------------------------------------------------------
|
|
|
|
|
|
def test_top_changes_version_ignores_pre_versioning_date_headings():
|
|
text = CHANGES_HEADER + "## 2026-08-01 - Older, unversioned\n\nBody.\n"
|
|
assert version_mod.top_changes_version(text) is None
|
|
|
|
|
|
def test_top_changes_version_finds_the_newest_versioned_entry():
|
|
text = (
|
|
CHANGES_HEADER
|
|
+ "## 0.2.0 - 2026-09-01 - Newer\n\nBody.\n\n---\n\n"
|
|
+ "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
|
)
|
|
assert version_mod.top_changes_version(text) == Version(0, 2, 0)
|
|
|
|
|
|
def test_changes_section_returns_one_entry_without_the_separator():
|
|
text = (
|
|
CHANGES_HEADER
|
|
+ "## 0.2.0 - 2026-09-01 - Newer\n\nNew body.\n\n---\n\n"
|
|
+ "## 0.1.0 - 2026-08-29 - Older\n\nOld body.\n"
|
|
)
|
|
section = version_mod.changes_section(text, Version(0, 2, 0))
|
|
assert "New body." in section
|
|
assert "Old body." not in section
|
|
assert not section.rstrip().endswith("---")
|
|
|
|
|
|
def test_changes_section_stops_at_a_pre_versioning_dated_entry():
|
|
"""Regression: terminating on the next *versioned* heading ran the newest
|
|
entry to the end of the file, because every entry below 0.1.0 is headed by
|
|
a date instead."""
|
|
text = (
|
|
CHANGES_HEADER
|
|
+ "## 0.1.0 - 2026-08-29 - Newest\n\nNew body.\n\n---\n\n"
|
|
+ "## 2026-08-01 - Before versioning\n\nAncient body.\n"
|
|
)
|
|
section = version_mod.changes_section(text, Version(0, 1, 0))
|
|
assert "New body." in section
|
|
assert "Ancient body." not in section
|
|
assert "Before versioning" not in section
|
|
|
|
|
|
def test_changes_section_is_none_for_an_undocumented_version():
|
|
assert version_mod.changes_section(CHANGES_HEADER, Version(9, 9, 9)) is None
|
|
|
|
|
|
def test_insert_changes_entry_lands_above_the_newest_entry():
|
|
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
|
result = version_mod.insert_changes_entry(
|
|
text, Version(0, 2, 0), "2026-09-01", "Newer", "Someone"
|
|
)
|
|
assert result.index("## 0.2.0") < result.index("## 0.1.0")
|
|
assert "Preamble." in result
|
|
assert version_mod.top_changes_version(result) == Version(0, 2, 0)
|
|
|
|
|
|
# --- version bump ----------------------------------------------------------
|
|
|
|
|
|
def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
|
version_cmd.bump_command(
|
|
major=False, minor=True, patch=False, title="Something happened",
|
|
breaking=None, no_migration=None, dry_run=False,
|
|
)
|
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
|
assert "## 1.1.0 - " in changes
|
|
assert "Something happened" in changes
|
|
assert "**Author:** Test Author" in changes
|
|
|
|
|
|
def test_bump_dry_run_writes_nothing(tree):
|
|
version_cmd.bump_command(
|
|
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, dry_run=True
|
|
)
|
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
|
assert "1.0.1" not in (tree / "CHANGES.md").read_text(encoding="utf-8")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"flags", [(False, False, False), (True, True, False), (True, False, True)]
|
|
)
|
|
def test_bump_demands_exactly_one_part(tree, flags):
|
|
major, minor, patch = flags
|
|
with pytest.raises(typer.Exit):
|
|
version_cmd.bump_command(
|
|
major=major, minor=minor, patch=patch, title="x", breaking=None, no_migration=None, dry_run=False
|
|
)
|
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
|
|
|
|
|
def test_bump_refuses_an_empty_title(tree):
|
|
with pytest.raises(typer.Exit):
|
|
version_cmd.bump_command(
|
|
major=False, minor=False, patch=True, title=" ", breaking=None, no_migration=None, dry_run=False
|
|
)
|
|
|
|
|
|
def test_bump_refuses_when_the_changelog_is_already_ahead(tree):
|
|
"""A changelog documenting a version the tree has not reached means
|
|
someone edited one of the two by hand; bumping past it would hide that."""
|
|
(tree / "CHANGES.md").write_text(
|
|
CHANGES_HEADER + "## 1.5.0 - 2026-09-01 - Ahead\n\nBody.\n", encoding="utf-8"
|
|
)
|
|
with pytest.raises(typer.Exit):
|
|
version_cmd.bump_command(
|
|
major=False, minor=False, patch=True, title="x", breaking=None, no_migration=None, dry_run=False
|
|
)
|
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
|
|
|
|
|
# --- version bump: the compatibility boundary ------------------------------
|
|
|
|
|
|
def test_a_boundary_crossing_bump_without_a_migration_is_refused(tree):
|
|
"""An instance being told it must migrate, with nothing telling it how, is
|
|
the gap this closes."""
|
|
with pytest.raises(typer.Exit):
|
|
version_cmd.bump_command(
|
|
major=True, minor=False, patch=False, title="Breaking",
|
|
breaking="the feed moved", no_migration=None, dry_run=False,
|
|
)
|
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
|
|
|
|
|
def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
|
|
migrations = tree / "instructions" / "migrations"
|
|
(migrations / "2.0.0-retype.md").write_text(
|
|
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
|
"description: Retype every page.\nmanual: true\n"
|
|
"migrates_to: 2.0.0\nmigration_kind: assisted\n---\n\n# M\n",
|
|
encoding="utf-8",
|
|
)
|
|
version_cmd.bump_command(
|
|
major=True, minor=False, patch=False, title="Breaking",
|
|
breaking="every page is retyped", no_migration=None, dry_run=False,
|
|
)
|
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0"
|
|
|
|
|
|
def test_no_migration_records_the_reason_in_the_changelog(tree):
|
|
version_cmd.bump_command(
|
|
major=True, minor=False, patch=False, title="Breaking",
|
|
breaking="the release feed moved",
|
|
no_migration="no distributed instance exists yet", dry_run=False,
|
|
)
|
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
|
assert version_mod.MIGRATION_NONE_MARKER in changes
|
|
assert "no distributed instance exists yet" in changes
|
|
|
|
|
|
def test_no_migration_is_refused_on_a_compatible_bump(tree):
|
|
"""It would otherwise become a habit rather than a statement."""
|
|
with pytest.raises(typer.Exit):
|
|
version_cmd.bump_command(
|
|
major=False, minor=False, patch=True, title="Fix",
|
|
breaking=None, no_migration="not needed", dry_run=False,
|
|
)
|
|
|
|
|
|
# --- version bump: the breaking-change note --------------------------------
|
|
|
|
|
|
def test_a_boundary_crossing_bump_without_breaking_is_refused(tree):
|
|
"""The corpus question and the drop-in question are independent: a bump
|
|
can migrate nothing and still leave every instance with hand-work."""
|
|
with pytest.raises(typer.Exit):
|
|
version_cmd.bump_command(
|
|
major=True, minor=False, patch=False, title="Renamed the feed",
|
|
breaking=None, no_migration="kb/ keeps its shape", dry_run=False,
|
|
)
|
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
|
|
|
|
|
def test_breaking_records_what_stops_working_in_the_changelog(tree):
|
|
version_cmd.bump_command(
|
|
major=True, minor=False, patch=False, title="Renamed the feed",
|
|
breaking="update_url points at a repo path that no longer exists",
|
|
no_migration="kb/ keeps its shape", dry_run=False,
|
|
)
|
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
|
assert version_mod.BREAKING_CHANGE_MARKER in changes
|
|
assert "update_url points at a repo path that no longer exists" in changes
|
|
# The break comes before the migration note: it is what an operator acts on.
|
|
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(
|
|
version_mod.MIGRATION_NONE_MARKER
|
|
)
|
|
|
|
|
|
def test_breaking_is_refused_on_a_compatible_bump(tree):
|
|
"""A compatible bump that claims a break is describing itself wrongly -
|
|
one of the two is a mistake, and the command will not guess which."""
|
|
with pytest.raises(typer.Exit):
|
|
version_cmd.bump_command(
|
|
major=False, minor=True, patch=False, title="New command",
|
|
breaking="nothing, really", no_migration=None, dry_run=False,
|
|
)
|
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
|
|
|
|
|
# --- version notes ---------------------------------------------------------
|
|
|
|
|
|
def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
|
|
version_cmd.notes_command(version=None)
|
|
assert "## 1.0.0" in capsys.readouterr().out
|
|
|
|
|
|
def test_notes_fails_for_a_version_with_no_entry(tree):
|
|
with pytest.raises(typer.Exit):
|
|
version_cmd.notes_command(version="9.9.9")
|
|
|
|
|
|
# --- version check ---------------------------------------------------------
|
|
|
|
|
|
def _feed(payload: dict):
|
|
def fetcher(url: str, token, timeout: float) -> bytes:
|
|
return json.dumps(payload).encode("utf-8")
|
|
|
|
return fetcher
|
|
|
|
|
|
def test_fetch_latest_reads_the_tag(tree):
|
|
assert version_mod.fetch_latest(
|
|
"https://example/feed", fetcher=_feed({"tag_name": "v0.4.2"})
|
|
) == Version(0, 4, 2)
|
|
|
|
|
|
def test_fetch_latest_release_also_returns_url_and_date(tree):
|
|
version, url, published = version_mod.fetch_latest_release(
|
|
"https://example/feed",
|
|
fetcher=_feed(
|
|
{"tag_name": "0.4.2", "html_url": "https://example/r/0.4.2", "published_at": "2026-09-01"}
|
|
),
|
|
)
|
|
assert (str(version), url, published) == ("0.4.2", "https://example/r/0.4.2", "2026-09-01")
|
|
|
|
|
|
def test_a_feed_without_a_tag_is_an_error_not_an_answer(tree):
|
|
with pytest.raises(VersionError):
|
|
version_mod.fetch_latest("https://example/feed", fetcher=_feed({"message": "nope"}))
|
|
|
|
|
|
def test_an_unreachable_feed_is_an_error_not_up_to_date(tree):
|
|
"""The failure mode worth a test of its own: reporting "no update" when
|
|
the question was never answered."""
|
|
|
|
def refuse(url, token, timeout):
|
|
raise urllib.error.URLError("connection refused")
|
|
|
|
with pytest.raises(VersionError) as exc:
|
|
version_mod.fetch_latest("https://example/feed", fetcher=refuse)
|
|
assert "Could not reach" in str(exc.value)
|
|
|
|
|
|
def test_an_authenticated_feed_names_the_token_variable(tree):
|
|
def unauthorized(url, token, timeout):
|
|
raise urllib.error.HTTPError(url, 401, "Unauthorized", {}, None)
|
|
|
|
with pytest.raises(VersionError) as exc:
|
|
version_mod.fetch_latest("https://example/feed", fetcher=unauthorized)
|
|
assert version_mod.UPDATE_TOKEN_ENV in str(exc.value)
|
|
|
|
|
|
def test_check_reports_a_migration_in_json(tree, monkeypatch, capsys):
|
|
monkeypatch.setattr(
|
|
version_mod, "fetch_latest_release", lambda *a, **k: (Version(2, 0, 0), None, None)
|
|
)
|
|
version_cmd.check_command(url="https://example/feed", timeout=1.0, json_out=True)
|
|
result = json.loads(capsys.readouterr().out)
|
|
assert result["state"] == "migration"
|
|
assert result["requires_migration"] is True
|
|
|
|
|
|
def test_check_exits_nonzero_when_the_feed_cannot_be_reached(tree, monkeypatch):
|
|
def refuse(*args, **kwargs):
|
|
raise VersionError("Could not reach https://example/feed")
|
|
|
|
monkeypatch.setattr(version_mod, "fetch_latest_release", refuse)
|
|
with pytest.raises(typer.Exit):
|
|
version_cmd.check_command(url="https://example/feed", timeout=1.0, json_out=False)
|
|
|
|
|
|
# --- version show ----------------------------------------------------------
|
|
|
|
|
|
def test_show_json_carries_the_stamp_and_the_feed(tree):
|
|
(tree / version_mod.RELEASE_STAMP_FILENAME).write_text(
|
|
json.dumps({"version": "1.0.0", "update_url": "https://stamp.example/feed"}),
|
|
encoding="utf-8",
|
|
)
|
|
import io
|
|
import contextlib
|
|
|
|
buffer = io.StringIO()
|
|
with contextlib.redirect_stdout(buffer):
|
|
version_cmd.show_command(json_out=True)
|
|
result = json.loads(buffer.getvalue())
|
|
assert result["version"] == "1.0.0"
|
|
assert result["update_url"] == "https://stamp.example/feed"
|
|
assert result["stamp"]["update_url"] == "https://stamp.example/feed"
|