Files
Akhil Velagapudi d41bb47aba Consolidate BOM table and JSON rows by selected offer (#1225)
* Consolidate BOM table and JSON rows by selected offer

Amp-Thread-ID: https://ampcode.com/threads/T-01a07830-313a-754d-98f1-c37d1e612e1c

* Keep grouped BOM sourcing and totals consistent

Amp-Thread-ID: https://ampcode.com/threads/T-01a07830-313a-754d-98f1-c37d1e612e1c

* Share sourcing data once per BOM JSON group

Amp-Thread-ID: https://ampcode.com/threads/T-01a07830-313a-754d-98f1-c37d1e612e1c
2026-09-06 18:51:06 -04:00

469 lines
16 KiB
Rust

#![cfg(not(target_os = "windows"))]
use pcb_test_utils::assert_snapshot;
use pcb_test_utils::sandbox::Sandbox;
use std::fs;
const LED_MODULE_ZEN: &str = r#"
load("@stdlib/interfaces.zen", "Gpio")
Resistor = Module("@stdlib/generics/Resistor.zen")
Led = Module("@stdlib/generics/Led.zen")
led_color = config(str, default = "red")
r_value = config(str, default = "330Ohm")
package = config(str, default = "0603")
VCC = io(Power)
GND = io(Ground)
CTRL = io(Gpio)
led_anode = Net("LED_ANODE")
Resistor(name = "R1", value = r_value, package = package, P1 = VCC, P2 = led_anode)
Led(name = "D1", color = led_color, package = package, A = led_anode, K = CTRL)
"#;
const TEST_BOARD_ZEN: &str = r#"
load("@stdlib/interfaces.zen", "Gpio")
LedModule = Module("../modules/LedModule.zen")
Resistor = Module("@stdlib/generics/Resistor.zen")
Capacitor = Module("@stdlib/generics/Capacitor.zen")
Crystal = Module("@stdlib/generics/Crystal.zen")
vcc_3v3 = Power("VCC_3V3")
gnd = Ground("GND")
led_ctrl = Gpio("LED_CTRL")
osc_xi = Gpio("OSC_XI")
osc_xo = Gpio("OSC_XO")
Capacitor(name = "C1", value = "100nF", package = "0402", P1 = vcc_3v3, P2 = gnd)
Capacitor(name = "C2", value = "10uF", package = "0805", P1 = vcc_3v3, P2 = gnd)
LedModule(name = "LED1", led_color = "green", VCC = vcc_3v3, GND = gnd, CTRL = led_ctrl)
LedModule(name = "LED2", led_color = "red", VCC = vcc_3v3, GND = gnd, CTRL = Gpio(gnd))
Crystal(name = "X1", frequency = "16MHz", load_capacitance = "18pF", package = "5032_2Pin", XIN = osc_xi, XOUT = osc_xo)
Capacitor(name = "C3", value = "22pF", package = "0402", P1 = osc_xi, P2 = gnd)
Capacitor(name = "C4", value = "22pF", package = "0402", P1 = osc_xo, P2 = gnd)
Resistor(name = "R1", value = "10kOhm", package = "0603", P1 = vcc_3v3, P2 = led_ctrl)
"#;
const PARAMETRIC_GENERICS_BOARD_ZEN: &str = r#"
# ```pcb
# [workspace]
# pcb-version = "0.4"
# ```
FerriteBead = Module("@stdlib/generics/FerriteBead.zen")
Inductor = Module("@stdlib/generics/Inductor.zen")
Crystal = Module("@stdlib/generics/Crystal.zen")
Led = Module("@stdlib/generics/Led.zen")
PinHeader = Module("@stdlib/generics/PinHeader.zen")
Rectifier = Module("@stdlib/generics/Rectifier.zen")
Resistor = Module("@stdlib/generics/Resistor.zen")
Tvs = Module("@stdlib/generics/Tvs.zen")
Zener = Module("@stdlib/generics/Zener.zen")
vcc = Power("VCC")
gnd = Ground("GND")
signal = Net("SIGNAL")
osc_in = Net("OSC_IN")
osc_out = Net("OSC_OUT")
Resistor(name = "R1", value = "10kOhm", package = "0603", power = "250mW", P1 = vcc, P2 = signal)
Inductor(name = "L1", value = "4.7uH", package = "0603", current = "1A", dcr = "120mOhm", P1 = vcc, P2 = signal)
FerriteBead(name = "FB1", value = "600Ohm", package = "0603", frequency = "100MHz", current = "500mA", dcr = "200mOhm", P1 = vcc, P2 = signal)
Crystal(name = "Y1", package = "3215_2Pin", frequency = "32.768kHz", load_capacitance = "12.5pF", esr = "70kOhm", XIN = osc_in, XOUT = osc_out)
Led(name = "D2", package = "0402", color = "red", forward_voltage = "2V", forward_current = "20mA", A = signal, K = gnd)
PinHeader(name = "J1", mount = "THT", pins = 2, rows = 1, pitch = "2.54mm", orientation = "Vertical", gender = "Male", P1 = vcc, P2 = gnd)
Rectifier(name = "D1", package = "SOD-123", technology = "Schottky", reverse_voltage = "40V", forward_current = "1A", forward_voltage = "500mV", A = signal, K = gnd)
Zener(name = "Z1", package = "SOD-123", zener_voltage = "5.1V", power = "500mW", A = signal, K = gnd)
Tvs(name = "TVS1", package = "DO-219AB", direction = "Unidirectional", reverse_standoff_voltage = "5V", reverse_clamping_voltage = "9V", peak_pulse_power = "400W", capacitance = "30pF", A = gnd, K = signal)
"#;
const SIMPLE_RESISTOR_BOARD_ZEN: &str = r#"
# ```pcb
# [workspace]
# pcb-version = "0.4"
# ```
Resistor = Module("@stdlib/generics/Resistor.zen")
vcc = Power("VCC")
gnd = Ground("GND")
Resistor(name = "R1", value = "1kOhm", package = "0603", P1 = vcc, P2 = gnd)
Resistor(name = "R2", value = "1kOhm", package = "0603", P1 = vcc, P2 = gnd)
Resistor(name = "R3", value = "4.7kOhm", package = "0402", P1 = vcc, P2 = gnd)
"#;
const CAPACITOR_BOARD_ZEN: &str = r#"
# ```pcb
# [workspace]
# pcb-version = "0.4"
# ```
Capacitor = Module("@stdlib/generics/Capacitor.zen")
vcc = Power("VCC")
gnd = Ground("GND")
Capacitor(name = "C1", value = "100nF", package = "0402", voltage = "16V", dielectric = "X7R", P1 = vcc, P2 = gnd)
Capacitor(name = "C2", value = "10uF", package = "0805", voltage = "25V", dielectric = "X5R", P1 = vcc, P2 = gnd)
Capacitor(name = "C3", value = "1uF", package = "0603", P1 = vcc, P2 = gnd)
"#;
const SKIP_BOM_BOARD_ZEN: &str = r#"
# ```pcb
# [workspace]
# pcb-version = "0.4"
# ```
P1 = Net()
P2 = Net()
# Normal resistor - should appear in BOM
Component(
name = "normal",
footprint = File("@kicad-footprints/Resistor_SMD.pretty/R_0603_1608Metric.kicad_mod"),
pin_defs = {"1": "1", "2": "2"},
pins = {"1": P1, "2": P2},
part = Part(mpn = "RC0603FR-071KL", manufacturer = "Yageo"),
)
# Component with skip_bom kwarg - should NOT appear in BOM
Component(
name = "skip_bom_kwarg",
footprint = File("@kicad-footprints/Resistor_SMD.pretty/R_0603_1608Metric.kicad_mod"),
pin_defs = {"1": "1", "2": "2"},
pins = {"1": P1, "2": P2},
skip_bom = True,
)
# Component with skip_bom kwarg - should NOT appear in BOM
Component(
name = "skip_bom_kwarg_2",
footprint = File("@kicad-footprints/Resistor_SMD.pretty/R_0603_1608Metric.kicad_mod"),
pin_defs = {"1": "1", "2": "2"},
pins = {"1": P1, "2": P2},
skip_bom = True,
)
# Normal resistor - should appear in BOM
Component(
name = "normal2",
footprint = File("@kicad-footprints/Resistor_SMD.pretty/R_0603_1608Metric.kicad_mod"),
pin_defs = {"1": "1", "2": "2"},
pins = {"1": P1, "2": P2},
part = Part(mpn = "RC0603FR-0710KL", manufacturer = "Yageo"),
)
"#;
const DNP_BOARD_ZEN: &str = r#"
# ```pcb
# [workspace]
# pcb-version = "0.4"
# ```
P1 = Net()
P2 = Net()
# Normal component - should appear in BOM with dnp omitted (false)
Component(
name = "normal",
footprint = File("@kicad-footprints/Resistor_SMD.pretty/R_0603_1608Metric.kicad_mod"),
pin_defs = {"1": "1", "2": "2"},
pins = {"1": P1, "2": P2},
part = Part(mpn = "RC0603FR-071KL", manufacturer = "Yageo"),
)
# DNP component (via dnp kwarg) - should appear in BOM with dnp=true
Component(
name = "dnp_kwarg_1",
footprint = File("@kicad-footprints/Resistor_SMD.pretty/R_0603_1608Metric.kicad_mod"),
pin_defs = {"1": "1", "2": "2"},
pins = {"1": P1, "2": P2},
part = Part(mpn = "RC0603FR-0710KL", manufacturer = "Yageo"),
dnp = True,
)
# DNP component (via dnp kwarg) - should appear in BOM with dnp=true
Component(
name = "dnp_kwarg_2",
footprint = File("@kicad-footprints/Resistor_SMD.pretty/R_0603_1608Metric.kicad_mod"),
pin_defs = {"1": "1", "2": "2"},
pins = {"1": P1, "2": P2},
part = Part(mpn = "RC0603FR-074K7L", manufacturer = "Yageo"),
dnp = True,
)
# Component with both DNP and skip_bom - should NOT appear in BOM (skip_bom wins)
Component(
name = "dnp_and_skip_bom",
footprint = File("@kicad-footprints/Resistor_SMD.pretty/R_0603_1608Metric.kicad_mod"),
pin_defs = {"1": "1", "2": "2"},
pins = {"1": P1, "2": P2},
part = Part(mpn = "RC0603FR-07100KL", manufacturer = "Yageo"),
dnp = True,
skip_bom = True,
)
"#;
const MODULE_DNP_BOARD_ZEN: &str = r#"
# ```pcb
# [workspace]
# pcb-version = "0.4"
# ```
Resistor = Module("@stdlib/generics/Resistor.zen")
Capacitor = Module("@stdlib/generics/Capacitor.zen")
vcc = Power("VCC")
gnd = Ground("GND")
# Normal module - components should NOT be DNP
Resistor(name = "R1", value = "1kOhm", package = "0603", P1 = vcc, P2 = gnd)
# Module with dnp=True - all child components should be DNP
Resistor(name = "R2_DNP", value = "10kOhm", package = "0603", P1 = vcc, P2 = gnd, dnp = True)
Capacitor(name = "C1_DNP", value = "100nF", package = "0402", P1 = vcc, P2 = gnd, dnp = True)
# Normal module again - should NOT be DNP
Capacitor(name = "C2", value = "10uF", package = "0805", P1 = vcc, P2 = gnd)
"#;
const WORKSPACE_TOML: &str = r#"
[workspace]
pcb-version = "0.4"
"#;
#[test]
fn test_bom_json_format() {
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write("modules/LedModule.zen", LED_MODULE_ZEN)
.write("boards/TestBoard.zen", TEST_BOARD_ZEN)
.sync()
.snapshot_run("pcbc", ["bom", "boards/TestBoard.zen", "-f", "json"]);
assert_snapshot!("bom_json", output);
}
#[test]
fn test_parametric_generics_json_format() {
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write(
"boards/ParametricGenerics.zen",
PARAMETRIC_GENERICS_BOARD_ZEN,
)
.sync()
.snapshot_run(
"pcbc",
[
"bom",
"boards/ParametricGenerics.zen",
"--offline",
"-f",
"json",
],
);
assert_snapshot!("bom_parametric_generics_json", output);
}
#[test]
fn test_bom_table_format() {
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write("modules/LedModule.zen", LED_MODULE_ZEN)
.write("boards/TestBoard.zen", TEST_BOARD_ZEN)
.sync()
.snapshot_run("pcbc", ["bom", "boards/TestBoard.zen", "-f", "table"]);
assert_snapshot!("bom_table", output);
}
#[test]
fn test_bom_default_format() {
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write("modules/LedModule.zen", LED_MODULE_ZEN)
.write("boards/TestBoard.zen", TEST_BOARD_ZEN)
.sync()
.snapshot_run("pcbc", ["bom", "boards/TestBoard.zen"]);
assert_snapshot!("bom_default", output);
}
#[test]
fn test_bom_simple_resistors() {
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write("boards/SimpleResistors.zen", SIMPLE_RESISTOR_BOARD_ZEN)
.sync()
.snapshot_run("pcbc", ["bom", "boards/SimpleResistors.zen", "-f", "json"]);
assert_snapshot!("bom_simple_resistors_json", output);
}
#[test]
fn test_bom_simple_resistors_table() {
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write("boards/SimpleResistors.zen", SIMPLE_RESISTOR_BOARD_ZEN)
.sync()
.snapshot_run("pcbc", ["bom", "boards/SimpleResistors.zen", "-f", "table"]);
assert_snapshot!("bom_simple_resistors_table", output);
}
#[test]
fn test_bom_capacitors_with_dielectric() {
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write("boards/Capacitors.zen", CAPACITOR_BOARD_ZEN)
.sync()
.snapshot_run("pcbc", ["bom", "boards/Capacitors.zen", "-f", "json"]);
assert_snapshot!("bom_capacitors_json", output);
}
#[test]
fn test_bom_capacitors_table() {
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write("boards/Capacitors.zen", CAPACITOR_BOARD_ZEN)
.sync()
.snapshot_run("pcbc", ["bom", "boards/Capacitors.zen", "-f", "table"]);
assert_snapshot!("bom_capacitors_table", output);
}
#[test]
fn test_bom_kicad_fallback_json() {
// Test BOM fallback to kicad-cli when design has no components
// Copy the kicad project files into the sandbox
let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let test_dir = workspace_root.join("crates/pcb-sch/test/kicad-bom");
let kicad_sch = fs::read_to_string(test_dir.join("layout.kicad_sch")).unwrap();
let kicad_pcb = fs::read_to_string(test_dir.join("layout.kicad_pcb")).unwrap();
let kicad_pro = fs::read_to_string(test_dir.join("layout.kicad_pro")).unwrap();
let zen_file = r#"
Layout(name="kicad-bom", path="layout")
"#;
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write("kicad-bom.zen", zen_file)
.write("layout/layout.kicad_sch", kicad_sch)
.write("layout/layout.kicad_pcb", kicad_pcb)
.write("layout/layout.kicad_pro", kicad_pro)
.sync()
.snapshot_run("pcbc", ["bom", "kicad-bom.zen", "-f", "json"]);
assert_snapshot!("bom_kicad_fallback_json", output);
}
#[test]
fn test_bom_skip_bom_filtering() {
// Test that components with skip_bom are excluded from BOM output
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write("boards/SkipBom.zen", SKIP_BOM_BOARD_ZEN)
.sync()
.snapshot_run("pcbc", ["bom", "boards/SkipBom.zen", "-f", "json"]);
assert_snapshot!("bom_skip_bom_json", output);
}
#[test]
fn test_bom_skip_bom_filtering_table() {
// Test skip_bom filtering in table format
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write("boards/SkipBom.zen", SKIP_BOM_BOARD_ZEN)
.sync()
.snapshot_run("pcbc", ["bom", "boards/SkipBom.zen", "-f", "table"]);
assert_snapshot!("bom_skip_bom_table", output);
}
#[test]
fn test_bom_dnp_components() {
// Test that DNP components appear in BOM (dnp is for assembly, not procurement)
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write("boards/DnpBoard.zen", DNP_BOARD_ZEN)
.sync()
.snapshot_run("pcbc", ["bom", "boards/DnpBoard.zen", "-f", "json"]);
assert_snapshot!("bom_dnp_json", output);
}
#[test]
fn test_bom_module_dnp_propagation() {
// Test that module-level dnp=True propagates to all child components
let output = Sandbox::new()
.write("pcb.toml", WORKSPACE_TOML)
.write("boards/ModuleDnp.zen", MODULE_DNP_BOARD_ZEN)
.sync()
.snapshot_run("pcbc", ["bom", "boards/ModuleDnp.zen", "-f", "json"]);
assert_snapshot!("bom_module_dnp_json", output);
}
#[test]
fn bom_json_uses_symbol_description_for_untyped_component() {
const SYMBOL: &str = r#"(kicad_symbol_lib (version 20231120) (generator kicad_symbol_editor)
(symbol "TCXO"
(property "Reference" "U" (at 0 0 0) (effects (font (size 1.27 1.27))))
(property "Value" "TCXO" (at 0 0 0) (effects (font (size 1.27 1.27))))
(property "Footprint" "" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "Description" "Temperature-compensated crystal oscillator" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "Manufacturer_Name" "Acme" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(property "Manufacturer_Part_Number" "ACME-TCXO-32" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
(symbol "TCXO_1_1"
(pin input line (at -2.54 0 0) (length 2.54)
(name "EN" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))))))"#;
const BOARD: &str = r#"enable = Net("ENABLE")
Component(
name = "oscillator",
symbol = Symbol(library = "TCXO.kicad_sym"),
footprint = "Package_DirectFET:DirectFET_L4",
properties = {"value": "32MHz"},
pins = {"EN": enable},
)
"#;
let mut sandbox = Sandbox::new();
sandbox
.write("pcb.toml", "[workspace]\n")
.write("TCXO.kicad_sym", SYMBOL)
.write("TCXO.zen", BOARD);
let output = sandbox
.run("pcbc", ["bom", "TCXO.zen", "-f", "json"])
.stdout_capture()
.stderr_capture()
.unchecked()
.run()
.expect("BOM command should run");
assert!(
output.status.success(),
"BOM command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let bom: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("BOM output should be valid JSON");
let entry = &bom[0]["members"][0];
assert_eq!(entry["value"], "32MHz");
assert_eq!(
entry["description"],
"Temperature-compensated crystal oscillator"
);
assert_eq!(entry["manufacturer"], "Acme");
assert_eq!(entry["mpn"], "ACME-TCXO-32");
}