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

134 lines
4.7 KiB
Python

from __future__ import annotations
import pytest
from boxsim import symbols
from boxsim.catalog import SERIES_TO_SYMBOL
from boxsim.model import Orientation, Part, PartType
def test_catalogue_shape(catalog):
"""Guard the figures the resolver design relies on."""
assert len(catalog) == 95
assert len(catalog.series_names) == 11
assert set(catalog.series_names) == set(SERIES_TO_SYMBOL)
# The last catalogue line is a real part; the file has no trailing newline
# and is easy to truncate by one.
assert catalog.find("SP 1,3mm", 2.2e-3) is not None
def test_every_catalogue_part_has_a_symbol(catalog, library):
extras = symbols.cross_check(catalog, library)
assert extras == ["MKT 22μF"], (
"library should contain exactly the catalogue plus Amplifier, Speaker "
"and the radial MKT 22uF that Boxsim cannot select"
)
def test_library_values_are_matched_numerically(library):
"""Irregular library names must still resolve: 1.0uF next to 10uF."""
assert library.lookup("MKP", 1e-6) is not None
assert library.lookup("MKP", 10e-6) is not None
assert library.lookup("SP-Spule", 1e-3, "0.6").name == "SP-Spule 1.0mH (0.6)"
assert library.lookup("LR-Spule", 10e-3).name == "LR-Spule 10mH"
def test_derived_symbols_resolve_geometry(library):
"""Most symbols use `extends`; pins live on the base symbol."""
from boxsim.schematic import symbol_pins
pins = symbol_pins(library, "SP-Spule 0.82mH (0.6)")
assert len(pins.by_number) == 2
assert library.base_of("SP-Spule 0.82mH (0.6)") == "FC-Spule 10mH"
def test_flattened_definition_has_no_extends(library):
body = library.definition("SP-Spule 0.82mH (0.6)")
assert "extends" not in body
assert '(symbol "SP-Spule 0.82mH (0.6)_' in body, "graphics must be inlined"
def test_default_project_fully_resolves(catalog, library, default_project):
resolver = symbols.Resolver(catalog, library)
components = default_project.crossovers[0].components
results = resolver.resolve_all(components)
assert len(results) == len(components) == 22
assert all(r.symbol in library for r in results)
def test_empty_series_is_inferred_from_sibling(catalog, library, default_project):
resolver = symbols.Resolver(catalog, library)
components = default_project.crossovers[0].components
results = {r.part.slot: r for r in resolver.resolve_all(components)}
part231 = results[231]
assert part231.inferred_series is True
assert part231.symbol == "MKT-A 15μF", "sibling Part198 is an MKT-A 15uF"
def test_dcr_deviation_is_reported_not_corrected(catalog, library, default_project):
resolver = symbols.Resolver(catalog, library)
results = {
r.part.slot: r
for r in resolver.resolve_all(default_project.crossovers[0].components)
}
assert results[5].dcr_note is not None, "0.28 vs catalogue 0.33"
assert results[48].dcr_note is not None, "5 vs catalogue 4.8"
assert results[5].part.dcr == 0.28, "the project value must win"
def test_unstocked_value_is_a_hard_error(catalog, library):
resolver = symbols.Resolver(catalog, library)
part = Part(
slot=1,
typ=PartType.CAPACITOR,
orientation=Orientation.HORIZONTAL,
row=0,
col=0,
node_a=0,
node_b=1,
value=13e-6, # not a Visaton value
label="MKT-A",
)
with pytest.raises(symbols.SymbolError, match="not stocked"):
resolver.resolve(part)
def test_unknown_series_is_a_hard_error(catalog, library):
resolver = symbols.Resolver(catalog, library)
part = Part(
slot=2,
typ=PartType.CAPACITOR,
orientation=Orientation.HORIZONTAL,
row=0,
col=0,
node_a=0,
node_b=1,
value=10e-6,
label="Mundorf Supreme",
)
with pytest.raises(symbols.SymbolError, match="not in the Visaton catalogue"):
resolver.resolve(part)
def test_catalogue_shape_check_has_teeth(catalog):
"""A truncated catalogue must be rejected, not silently accepted.
`cross_check` only proves the catalogue is a subset of the library, so a
short catalogue passes it and then rejects valid projects at runtime with
"not stocked by Visaton". This is the check that catches it.
"""
from boxsim import catalog as catalog_mod
catalog_mod.check_shape(catalog) # the real thing must pass
short = catalog_mod.Catalog(catalog.parts[:40])
with pytest.raises(catalog_mod.CatalogError, match="expected"):
catalog_mod.check_shape(short)
# Losing a whole series must also be caught, even at the right total.
without_sp13 = catalog_mod.Catalog(
[p for p in catalog.parts if p.series != "SP 1,3mm"]
)
with pytest.raises(catalog_mod.CatalogError):
catalog_mod.check_shape(without_sp13)