"""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 "),), 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 -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 ", notes="Variant A's own explanation."), cc.Variant(usage="frobnicate --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 -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