Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
# Copyright (c) 2026 TiaC Systems
# SPDX-License-Identifier: Apache-2.0
"""Unit: compare -- the structural comparators that replace byte
comparison with an assertion over each artifact's actual contract:
context.cmake as a key -> value mapping (RIG_DEPENDS a SET, RIG_SHIELDS
an ORDERED list), config-sheet.md as the facts it carries (which
instance sits on which socket, address, CS index, strap state -- never
its rendering), and rig-gen-includes.dtsi as the ORDERED header list the
rig's own parameter assignments needed. See scripts/rigc/tests/compare.py's
own docstring for each contract in full.
Most cases below are deliberate NEGATIVE CONTROLS: a mutation the
comparator must still reject. A comparator nobody has proven can still
fail is a tautological check at suite scale. The config-sheet.md
controls additionally cover OMISSIONS (a dropped section, row, or
bullet) deliberately: altering a value is the easy case for a fact
extractor to catch, silently dropping content is the one that
actually gets missed.
"""
from __future__ import annotations
import textwrap
import pytest
from rigc.tests.compare import (
ConfigSheetParseError,
ContextCmakeParseError,
IncludesDtsiParseError,
compare_config_sheet,
compare_context_cmake,
compare_includes_dtsi,
compare_overlay,
overlay_is_byte_compared,
parse_config_sheet,
parse_context_cmake,
parse_includes_dtsi,
split_dependency_set,
)
GOLDEN = textwrap.dedent("""\
# generated by rigc — consumed by cmake/modules/dts.cmake
set(RIG_NAME "nucleo_datalogger")
set(RIG_BOARD "nucleo_f401re/stm32f401xe/rig")
set(RIG_SHIELDS "adafruit_data_logger;i2c_sensor")
set(RIG_REVISION "1")
set(RIG_DEPENDS "/a/board.dts;/a/rig.yml;/a/shield.shield")
""")
def test_parses_set_lines_into_a_mapping() -> None:
parsed = parse_context_cmake(GOLDEN)
assert parsed["RIG_NAME"] == "nucleo_datalogger"
assert parsed["RIG_BOARD"] == "nucleo_f401re/stm32f401xe/rig"
assert parsed["RIG_SHIELDS"] == "adafruit_data_logger;i2c_sensor"
assert parsed["RIG_REVISION"] == "1"
def test_comment_lines_are_ignored() -> None:
parsed = parse_context_cmake(GOLDEN)
assert set(parsed) == {"RIG_NAME", "RIG_BOARD", "RIG_SHIELDS", "RIG_REVISION", "RIG_DEPENDS"}
def test_unparseable_line_raises_on_parse() -> None:
with pytest.raises(ContextCmakeParseError):
parse_context_cmake("this is not cmake at all\n")
def test_split_dependency_set_undoes_cmake_list_escaping() -> None:
# "/a;weird.dts" was escaped by emitter/context.py's
# _cmake_list_escape to "/a\;weird.dts" before joining -- the
# backslash-semicolon must NOT be read as the element delimiter.
raw = r"/a\;weird.dts;/b/plain.yml"
assert split_dependency_set(raw) == frozenset({"/a;weird.dts", "/b/plain.yml"})
def test_identical_text_compares_equal() -> None:
assert compare_context_cmake(GOLDEN, GOLDEN) is None
# --- negative controls -------------------------------------------------
def test_reordering_rig_depends_is_accepted() -> None:
reordered = GOLDEN.replace(
'"/a/board.dts;/a/rig.yml;/a/shield.shield"', '"/a/shield.shield;/a/board.dts;/a/rig.yml"'
)
assert compare_context_cmake(GOLDEN, reordered) is None
def test_dropping_a_rig_depends_entry_is_rejected() -> None:
dropped = GOLDEN.replace(
'"/a/board.dts;/a/rig.yml;/a/shield.shield"', '"/a/board.dts;/a/rig.yml"'
)
mismatch = compare_context_cmake(GOLDEN, dropped)
assert mismatch is not None
assert "RIG_DEPENDS" in mismatch
assert "missing" in mismatch
assert "/a/shield.shield" in mismatch
def test_adding_an_unexpected_rig_depends_entry_is_rejected() -> None:
added = GOLDEN.replace(
'"/a/board.dts;/a/rig.yml;/a/shield.shield"',
'"/a/board.dts;/a/rig.yml;/a/shield.shield;/a/extra.h"',
)
mismatch = compare_context_cmake(GOLDEN, added)
assert mismatch is not None
assert "RIG_DEPENDS" in mismatch
assert "unexpected" in mismatch
assert "/a/extra.h" in mismatch
def test_changing_rig_board_value_is_rejected() -> None:
changed = GOLDEN.replace(
'set(RIG_BOARD "nucleo_f401re/stm32f401xe/rig")', 'set(RIG_BOARD "frdm_k64f/mk64f12/rig")'
)
mismatch = compare_context_cmake(GOLDEN, changed)
assert mismatch is not None
assert "RIG_BOARD" in mismatch
def test_reordering_rig_shields_is_rejected() -> None:
"""RIG_SHIELDS is documented as distinct-in-rig-order and dts.cmake
iterates it -- unlike RIG_DEPENDS, its order IS the contract."""
reordered = GOLDEN.replace(
'set(RIG_SHIELDS "adafruit_data_logger;i2c_sensor")',
'set(RIG_SHIELDS "i2c_sensor;adafruit_data_logger")',
)
mismatch = compare_context_cmake(GOLDEN, reordered)
assert mismatch is not None
assert "RIG_SHIELDS" in mismatch
def test_omitting_rig_revision_entirely_is_rejected() -> None:
"""The "no declaration, no artifact" rule (emitter/context.py) makes
RIG_REVISION's absence meaningful -- a golden that carries it must
reject an actual run that dropped it, not treat the axis as
unconstrained."""
without_revision = (
"\n".join(line for line in GOLDEN.splitlines() if not line.startswith('set(RIG_REVISION'))
+ "\n"
)
mismatch = compare_context_cmake(GOLDEN, without_revision)
assert mismatch is not None
assert "RIG_REVISION" in mismatch
assert "absent" in mismatch
def test_a_differing_banner_comment_is_accepted() -> None:
"""Comment lines carry no contract for this artifact -- the banner is
the provenance leak this comparator exists to stop mattering."""
different_banner = GOLDEN.replace(
"# generated by rigc — consumed by cmake/modules/dts.cmake",
"# generated by something else entirely, worded differently",
)
assert compare_context_cmake(GOLDEN, different_banner) is None
def test_unparseable_actual_is_a_reported_mismatch_not_an_exception() -> None:
mismatch = compare_context_cmake(GOLDEN, "not cmake at all\n")
assert mismatch is not None
assert "failed to parse" in mismatch
def test_unparseable_golden_is_a_reported_mismatch_not_an_exception() -> None:
mismatch = compare_context_cmake("not cmake at all\n", GOLDEN)
assert mismatch is not None
assert "failed to parse" in mismatch
# --- config-sheet.md: facts, not bytes -------------------------------------
# Synthetic, and deliberately exercising every section kind in one
# document. Two of those kinds have ZERO golden coverage and are proven
# only here: a Wires section (no rig in the corpus declares wires:, so no
# golden contains one -- re-check with a grep for "## Wires" over the
# goldens tree) and a Chip-selects bullet with no gpio_map entry, hence no
# SoC suffix. Both shapes are additionally generated FROM the emitter
# below, so this hand-written copy cannot drift away from what
# emitter/sheet.py actually produces without a test failing.
SHEET_GOLDEN = textwrap.dedent("""\
# Physical configuration sheet — rig `test_rig`
<!-- generated by rigc — do not edit; the rig file is the source of truth -->
Board: **test_board/test_soc/rig**
## Socket assignment
| instance | shield | socket |
|---|---|---|
| flash_a | flash_click | sock1 |
| temp_a | temp_click | sock2 |
## Straps / jumpers
- **temp_a** (sock2): set **ADDR jumper** to state 0 → device address 0x48
- **wifi_a** (sock3): set **IRQ select** to state 1 → routed to pin D2
## Chip-selects
- flash_a/nor: CS index 0, CS → SoC gpioa pin 3
- flash_a/nor2: CS index 1, D9
## PWM / analog pin-mux (board-provided pinctrl)
rigc enables these controllers; the SoC pin-mux for each pin is
board-provided and must be applied (stubbed):
- light_1/light (sock4 SIG0) → ADC adc ch0: mux the pin to the controller
## Wires
- connect **a.node1** → **b.node2** — via header position 7
- connect **c.node3** → **d.node4** — ad-hoc jumper wire (in no connector)
## Parameters
| instance | device | property | value |
|---|---|---|---|
| flash_a | dev | zephyr,code | INPUT_KEY_0 (11) |
| temp_a | dev | zephyr,code | 5 |
""")
def test_parses_every_section_kind() -> None:
facts = parse_config_sheet(SHEET_GOLDEN)
assert facts.rig_name == "test_rig"
assert facts.board == "test_board/test_soc/rig"
assert set(facts.sections) == {
"socket_assignment",
"straps_jumpers",
"chip_selects",
"pwm",
"wires",
"parameters",
}
def test_socket_assignment_rows() -> None:
facts = parse_config_sheet(SHEET_GOLDEN)
assert facts.sections["socket_assignment"] == (
("flash_a", "flash_click", "sock1"),
("temp_a", "temp_click", "sock2"),
)
def test_straps_and_jumpers_are_both_recognised_in_one_section() -> None:
facts = parse_config_sheet(SHEET_GOLDEN)
assert facts.sections["straps_jumpers"] == (
("strap", "temp_a", "sock2", "ADDR jumper", "0", "0x48"),
("jumper", "wifi_a", "sock3", "IRQ select", "1", "D2"),
)
def test_chip_select_with_and_without_gpio_map_entry() -> None:
facts = parse_config_sheet(SHEET_GOLDEN)
assert facts.sections["chip_selects"] == (
("flash_a", "nor", "0", "CS", "gpioa", "3"),
("flash_a", "nor2", "1", "D9", "", ""),
)
def test_wires_section_has_no_golden_coverage_so_is_proven_here() -> None:
"""No rig in the corpus declares wires:, so no golden carries a Wires
section and this is the extractor's only proof it recognises either
shape -- a routed header position and an ad-hoc jumper wire. Re-check
the premise with a grep for "## Wires" over the goldens tree."""
facts = parse_config_sheet(SHEET_GOLDEN)
assert facts.sections["wires"] == (
("a.node1", "b.node2", "via header position 7"),
("c.node3", "d.node4", "ad-hoc jumper wire (in no connector)"),
)
def test_identical_sheet_text_compares_equal() -> None:
assert compare_config_sheet(SHEET_GOLDEN, SHEET_GOLDEN) is None
# --- negative controls: omissions -- these must include DROPPED content,
# --- not just changed values, since a value change is the easy case for
# --- a fact extractor to catch. ------------------------------------------
def test_removing_an_entire_section_is_rejected() -> None:
without_chip_selects = SHEET_GOLDEN.replace(
textwrap.dedent("""\
## Chip-selects
- flash_a/nor: CS index 0, CS → SoC gpioa pin 3
- flash_a/nor2: CS index 1, D9
"""),
"\n",
)
mismatch = compare_config_sheet(SHEET_GOLDEN, without_chip_selects)
assert mismatch is not None
assert "chip_selects" in mismatch
assert "absent from actual" in mismatch
def test_removing_a_table_row_is_rejected() -> None:
dropped_row = SHEET_GOLDEN.replace("| temp_a | temp_click | sock2 |\n", "")
mismatch = compare_config_sheet(SHEET_GOLDEN, dropped_row)
assert mismatch is not None
assert "socket_assignment" in mismatch
def test_removing_a_bullet_is_rejected() -> None:
dropped_bullet = SHEET_GOLDEN.replace("- flash_a/nor2: CS index 1, D9\n", "")
mismatch = compare_config_sheet(SHEET_GOLDEN, dropped_bullet)
assert mismatch is not None
assert "chip_selects" in mismatch
# --- negative controls: altered values -------------------------------------
def test_changed_address_is_rejected() -> None:
changed = SHEET_GOLDEN.replace("device address 0x48", "device address 0x49")
mismatch = compare_config_sheet(SHEET_GOLDEN, changed)
assert mismatch is not None
assert "straps_jumpers" in mismatch
def test_changed_cs_index_is_rejected() -> None:
changed = SHEET_GOLDEN.replace("CS index 1, D9", "CS index 2, D9")
mismatch = compare_config_sheet(SHEET_GOLDEN, changed)
assert mismatch is not None
assert "chip_selects" in mismatch
def test_changed_socket_is_rejected() -> None:
changed = SHEET_GOLDEN.replace(
"| flash_a | flash_click | sock1 |", "| flash_a | flash_click | sock9 |"
)
mismatch = compare_config_sheet(SHEET_GOLDEN, changed)
assert mismatch is not None
assert "socket_assignment" in mismatch
def test_changed_position_name_is_rejected() -> None:
changed = SHEET_GOLDEN.replace("routed to pin D2", "routed to pin D3")
mismatch = compare_config_sheet(SHEET_GOLDEN, changed)
assert mismatch is not None
assert "straps_jumpers" in mismatch
def test_swapping_two_rows_within_a_section_is_rejected() -> None:
"""Row order is contract: the emitter sorts deterministically, so
a swap is a real regression, never noise to tolerate."""
swapped = SHEET_GOLDEN.replace(
"| flash_a | flash_click | sock1 |\n| temp_a | temp_click | sock2 |\n",
"| temp_a | temp_click | sock2 |\n| flash_a | flash_click | sock1 |\n",
)
mismatch = compare_config_sheet(SHEET_GOLDEN, swapped)
assert mismatch is not None
assert "socket_assignment" in mismatch
assert "order is contract" in mismatch
def test_an_unrecognised_line_inserted_is_rejected() -> None:
"""A line the recognisers do not understand must be a mismatch, never
silently skipped -- inserted as a bogus bullet inside an existing
bullet section, since a bogus line elsewhere would just as easily fail
the header/heading recognisers."""
corrupted = SHEET_GOLDEN.replace(
"- flash_a/nor2: CS index 1, D9\n",
"- flash_a/nor2: CS index 1, D9\n- this line matches no recognised bullet shape at all\n",
)
mismatch = compare_config_sheet(SHEET_GOLDEN, corrupted)
assert mismatch is not None
assert "failed to parse" in mismatch
# --- positive controls: what must stay free to change -----------------------
def test_reworded_section_heading_is_accepted() -> None:
reworded = SHEET_GOLDEN.replace("## Chip-selects", "## Chip select assignments (physical)")
assert compare_config_sheet(SHEET_GOLDEN, reworded) is None
def test_reworded_table_column_headers_are_accepted() -> None:
reworded = SHEET_GOLDEN.replace(
"| instance | shield | socket |", "| Instance | Shield name | Socket |"
)
assert compare_config_sheet(SHEET_GOLDEN, reworded) is None
def test_extra_surrounding_prose_is_accepted() -> None:
"""The PWM section's intro paragraph is the one slot prose is
deliberately whitelisted in -- extending it must never turn into a
refreeze."""
reworded = SHEET_GOLDEN.replace(
"rigc enables these controllers; the SoC pin-mux for each "
"pin is board-provided and must be applied (stubbed):",
"rigc enables these controllers. The SoC pin-mux for each "
"pin is board-provided. It must be applied manually (stubbed for "
"now, see the board's own pinctrl):",
)
assert compare_config_sheet(SHEET_GOLDEN, reworded) is None
def test_a_differing_provenance_banner_is_accepted() -> None:
reworded = SHEET_GOLDEN.replace(
"<!-- generated by rigc — do not edit; the rig file is the source of truth -->",
"<!-- generated by something else, worded completely differently -->",
)
assert compare_config_sheet(SHEET_GOLDEN, reworded) is None
# --- parse-failure plumbing, mirroring context.cmake's own -----------------
def test_config_sheet_unparseable_actual_is_a_reported_mismatch() -> None:
mismatch = compare_config_sheet(SHEET_GOLDEN, "not a config sheet at all\n")
assert mismatch is not None
assert "failed to parse" in mismatch
def test_config_sheet_unparseable_golden_is_a_reported_mismatch() -> None:
mismatch = compare_config_sheet("not a config sheet at all\n", SHEET_GOLDEN)
assert mismatch is not None
assert "failed to parse" in mismatch
def test_missing_banner_raises_on_parse() -> None:
without_banner = SHEET_GOLDEN.replace(
"\n<!-- generated by rigc — do not edit; the rig file is the source of truth -->\n", "\n"
)
with pytest.raises(ConfigSheetParseError):
parse_config_sheet(without_banner)
# --- the GUARDS themselves ------------------------------------------------
# The integration suite cannot falsify a comparator: emitter output is
# byte-identical to the goldens, so a gutted comparator passes all 146
# tests exactly as a correct one does. These are the only tests that fail
# when a guard is removed, and each one names the guard it pins.
def test_a_prose_line_after_a_sections_last_row_is_rejected() -> None:
"""The section-level total-coverage guard. Prose is tolerated ONLY
between a heading and that section's first row; after the rows there
is no window, so an unmatched line must surface. This guard is what
catches content smuggled anywhere other than the one prose slot."""
smuggled = SHEET_GOLDEN.replace(
"- flash_a/nor2: CS index 1, D9\n",
"- flash_a/nor2: CS index 1, D9\nand one more note about nothing\n",
)
mismatch = compare_config_sheet(SHEET_GOLDEN, smuggled)
assert mismatch is not None
assert "failed to parse" in mismatch
def test_a_line_after_the_final_section_is_rejected() -> None:
trailing = SHEET_GOLDEN + "a trailing line carrying: CS index 7 on D1\n"
mismatch = compare_config_sheet(SHEET_GOLDEN, trailing)
assert mismatch is not None
assert "failed to parse" in mismatch
def test_a_second_intro_paragraph_is_rejected() -> None:
"""One paragraph per section is the bound, enforced rather than
described: without it, arbitrary text -- including a sentence
contradicting the rows below -- sits at the head of every section and
still compares equal."""
two_paragraphs = SHEET_GOLDEN.replace(
"must be applied (stubbed):\n\n",
"must be applied (stubbed):\n\nand a second paragraph: D9 not D2\n\n",
)
mismatch = compare_config_sheet(SHEET_GOLDEN, two_paragraphs)
assert mismatch is not None
assert "at most ONE intro paragraph" in mismatch
def test_a_repeated_section_kind_is_rejected_not_silently_overwritten() -> None:
"""sections is keyed by KIND, so a second section resolving to the
same kind would overwrite the first and discard a whole section's
facts with no mismatch at all."""
duplicated = SHEET_GOLDEN + textwrap.dedent("""\
## Another socket table
| instance | shield | socket |
|---|---|---|
| other_a | other_click | sock9 |
""")
mismatch = compare_config_sheet(SHEET_GOLDEN, duplicated)
assert mismatch is not None
assert "appears more than once" in mismatch
def test_a_heading_whose_body_is_only_prose_is_rejected() -> None:
"""A heading with no rows must not parse as a present-but-empty
section: that would delete every fact in it while keeping the section
'present', which the section-set comparison would accept."""
gutted = SHEET_GOLDEN.replace(
"- flash_a/nor: CS index 0, CS → SoC gpioa pin 3\n- flash_a/nor2: CS index 1, D9\n",
"these chip-selects are left as an exercise for the reader\n",
)
mismatch = compare_config_sheet(SHEET_GOLDEN, gutted)
assert mismatch is not None
assert "failed to parse" in mismatch
def test_a_changed_rig_name_is_rejected() -> None:
mismatch = compare_config_sheet(SHEET_GOLDEN, SHEET_GOLDEN.replace("`test_rig`", "`other_rig`"))
assert mismatch is not None
assert "rig name" in mismatch
def test_a_changed_board_is_rejected() -> None:
mismatch = compare_config_sheet(SHEET_GOLDEN, SHEET_GOLDEN.replace("test_board", "other_board"))
assert mismatch is not None
assert "board" in mismatch
def test_an_extra_section_present_only_in_actual_is_rejected() -> None:
"""The mirror of the missing-section control: a section the golden
does not have is just as much a mismatch as one it has and the
artifact lacks."""
without = SHEET_GOLDEN.replace(
textwrap.dedent("""\
## Wires
- connect **a.node1** → **b.node2** — via header position 7
- connect **c.node3** → **d.node4** — ad-hoc jumper wire (in no connector)
"""),
"\n",
)
mismatch = compare_config_sheet(without, SHEET_GOLDEN)
assert mismatch is not None
assert "wires" in mismatch
assert "absent from golden" in mismatch
def test_a_changed_pwm_controller_or_channel_is_rejected() -> None:
"""The PWM fact tuple carries function, controller and channel; all
three are what the section exists to tell a human."""
for before, after in (
("ADC adc ch0", "ADC adc ch1"),
("ADC adc ch0", "ADC other ch0"),
("→ ADC adc ch0", "→ PWM adc ch0"),
):
changed = SHEET_GOLDEN.replace(before, after)
assert changed != SHEET_GOLDEN, before
mismatch = compare_config_sheet(SHEET_GOLDEN, changed)
assert mismatch is not None, after
assert "pwm" in mismatch, after
def test_a_table_row_with_the_wrong_column_count_is_rejected() -> None:
widened = SHEET_GOLDEN.replace(
"| flash_a | flash_click | sock1 |", "| flash_a | flash_click | sock1 | extra |"
)
mismatch = compare_config_sheet(SHEET_GOLDEN, widened)
assert mismatch is not None
assert "failed to parse" in mismatch
def test_a_table_missing_its_separator_row_is_rejected() -> None:
no_separator = SHEET_GOLDEN.replace(
"| instance | shield | socket |\n|---|---|---|\n", "| instance | shield | socket |\n"
)
mismatch = compare_config_sheet(SHEET_GOLDEN, no_separator)
assert mismatch is not None
assert "failed to parse" in mismatch
def test_a_foreign_bullet_shape_inside_a_section_is_rejected() -> None:
"""Bullet sections take their kind from the first bullet's shape; a
different shape mixed in below would otherwise be read as another row
of the section it landed in."""
mixed = SHEET_GOLDEN.replace(
"- flash_a/nor2: CS index 1, D9\n",
"- flash_a/nor2: CS index 1, D9\n"
"- connect **x.n1** → **y.n2** — ad-hoc jumper wire (in no connector)\n",
)
mismatch = compare_config_sheet(SHEET_GOLDEN, mixed)
assert mismatch is not None
assert "failed to parse" in mismatch
# --- the zero-coverage shapes, GENERATED from the emitter -----------------
# The two shapes no golden exercises were previously pinned by the
# hand-written fixture above, and a hand-written fixture can describe
# output the emitter cannot produce (a route was written as a position
# NAME where analyzer/wires.py resolves routes to an integer INDEX). These
# drive the real renderer instead, so fixture drift becomes impossible
# rather than something a reader has to notice.
def _generated_sheet() -> str:
"""config-sheet.md as the REAL emitter renders it, over a synthetic
Solved carrying both zero-golden-coverage shapes: a wire routed
through a header position and an ad-hoc one, and a chip-select with a
gpio_map entry alongside one without."""
from rigc.analyzer import Solved
from rigc.diag import SourceRef
from rigc.emitter.sheet import render_sheet
from rigc.model import BoardSocket, ConnectorType, Device, Instance, Rig, Shield, Wire, WireEnd
src = SourceRef("f.yml", 1, "k")
ctype = ConnectorType(
name="t",
positions={},
index2name={0: "CS", 9: "D9"},
bus_proxies=[],
stackable=False,
cs_pool={},
)
mapped = BoardSocket(
label="sock1", path="/s1", type_name="t", gpio_map={0: ("gpioa", 3, 0)}, buses={}
)
unmapped = BoardSocket(label="sock2", path="/s2", type_name="t", gpio_map={}, buses={})
nor = Device(
name="nor",
label="nor",
compatible=None,
bus="spi",
group=None,
reg=None,
addr_from=None,
cs_position=None,
)
shield = Shield(name="flash_click", label="fc", plugs={"plug": "t"}, devices=[nor])
insts = [
Instance(name="flash_a", shield=shield, sockets={"plug": "sock1"}),
Instance(name="flash_b", shield=shield, sockets={"plug": "sock2"}),
]
frm = WireEnd(instance_name="a", node="node1", src=src)
to = WireEnd(instance_name="b", node="node2", src=src)
solved = Solved(
sockets={"flash_a": {"plug": mapped}, "flash_b": {"plug": unmapped}},
cs={("flash_a", "nor"): (0, 0), ("flash_b", "nor"): (1, 9)},
wires=[
Wire(frm=frm, to=to, route=7, src=src),
Wire(frm=frm, to=to, route="adhoc", src=src),
],
)
rig = Rig(name="test_rig", board="test_board/test_soc/rig", instances=insts)
return render_sheet(rig, solved, {"t": ctype}, workdir="/does-not-matter")
def test_the_emitters_own_wire_shapes_parse() -> None:
facts = parse_config_sheet(_generated_sheet())
assert facts.sections["wires"] == (
("a.node1", "b.node2", "via header position 7"),
("a.node1", "b.node2", "ad-hoc jumper wire (in no connector)"),
)
def test_the_emitters_own_chip_select_shapes_parse_mapped_and_unmapped() -> None:
"""A socket with a gpio_map entry renders a "-> SoC <ctrl> pin <n>"
suffix; one without renders none. No golden exercises the second."""
facts = parse_config_sheet(_generated_sheet())
assert facts.sections["chip_selects"] == (
("flash_a", "nor", "0", "CS", "gpioa", "3"),
("flash_b", "nor", "1", "D9", "", ""),
)
def test_the_generated_sheet_self_compares_equal() -> None:
generated = _generated_sheet()
assert compare_config_sheet(generated, generated) is None
# --- rig-gen.overlay: the split contract ----------------------------------
# compare_overlay does not parse the overlay into a devicetree at all --
# semantics ride the zephyr.dts + dts_equiv.py comparison instead. What
# follows targets only the three facts that comparison structurally
# cannot see: a verbatim param token, the quoted #include's position, and
# the human-facing position/pinctrl comments.
def _generated_overlay() -> str:
"""rig-gen.overlay as the REAL emitter renders it, over a synthetic
Solved exercising every shape compare_overlay's targeted assertions
care about, in one document: a needed param-includes header (the
quoted #include that must open the file), a rig-assigned param token
(zephyr,code = <TOKEN>;, verbatim, never resolved here), an inverted
gpio position annotation, and a resolved PWM/ADC controller (the
pinctrl note). Driving render_overlay directly means this fixture
cannot drift away from what the emitter actually produces, the same
discipline _generated_sheet() above applies to config-sheet.md."""
from rigc.analyzer import Solved
from rigc.diag import SourceRef
from rigc.emitter.overlay import render_overlay
from rigc.model import BoardSocket, ConnectorType, Device, FunctionRef, Instance, Rig, Shield
src = SourceRef("f.yml", 1, "k")
# TWO gpio refs, so a test can swap their trailing comments and prove
# each annotation is tied to the position it describes rather than
# merely present somewhere in the file.
ref = FunctionRef(prop="int-gpios", position=5, flags=0x1, src=src, function="gpio")
ref2 = FunctionRef(prop="extra-gpios", position=6, flags=0x1, src=src, function="gpio")
dev = Device(
name="d",
label="d",
compatible=None,
bus=None,
group=None,
reg=None,
addr_from=None,
cs_position=None,
function_refs=[ref, ref2],
extra_props=[("zephyr,code", "zephyr,code = <INPUT_KEY_0>;")],
)
shield = Shield(name="sh", label="sh", plugs={"plug": "t"}, devices=[dev])
inst = Instance(
name="i1",
shield=shield,
sockets={"plug": "sock"},
invert=True,
params={"d": {"zephyr,code": "INPUT_KEY_9"}},
)
rig = Rig(name="r", instances=[inst])
ctype = ConnectorType(
name="t",
positions={},
index2name={5: "D2", 6: "D3"},
bus_proxies=[],
stackable=False,
cs_pool={},
)
socket = BoardSocket(label="sock", path="/s", type_name="t", gpio_map={}, buses={})
solved = Solved(sockets={"i1": {"plug": socket}}, controllers={"tcc0": "pwm"})
return render_overlay(rig, solved, {"t": ctype}, needed_includes=["rig-params.h"])
@pytest.fixture(scope="module")
def OVERLAY_GOLDEN() -> str:
"""Generated per module rather than at import time: an emitter
change then fails these tests instead of erroring collection for
every unrelated test in the file."""
return _generated_overlay()
def test_the_generated_overlay_self_compares_equal(OVERLAY_GOLDEN: str) -> None:
assert compare_overlay(OVERLAY_GOLDEN, OVERLAY_GOLDEN) is None
# --- negative controls: each pins one of compare_overlay's four guards ----
# every guard needs its own negative control, proven to actually fail,
# not merely asserted correct by a green suite. --------------------------
def test_param_token_resolved_to_a_bare_number_is_rejected(OVERLAY_GOLDEN: str) -> None:
"""The guard: _param_tokens. Resolving <INPUT_KEY_9> to its numeric
value is exactly the regression emitter/overlay.py's own docstring
says must never happen -- zephyr.dts shows only the resolved number,
so only this guard can catch it."""
resolved = OVERLAY_GOLDEN.replace("zephyr,code = <INPUT_KEY_9>;", "zephyr,code = <11>;")
mismatch = compare_overlay(OVERLAY_GOLDEN, resolved)
assert mismatch is not None
assert "INPUT_KEY_9" in mismatch
def test_includes_dtsi_line_removed_is_rejected(OVERLAY_GOLDEN: str) -> None:
"""The guard: the include-first-line check, missing-line branch."""
without_include = OVERLAY_GOLDEN.replace('#include "rig-gen-includes.dtsi"\n', "")
mismatch = compare_overlay(OVERLAY_GOLDEN, without_include)
assert mismatch is not None
assert "first line" in mismatch
def test_includes_dtsi_line_present_but_not_first_is_rejected(OVERLAY_GOLDEN: str) -> None:
"""The guard: the include-first-line check, displaced-line branch --
quoted-include resolution is against the FILE'S OWN directory, so the
line must open the file, not merely appear somewhere in it."""
displaced = (
OVERLAY_GOLDEN.replace('#include "rig-gen-includes.dtsi"\n', "", 1)
+ '#include "rig-gen-includes.dtsi"\n'
)
mismatch = compare_overlay(OVERLAY_GOLDEN, displaced)
assert mismatch is not None
assert "first line" in mismatch
def test_gpio_inverted_annotation_dropped_is_rejected(OVERLAY_GOLDEN: str) -> None:
"""The guard: _annotation_comments. The position/inverted comment is
the artifact's only human-facing record of which physical pin (and
active level) a gpio ref resolved to -- it disappears entirely once
dtc has resolved the reference, so only this guard can catch it."""
without_annotation = OVERLAY_GOLDEN.replace(
"int-gpios = <&sock 5 0x0>;\t/* D2 inverted */", "int-gpios = <&sock 5 0x0>;"
)
mismatch = compare_overlay(OVERLAY_GOLDEN, without_annotation)
assert mismatch is not None
assert "D2 inverted" in mismatch
def test_pwm_adc_pinctrl_note_dropped_is_rejected(OVERLAY_GOLDEN: str) -> None:
"""The guard: the literal _PINCTRL_NOTE membership check."""
without_note = OVERLAY_GOLDEN.replace(
"/* PWM/ADC: enable the resolved controllers; the pin-mux (pinctrl)\n"
" * for each muxed pin is board-provided and must be applied —\n"
" * stubbed here, see the config sheet. */\n",
"",
)
mismatch = compare_overlay(OVERLAY_GOLDEN, without_note)
assert mismatch is not None
assert "pinctrl note" in mismatch
# --- positive controls: what must stay free to change ----------------------
def test_property_reordering_that_drops_no_fact_is_accepted(OVERLAY_GOLDEN: str) -> None:
"""The freedom the split contract creates: swapping two property
lines removes no token, no include position, and no annotation."""
reordered = OVERLAY_GOLDEN.replace(
"\t\t\tzephyr,code = <INPUT_KEY_9>;\n\t\t\tint-gpios = <&sock 5 0x0>;\t/* D2 inverted */\n",
"\t\t\tint-gpios = <&sock 5 0x0>;\t/* D2 inverted */\n\t\t\tzephyr,code = <INPUT_KEY_9>;\n",
)
assert reordered != OVERLAY_GOLDEN
assert compare_overlay(OVERLAY_GOLDEN, reordered) is None
def test_whitespace_change_that_alters_no_fact_is_accepted(OVERLAY_GOLDEN: str) -> None:
whitespace_changed = OVERLAY_GOLDEN.replace("\t};\n};\n", "\t};\n\n};\n")
assert whitespace_changed != OVERLAY_GOLDEN
assert compare_overlay(OVERLAY_GOLDEN, whitespace_changed) is None
def test_overlay_a_differing_provenance_banner_is_accepted(OVERLAY_GOLDEN: str) -> None:
reworded = OVERLAY_GOLDEN.replace(
"generated by rigc — do not edit; the rig file is the source of truth",
"generated by something else, worded completely differently",
)
assert reworded != OVERLAY_GOLDEN
assert compare_overlay(OVERLAY_GOLDEN, reworded) is None
# --- the shield-uart-subset-frdm interim exception -----------------------
def test_shield_uart_subset_frdm_overlay_stays_byte_compared() -> None:
"""The only accept rig with a rig-gen.overlay golden but no
zephyr.dts -- under the split contract it would otherwise lose
semantic checking entirely, so it keeps the byte comparison as an
explicit interim exception until it gains a tier-2 build."""
assert overlay_is_byte_compared("shield-uart-subset-frdm") is True
def test_other_rigs_overlay_routes_through_compare_overlay() -> None:
"""Every other golden directory takes the split-contract path -- this
guard failing open (returning True for everything) would silently
byte-freeze every overlay again, exactly the burden the split
contract exists to remove."""
assert overlay_is_byte_compared("lotus_buttons") is False
assert overlay_is_byte_compared("lotus_pwm") is False
def test_an_annotation_naming_the_wrong_position_is_rejected(OVERLAY_GOLDEN: str) -> None:
"""The pairing, not just the presence. Swapping two refs' trailing
comments leaves the SET of comment strings identical, so a check over
bare comment text accepts it -- and a resolved zephyr.dts cannot see
comments at all, so nothing else in the suite would notice an artifact
telling a human the wrong pin."""
first = "int-gpios = <&sock 5 0x0>;\t/* D2 inverted */"
second = "extra-gpios = <&sock 6 0x0>;\t/* D3 inverted */"
assert first in OVERLAY_GOLDEN and second in OVERLAY_GOLDEN
swapped = OVERLAY_GOLDEN.replace(
first, "int-gpios = <&sock 5 0x0>;\t/* D3 inverted */"
).replace(second, "extra-gpios = <&sock 6 0x0>;\t/* D2 inverted */")
mismatch = compare_overlay(OVERLAY_GOLDEN, swapped)
assert mismatch is not None
assert "annotation" in mismatch
def test_a_spuriously_added_annotation_is_rejected(OVERLAY_GOLDEN: str) -> None:
"""Compared for equality, not containment: a comment the artifact
GAINED misinforms a reader exactly like one it lost."""
anchor = "extra-gpios = <&sock 6 0x0>;\t/* D3 inverted */"
assert anchor in OVERLAY_GOLDEN
embellished = OVERLAY_GOLDEN.replace(
anchor, anchor + "\n\t\t\tspare-gpios = <&sock 7 0x0>;\t/* D4 */", 1
)
mismatch = compare_overlay(OVERLAY_GOLDEN, embellished)
assert mismatch is not None
assert "added" in mismatch
def test_a_dropped_cs_active_low_annotation_is_rejected() -> None:
"""The cs-gpios cell carries its own inline ACTIVE_LOW annotation --
inside the cell rather than after a ">;", so the trailing-comment
idiom cannot see it. The numeric flag survives into zephyr.dts and is
compared there; this human-readable annotation exists only in the
overlay, and 11 of the 19 overlay goldens carry it."""
with_flag = textwrap.dedent("""\
/* generated by rigc */
&spi1 {
\tcs-gpios = <&sock 0 1 /* ACTIVE_LOW */>;
};
""")
without = with_flag.replace(" /* ACTIVE_LOW */", "")
mismatch = compare_overlay(with_flag, without)
assert mismatch is not None
assert "annotation" in mismatch
def test_reordering_cs_entries_keeps_their_annotations_paired() -> None:
"""Two cs-gpios entries reordered is a rendering change; each keeps its
own position, so the paired set is unchanged and the comparison
accepts it."""
forward = textwrap.dedent("""\
/* generated by rigc */
&spi1 {
\tcs-gpios = <&sock 0 1 /* ACTIVE_LOW */>, <&sock 9 1 /* ACTIVE_LOW */>;
};
""")
reversed_cells = textwrap.dedent("""\
/* generated by rigc */
&spi1 {
\tcs-gpios = <&sock 9 1 /* ACTIVE_LOW */>, <&sock 0 1 /* ACTIVE_LOW */>;
};
""")
assert compare_overlay(forward, reversed_cells) is None
def test_an_annotation_kept_while_its_position_moves_is_rejected(OVERLAY_GOLDEN: str) -> None:
"""The position component of the annotation triple, pinned on its own.
Here the property and its comment stay paired -- only the position cell
moves -- so a (property, comment) pair would accept it. The resolved
zephyr.dts would independently notice the moved position, but this
keeps the overlay's own annotation honest without relying on that, and
catches the case where a comment is left behind by an edit."""
moved = OVERLAY_GOLDEN.replace(
"int-gpios = <&sock 5 0x0>;\t/* D2 inverted */",
"int-gpios = <&sock 9 0x0>;\t/* D2 inverted */",
1,
)
assert moved != OVERLAY_GOLDEN
mismatch = compare_overlay(OVERLAY_GOLDEN, moved)
assert mismatch is not None
assert "annotation" in mismatch
# --------------------------------------------------------------------------
# rig-gen-includes.dtsi: the ORDERED header list the rig's own parameter
# assignments needed. Unlike RIG_DEPENDS, cpp include order can matter, so
# this compares as a LIST, never a set -- reordering is a mismatch, not
# noise.
INCLUDES_GOLDEN = textwrap.dedent("""\
/* generated by rigc — do not edit; the rig file is the source of truth */
#include <zephyr/dt-bindings/input/input-event-codes.h>
#include <zephyr/dt-bindings/gpio/gpio.h>
""")
def test_parses_banner_and_ordered_headers() -> None:
assert parse_includes_dtsi(INCLUDES_GOLDEN) == (
"zephyr/dt-bindings/input/input-event-codes.h",
"zephyr/dt-bindings/gpio/gpio.h",
)
def test_includes_identical_text_compares_equal() -> None:
assert compare_includes_dtsi(INCLUDES_GOLDEN, INCLUDES_GOLDEN) is None
def test_blank_lines_between_includes_are_accepted() -> None:
"""Blank lines carry no contract -- only the banner and #include lines
do."""
spaced = INCLUDES_GOLDEN.replace(
"#include <zephyr/dt-bindings/input/input-event-codes.h>\n",
"#include <zephyr/dt-bindings/input/input-event-codes.h>\n\n",
)
assert spaced != INCLUDES_GOLDEN
assert compare_includes_dtsi(INCLUDES_GOLDEN, spaced) is None
# --- negative controls -------------------------------------------------
def test_dropping_an_include_is_rejected() -> None:
dropped = INCLUDES_GOLDEN.replace("#include <zephyr/dt-bindings/gpio/gpio.h>\n", "")
mismatch = compare_includes_dtsi(INCLUDES_GOLDEN, dropped)
assert mismatch is not None
assert "gpio/gpio.h" in mismatch
def test_reordering_includes_is_rejected() -> None:
"""The declaring shield device's own header order is the shield
author's, and cpp include order can matter -- unlike RIG_DEPENDS, this
is a LIST, never a set."""
reordered = INCLUDES_GOLDEN.replace(
"#include <zephyr/dt-bindings/input/input-event-codes.h>\n"
"#include <zephyr/dt-bindings/gpio/gpio.h>\n",
"#include <zephyr/dt-bindings/gpio/gpio.h>\n"
"#include <zephyr/dt-bindings/input/input-event-codes.h>\n",
)
mismatch = compare_includes_dtsi(INCLUDES_GOLDEN, reordered)
assert mismatch is not None
assert "order is contract" in mismatch
def test_adding_an_unexpected_include_is_rejected() -> None:
added = INCLUDES_GOLDEN.replace(
"#include <zephyr/dt-bindings/gpio/gpio.h>\n",
"#include <zephyr/dt-bindings/gpio/gpio.h>\n#include <zephyr/dt-bindings/pwm/pwm.h>\n",
)
mismatch = compare_includes_dtsi(INCLUDES_GOLDEN, added)
assert mismatch is not None
assert "pwm.h" in mismatch
def test_an_unrecognised_includes_line_is_rejected() -> None:
"""A line neither the banner nor a #include is a mismatch, never
silently skipped."""
corrupted = INCLUDES_GOLDEN + "this is not an include line at all\n"
mismatch = compare_includes_dtsi(INCLUDES_GOLDEN, corrupted)
assert mismatch is not None
assert "failed to parse" in mismatch
def test_includes_differing_banner_comment_is_accepted() -> None:
"""The banner is the tool-identity leak this comparator exists to stop
mattering, same as compare_context_cmake/compare_config_sheet."""
different_banner = INCLUDES_GOLDEN.replace(
"/* generated by rigc — do not edit; the rig file is the source of truth */",
"/* generated by something else entirely, worded differently */",
)
assert different_banner != INCLUDES_GOLDEN # the mutation really applied
assert compare_includes_dtsi(INCLUDES_GOLDEN, different_banner) is None
def test_includes_unparseable_actual_is_a_reported_mismatch() -> None:
mismatch = compare_includes_dtsi(INCLUDES_GOLDEN, "not a dtsi file at all\n")
assert mismatch is not None
assert "failed to parse" in mismatch
def test_includes_unparseable_golden_is_a_reported_mismatch() -> None:
mismatch = compare_includes_dtsi("not a dtsi file at all\n", INCLUDES_GOLDEN)
assert mismatch is not None
assert "failed to parse" in mismatch
def test_includes_missing_banner_raises_on_parse() -> None:
"""The total-coverage guard: a document with no banner at all must
not silently parse as an empty-but-valid header list."""
without_banner = (
"\n".join(line for line in INCLUDES_GOLDEN.splitlines() if not line.startswith("/*")) + "\n"
)
with pytest.raises(IncludesDtsiParseError):
parse_includes_dtsi(without_banner)
55022eb50This file is pinned to the selected release. Download the original to open it in your design tool.