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
222 lines
7.6 KiB
Python
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
|