Files
magdevandClaude Fable 5 4737a5bae8 Add boxsim CLI: pure Python codec, netlist extraction, KiCad project generation
Replaces the bpj2ini.py prototype with a packaged tool. The A2F codec is now
reimplemented in Python, so neither Boxsim.exe nor a CPU emulator is needed to
decode a container; the emulator survives as a development-only oracle that the
codec is differentially tested against.

Verified:
- 134 containers decode byte-identically to Boxsim's own decoder, 270 pre-2.0
  files rejected by both implementations (tools/difftest.py --self-test)
- Default.BPJ: 22 components, 14 nets, no dangling terminal
- 22 of 22 parts resolve to library symbols; one series inferred from a
  sibling, two DC resistance deviations reported
- ERC clean and the netlist KiCad extracts from the generated sheet is an
  identical partition to the one derived from the Boxsim grid
- container build byte-identical to the host build
- 32 tests passing, ruff clean

Decode time went 69 s -> 14.5 s (binding emulator hooks to trap addresses)
-> 1.6 s (pure Python).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-29 11:10:43 +02:00

108 lines
4.1 KiB
Python

"""End to end: generate a project, then check KiCad agrees with Boxsim.
The netlist comparison is the real gate, so it gets its own teeth test: a
deliberately mis-wired schematic must be reported as a mismatch. A gate that
has never been shown to fail is decoration.
"""
from __future__ import annotations
import pytest
from boxsim import project as project_mod
from boxsim import verify
from boxsim.schematic import DEFAULT_PITCH_MM, SchematicBuilder, transform
def test_rotation_transform_matches_kicad():
# Derived empirically from KiCad's own demo schematics.
assert transform(0, 3.81, 2.54) == (3.81, -2.54)
assert transform(90, 3.81, 2.54) == (-2.54, -3.81)
assert transform(180, 3.81, 2.54) == (-3.81, 2.54)
assert transform(270, 3.81, 2.54) == (2.54, 3.81)
def test_pitch_must_clear_widest_symbol(library):
with pytest.raises(ValueError, match="widest symbol"):
SchematicBuilder(library, pitch=20.0)
SchematicBuilder(library, pitch=DEFAULT_PITCH_MM) # must not raise
@pytest.fixture(scope="module")
def built(tmp_path_factory, default_project, catalog, library):
outdir = tmp_path_factory.mktemp("kicad")
return project_mod.build(default_project, "Default", outdir, catalog, library)
def test_project_files_written(built):
assert built.schematic.exists()
assert built.project_file.exists()
assert (built.directory / "sym-lib-table").exists()
assert (built.directory / "fp-lib-table").exists()
text = built.schematic.read_text(encoding="utf-8")
assert text.startswith("(kicad_sch")
assert "(version 20260101)" in text
assert "extends" not in text, "derived symbols must be flattened"
def test_all_components_placed(built, default_project):
assert len(built.sheet.placements) == len(default_project.crossovers[0].components)
refdes = [p.refdes for p in built.sheet.placements]
assert len(set(refdes)) == len(refdes), "reference designators must be unique"
assert sum(1 for r in refdes if r.startswith("LS")) == 3
assert sum(1 for r in refdes if r.startswith("J")) == 1
def test_output_is_deterministic(tmp_path, default_project, catalog, library):
a = project_mod.build(default_project, "Default", tmp_path / "a", catalog, library)
b = project_mod.build(default_project, "Default", tmp_path / "b", catalog, library)
assert a.schematic.read_text() == b.schematic.read_text(), (
"UUIDs must be derived, not random, so regeneration is reproducible"
)
@pytest.mark.needs_kicad
def test_gate_passes(built, default_project, kicad_cli):
gate = verify.run_gate(
built.schematic, default_project.crossovers[0], built.sheet.placements
)
assert gate.erc_ok, gate.messages
assert gate.netlist_matches, gate.messages
assert len(gate.reference_nets) == 14
@pytest.mark.needs_kicad
def test_gate_has_teeth(tmp_path, default_project, catalog, library, kicad_cli):
"""Swap one component's terminals and confirm the gate reports it.
The reference netlist is derived from the Boxsim grid while the compared one
is extracted from the sheet by KiCad, so mis-wiring a single part must break
the comparison. If this ever passes, the gate proves nothing.
"""
result = project_mod.build(
default_project, "Broken", tmp_path / "broken", catalog, library
)
crossover = default_project.crossovers[0]
# Pick a component whose two terminals are on different nets, then lie about
# which grid node one of its pins reaches.
netlist = crossover.build_netlist()
victim = next(
p
for p in result.sheet.placements
if len({netlist.net_of(n) for n in p.pin_nodes.values()}) == 2
)
pins = sorted(victim.pin_nodes)
victim.pin_nodes[pins[0]], victim.pin_nodes[pins[1]] = (
victim.pin_nodes[pins[1]],
victim.pin_nodes[pins[0]],
)
gate = verify.run_gate(result.schematic, crossover, result.sheet.placements)
assert not gate.netlist_matches, (
"a swapped terminal was not detected: the netlist comparison is not "
"actually comparing anything"
)
assert not gate.ok
assert any("mismatch" in m for m in gate.messages)