Files
chemenu/tools/chemenu/tests/test_version_cmd.py
T
torben 18ae28f918
CI / verify (push) Failing after 32s
Release / release (push) Successful in 38s
Chemenu 2.1.0 - deterministischer Wissenskompiler
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki:
raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht
tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren
Grenzen in Code durchgesetzt sind statt im Prompt.

Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige
Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses
Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44
Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist.

Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den
Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er
dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs.

Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte.
Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet -
siehe NOTICE.
2026-09-01 16:26:14 +02:00

384 lines
14 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",
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", 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", 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=" ", 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", 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",
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", 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",
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",
no_migration="not needed", dry_run=False,
)
# --- 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"