18ae28f918
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.
384 lines
14 KiB
Python
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"
|