Files
chemenu/tools/chemenu/tests/test_cli_contract.py
T
torben 63f566ff05
CI / verify (push) Successful in 1m15s
Release / release (push) Successful in 37s
tools: fail() prints ON FAILURE lines on stderr after ERROR (#143)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2

Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/cli.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/_util.py
- tools/chemenu/tests/test_cli.py
- tools/chemenu/tests/test_cli_contract.py
- tools/chemenu/tests/test_util.py
2026-09-26 14:53:51 +02:00

375 lines
14 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="", cause="Widget not found", reaction="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 f"- {rec.notes[0]}" in section
def test_notes_tuple_renders_one_bullet_per_entry():
rec = _fixture_record(notes=("Frobnicates in place.", "Leaves the widget's name alone."))
text = cc.render_text(rec)
assert "NOTES\n - Frobnicates in place.\n - Leaves the widget's name alone." in text
section = cc.render_markdown_section(rec)
assert "**NOTES**\n\n- Frobnicates in place.\n- Leaves the widget's name alone." in section
def test_one_exit_status_and_on_failure_line_per_cause():
rec = _fixture_record(
properties=cc.Properties(
effect=cc.Effect.WRITE,
idempotent=cc.Idempotent.NO,
atomic="No",
budget=cc.Budget.COUNTED,
gates=("mass-update",),
),
failures=(
cc.Failure(cause="Mass-Update Gate: too many files", reaction="Show the output and stop", code=42),
cc.Failure(cause="Widget not found", reaction="Fix the name and retry once"),
cc.Failure(cause="Widget locked", reaction="Report to the user"),
cc.Failure(cause="No remote configured - reported and skipped", reaction="", code=0),
),
)
status = cc.render_exit_status_lines(rec)
# Sorted by code, stable within a code; the explicit 42 cause replaces
# the generic gate line.
assert status == [
"0 success",
"0 No remote configured - reported and skipped",
"1 Widget not found",
"1 Widget locked",
"42 Mass-Update Gate: too many files",
]
# A cause without a reaction has no ON FAILURE line.
assert cc.render_on_failure_lines(rec) == [
"Widget not found -> Fix the name and retry once",
"Widget locked -> Report to the user",
"Mass-Update Gate: too many files -> Show the output and stop",
]
assert cc._exit_codes(rec) == [0, 1, 42]
def test_generic_gate_line_stays_without_an_explicit_42_cause():
rec = _fixture_record(
properties=cc.Properties(
effect=cc.Effect.WRITE,
idempotent=cc.Idempotent.NO,
atomic="No",
budget=cc.Budget.COUNTED,
gates=("mass-update",),
),
)
assert cc.render_exit_status_lines(rec)[-1].startswith("42 needs clearance - mass-update")
def test_label_prefixes_both_lines():
rec = _fixture_record(failures=(cc.Failure(cause="Bad name", reaction="Fix it", label="frobnicate --b"),))
assert "1 frobnicate --b: Bad name" in cc.render_exit_status_lines(rec)
assert cc.render_on_failure_lines(rec) == ["frobnicate --b: Bad name -> Fix it"]
def test_on_failure_omitted_when_no_cause_has_a_reaction():
rec = _fixture_record(failures=(cc.Failure(cause="Nothing to do", reaction="", code=0),))
assert "ON FAILURE" not in cc.render_text(rec)
assert "ON FAILURE" not in cc.render_markdown_section(rec)
def test_exit_42_cause_without_a_gate_is_refused():
with pytest.raises(ValueError, match="no gate"):
_fixture_record(failures=(cc.Failure(cause="Gate", reaction="Stop", code=42),))
def test_failure_code_outside_0_1_42_is_refused():
with pytest.raises(ValueError):
cc.Failure(cause="Crash", reaction="Report", code=2)
def test_notes_as_one_string_is_refused():
with pytest.raises(ValueError, match="tuple of bullets"):
_fixture_record(notes="One paragraph, the phase-1 form.")
def test_render_failure_hint_shows_only_exit_1_causes_with_a_reaction():
"""Gitea #143: the runtime hint `_util.fail()` prints is narrower than
`-h`'s ON FAILURE section - a `code: 42` cause is the gate's own re-run
line (already printed by the gate), a `code: 0` cause is not a failure,
and a cause without a reaction has nothing to add."""
rec = _fixture_record(
properties=cc.Properties(
effect=cc.Effect.WRITE,
idempotent=cc.Idempotent.NO,
atomic="No",
budget=cc.Budget.COUNTED,
gates=("mass-update",),
),
failures=(
cc.Failure(cause="Mass-Update Gate: too many files", reaction="Show the output and stop", code=42),
cc.Failure(cause="Widget not found", reaction="Fix the name and retry once"),
cc.Failure(cause="Bad name", reaction="Fix it", label="frobnicate --b"),
cc.Failure(cause="Nothing to do", reaction="", code=0),
cc.Failure(cause="Deprecated flag", reaction="", code=1),
),
)
hint = cc.render_failure_hint(rec)
assert hint == (
"ON FAILURE (wikitool frobnicate -h):\n"
" Widget not found -> Fix the name and retry once\n"
" frobnicate --b: Bad name -> Fix it"
)
def test_render_failure_hint_falls_back_to_a_bare_pointer():
"""A record with no exit-1 cause that carries a reaction - `docs
contract` is the real case (Gitea #146) - gets a `see:` pointer instead
of an empty or missing hint."""
assert cc.render_failure_hint(_fixture_record(failures=())) == "see: wikitool frobnicate -h"
rec = _fixture_record(
properties=cc.Properties(
effect=cc.Effect.WRITE,
idempotent=cc.Idempotent.NO,
atomic="No",
budget=cc.Budget.COUNTED,
gates=("mass-update",),
),
failures=(cc.Failure(cause="Too many files", reaction="Show the output and stop", code=42),),
)
assert cc.render_failure_hint(rec) == "see: wikitool frobnicate -h"
class _FakeContext:
def __init__(self, info_name, parent=None):
self.info_name = info_name
self.parent = parent
def test_path_of_walks_the_context_chain_by_info_name():
root = _FakeContext(None, parent=None)
group = _FakeContext("xref", parent=root)
leaf = _FakeContext("add", parent=group)
assert cc.path_of(leaf) == "xref add"
def test_path_of_ignores_argv0():
"""Unlike `ctx.command_path`, `path_of` never sees argv[0] - a fake
root `info_name` (what `ctx.command_path` would be prefixed with) is not
walked at all, since the root context has no parent."""
root = _FakeContext("python -m chemenu.cli", parent=None)
leaf = _FakeContext("touch", parent=root)
assert cc.path_of(leaf) == "touch"