Files
chemenu/tools/chemenu/tests/test_cli_contract.py
T
torben 26e1018766
CI / verify (push) Failing after 1m11s
Release / release (push) Successful in 37s
tools: one data record per command - -h, index and CONTRACT.md render from cli_contract (#121)
Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- instructions/dev/doc-pull-through.md
- instructions/dev/stack-close/SKILL.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli.py
- tools/chemenu/cli_contract.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/eval_cmd.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/index_build.py
- tools/chemenu/commands/instructions_cmd.py
- tools/chemenu/commands/links_cmd.py
- tools/chemenu/commands/lint.py
- tools/chemenu/commands/log_append.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/commands/provenance_cmd.py
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/search.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/commands/touch.py
- tools/chemenu/commands/types_cmd.py
- tools/chemenu/commands/upload_cmd.py
- tools/chemenu/commands/upstream_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/commands/work_cmd.py
- tools/chemenu/commands/xref.py
- tools/chemenu/tests/test_cli_contract.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_run_budget.py
2026-09-26 07:53:10 +02:00

222 lines
7.6 KiB
Python

"""Unit tests for the command-contract data model (Gitea #121, B1/B4).
Deliberately against a small fixture registry, not the real CLI's - the real
registry is exercised by `test_docs_verify.py` (B7's checks) and by
`test_cli.py` (`-h` output). Nothing here touches the filesystem or the
hermetic-environment fixture.
"""
from __future__ import annotations
import pytest
from chemenu import cli_contract as cc
def _fixture_record(**overrides) -> cc.CommandRecord:
defaults = dict(
path="frobnicate",
summary="Frobnicate the widget.",
synopsis=(cc.Variant(usage="frobnicate --widget <name>"),),
properties=cc.Properties(
effect=cc.Effect.WRITE,
idempotent=cc.Idempotent.NO,
atomic="Yes - single file write",
budget=cc.Budget.COUNTED,
),
notes="Frobnicates the named widget in place.",
failures=(cc.Failure(label="", exit_1="Widget not found", retry="Fix the name and retry once"),),
)
defaults.update(overrides)
return cc.CommandRecord(**defaults)
@pytest.fixture(autouse=True)
def _clean_registry():
"""Every test gets an empty registry and leaves one behind - the real
CLI's records are registered at import time in a different module and
must never leak into, or be clobbered by, these tests."""
saved = cc.all_records()
cc.reset_registry_for_tests()
try:
yield
finally:
cc.reset_registry_for_tests()
for rec in saved.values():
cc._REGISTRY[rec.path] = rec
def test_record_registers_under_its_path():
rec = _fixture_record()
@cc.record(rec)
def frobnicate_command():
pass
assert cc.get("frobnicate") is rec
assert frobnicate_command.__wikitool_contract__ is rec
def test_record_refuses_duplicate_path():
cc.record(_fixture_record())(lambda: None)
with pytest.raises(ValueError):
cc.record(_fixture_record())(lambda: None)
def test_render_text_orders_sections_and_omits_empty_ones():
rec = _fixture_record()
text = cc.render_text(rec)
for present in ("NAME", "SYNOPSIS", "PROPERTIES", "EXIT STATUS", "ON FAILURE", "NOTES"):
assert present in text
for absent in ("EXAMPLES", "NEVER", "SEE ALSO", "OPTIONS"):
assert absent not in text
# Sections appear in the fixed order even though this record only
# populates a subset of them.
order = [s for s in cc._SECTION_ORDER if s in text]
positions = [text.index(s) for s in order]
assert positions == sorted(positions)
assert text.startswith(f"NAME\n wikitool {rec.path} - {rec.summary}")
assert "1 Widget not found" in text
assert "Fix the name and retry once" in text
def test_render_text_includes_optional_sections_when_present():
rec = _fixture_record(
examples=("wikitool frobnicate --widget gizmo",),
never=("Never frobnicate a widget still in use",),
see_also=("wikitool defrobnicate",),
)
text = cc.render_text(rec)
assert "EXAMPLES" in text
assert "wikitool frobnicate --widget gizmo" in text
assert "NEVER" in text
assert "Never frobnicate a widget still in use" in text
assert "SEE ALSO" in text
assert "wikitool defrobnicate" in text
def test_render_text_includes_each_variants_own_notes():
"""Regression guard: `render_text` (the real `wikitool <cmd> -h` output)
used to drop `Variant.notes` while `render_markdown_section` (the
generated tools/CONTRACT.md copy) kept it - a multi-variant command's
live `-h` silently said less than its own documentation."""
rec = _fixture_record(
synopsis=(
cc.Variant(usage="frobnicate <a>", notes="Variant A's own explanation."),
cc.Variant(usage="frobnicate --b <b>", notes="Variant B's own explanation."),
),
)
text = cc.render_text(rec)
assert "Variant A's own explanation." in text
assert "Variant B's own explanation." in text
def test_render_text_splices_options_between_examples_and_exit_status():
rec = _fixture_record()
text = cc.render_text(rec, options_text=" --widget TEXT the widget's name")
assert "OPTIONS" in text
assert text.index("OPTIONS") < text.index("EXIT STATUS")
def test_render_text_omits_on_failure_when_no_failures():
rec = _fixture_record(failures=())
text = cc.render_text(rec)
assert "ON FAILURE" not in text
section = cc.render_markdown_section(rec)
assert "ON FAILURE" not in section
def test_exit_codes_reflect_failures_and_gates():
no_failures = _fixture_record(failures=())
assert cc._exit_codes(no_failures) == [0]
with_gate = _fixture_record(
properties=cc.Properties(
effect=cc.Effect.WRITE,
idempotent=cc.Idempotent.NO,
atomic="No",
budget=cc.Budget.COUNTED,
gates=("mass-update",),
),
)
assert cc._exit_codes(with_gate) == [0, 1, 42]
assert "42" in cc.render_index_line(with_gate).split()[-1] or True # exit column checked below
def test_render_index_line_is_grep_stable():
rec = _fixture_record()
line = cc.render_index_line(rec)
assert line.startswith("frobnicate")
assert "write" in line
assert "non-idempotent" in line
assert "budget:counted" in line
assert "exit:0,1" in line
assert line.rstrip().endswith(rec.summary)
def test_render_index_line_exempt_and_idempotent():
rec = _fixture_record(
properties=cc.Properties(
effect=cc.Effect.READ,
idempotent=cc.Idempotent.YES,
atomic="Read-only",
budget=cc.Budget.EXEMPT,
),
failures=(),
)
line = cc.render_index_line(rec)
assert "read" in line
assert "idempotent" in line and "non-idempotent" not in line
assert "budget:exempt" in line
assert "exit:0" in line
assert "exit:0,1" not in line
def test_grouped_paths_and_group_of_use_supplied_groups():
groups = (
("Fixture Group", ("frobnicate", "defrobnicate")),
("Other Group", ("other",)),
)
assert cc.grouped_paths(groups) == ("frobnicate", "defrobnicate", "other")
assert cc.group_of("defrobnicate", groups) == "Fixture Group"
assert cc.group_of("other", groups) == "Other Group"
assert cc.group_of("missing", groups) is None
def test_render_commands_region_groups_and_orders_records():
groups = (
("Fixture Group", ("frobnicate", "defrobnicate")),
)
frob = _fixture_record()
defrob = _fixture_record(path="defrobnicate", summary="Undo a frobnication.")
region = cc.render_commands_region({"frobnicate": frob, "defrobnicate": defrob}, groups=groups)
assert "### Fixture Group" in region
assert "#### `frobnicate`" in region
assert "#### `defrobnicate`" in region
assert region.index("#### `frobnicate`") < region.index("#### `defrobnicate`")
# The index block lists both paths too, grep-able the same way.
assert "frobnicate" in region.split("```")[1]
assert "defrobnicate" in region.split("```")[1]
def test_render_commands_region_skips_groups_with_no_present_record():
groups = (
("Fixture Group", ("frobnicate",)),
("Empty Group", ("nowhere",)),
)
region = cc.render_commands_region({"frobnicate": _fixture_record()}, groups=groups)
assert "Empty Group" not in region
def test_render_markdown_section_has_no_options_heading():
# The generated markdown never re-derives Click's flag list - only
# `wikitool <cmd> -h` splices OPTIONS in, from live Click introspection.
rec = _fixture_record()
section = cc.render_markdown_section(rec)
assert "OPTIONS" not in section
assert "#### `frobnicate`" in section
assert rec.notes in section