diff --git a/frontend/src/__tests__/component-to-spice.test.ts b/frontend/src/__tests__/component-to-spice.test.ts index 6ec7d6e3..95888ce1 100644 --- a/frontend/src/__tests__/component-to-spice.test.ts +++ b/frontend/src/__tests__/component-to-spice.test.ts @@ -89,6 +89,10 @@ const MINIMAL_FIXTURES: Record = { topology: { '+': 'load', '−': 'gnd' }, }, pushbutton: { pins: ['A', 'B'] }, + // Same switch, smaller body: it shares the pushbutton mapper (MAPPER_ALIASES), + // so it takes the same fixture. It reached the catalog without one and, with + // no model, stood down for a circuit that never modelled it — dead button. + 'pushbutton-6mm': { pins: ['A', 'B'] }, // SPDT: pin 2 is the common wiper; at value=1 it shorts to pin 3, so pin 3 // must be wired or the mapper emits no card. 'slide-switch': { pins: ['1', '2', '3'], properties: { value: 1 } }, diff --git a/frontend/src/__tests__/part-pin-ownership.test.ts b/frontend/src/__tests__/part-pin-ownership.test.ts index 7db93fd9..7f1efeb9 100644 --- a/frontend/src/__tests__/part-pin-ownership.test.ts +++ b/frontend/src/__tests__/part-pin-ownership.test.ts @@ -165,3 +165,29 @@ describe('one declaration of the sensors whose model owns the line', () => { expect(sensorRecordOwnsPin({ sensor_type: 'dht22', pin: 4, other_pin: 9 }, 9)).toBe(false); }); }); + +describe('models that own a pad without being single-wire', () => { + it('a matrix keypad owns its columns, not its rows', async () => { + const { sensorRecordOwnsPin } = await import('../simulation/sensorModels'); + // The worker drives the columns (it keeps a `_keypad_cols_owned` set) and + // the firmware scans them as inputs; the rows are the firmware's outputs. + const kp = { sensor_type: 'matrix-keypad', pin: 13, rows: [13, 12, 14, 27], cols: [26, 25, 33, 32] }; + expect(sensorRecordOwnsPin(kp, 26)).toBe(true); + expect(sensorRecordOwnsPin(kp, 32)).toBe(true); + expect(sensorRecordOwnsPin(kp, 12)).toBe(false); // a row: the guest drives it + }); + + it('an ePaper panel owns BUSY and nothing else', async () => { + const { sensorRecordOwnsPin } = await import('../simulation/sensorModels'); + // DC / CS / RST are host-driven — blocking those would strand the panel. + const ep = { sensor_type: 'epaper-ssd168x', pin: 17, dc_pin: 17, cs_pin: 5, rst_pin: 16, busy_pin: 4 }; + expect(sensorRecordOwnsPin(ep, 4)).toBe(true); + for (const p of [17, 5, 16]) expect(sensorRecordOwnsPin(ep, p)).toBe(false); + }); + + it('a kind that owns nothing answers false for every pad', async () => { + const { sensorRecordOwnsPin } = await import('../simulation/sensorModels'); + const i2c = { sensor_type: 'bmp280', pin: 200 + 0x76, addr: 0x76 }; + for (const p of [0, 4, 200 + 0x76]) expect(sensorRecordOwnsPin(i2c, p)).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/spice-model-coverage.test.ts b/frontend/src/__tests__/spice-model-coverage.test.ts new file mode 100644 index 00000000..d1e57feb --- /dev/null +++ b/frontend/src/__tests__/spice-model-coverage.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment node +/** + * A part that DEFERS to the circuit must have a circuit to defer to. + * + * Three parts hand control of their pin to the electrical solve rather than + * seeding it themselves (they check `spiceDriven(simulator)` and stand down). + * That is the right design — a mis-wired button has to read stuck, not "work + * anyway" — but it only works if SPICE actually models the part. `pushbutton` + * and `slide-switch` were modelled; `pushbutton-6mm` was not, so on every board + * with spiceDrivenInputs (all of them) it stood down and nothing took over: + * the button was dead on the canvas, silently, with no error anywhere. + * + * This test reads the parts themselves rather than a list, so the next variant + * someone adds is covered the day it is written. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { isSpiceMapped } from '../simulation/spice/componentToSpice'; + +const PARTS_DIR = join(__dirname, '..', 'simulation', 'parts'); + +/** metadataIds whose part stands down when the board solves its inputs. */ +function partsThatDeferToSpice(): string[] { + const found = new Set(); + for (const file of readdirSync(PARTS_DIR).filter((f) => f.endsWith('.ts'))) { + const src = readFileSync(join(PARTS_DIR, file), 'utf8'); + // Walk registrations in order; a `spiceDriven(` inside a registration body + // belongs to the id that opened it. + let current: string | null = null; + for (const line of src.split('\n')) { + const reg = line.match(/PartSimulationRegistry\.register\(\s*['"]([^'"]+)['"]/); + if (reg) current = reg[1]; + if (current && /spiceDriven\s*\(/.test(line)) found.add(current); + } + } + return [...found].sort(); +} + +describe('parts that defer to the electrical solve', () => { + it('every one of them is modelled by SPICE', () => { + const deferring = partsThatDeferToSpice(); + // Sanity: the scan finds something, so a rename cannot make this vacuous. + expect(deferring.length).toBeGreaterThan(0); + const unmodelled = deferring.filter((id) => !isSpiceMapped(id)); + expect( + unmodelled, + `these parts stand down for the circuit but SPICE has no model for them, ` + + `so they do nothing at all on a board with spiceDrivenInputs: ${unmodelled.join(', ')}`, + ).toEqual([]); + }); + + it('the 6mm pushbutton is modelled as the same switch as the full-size one', async () => { + const { componentToSpice } = await import('../simulation/spice/componentToSpice'); + const lookup = (p: string) => (p === '1.l' ? 'n1' : p === '2.l' ? 'n2' : null); + const big = componentToSpice({ id: 'b', metadataId: 'pushbutton', properties: {} } as never, lookup, { + vcc: 3.3, + } as never); + const small = componentToSpice( + { id: 'b', metadataId: 'pushbutton-6mm', properties: {} } as never, + lookup, + { vcc: 3.3 } as never, + ); + expect(small).not.toBeNull(); + expect(small!.cards).toEqual(big!.cards); + }); +}); diff --git a/frontend/src/simulation/sensorModels.ts b/frontend/src/simulation/sensorModels.ts index 03fb6cd0..b5598bed 100644 --- a/frontend/src/simulation/sensorModels.ts +++ b/frontend/src/simulation/sensorModels.ts @@ -57,6 +57,38 @@ export const SINGLE_WIRE_SENSOR_MODELS: Readonly }, }; +/** + * Pads a MODEL drives, per `sensor_type`, as record fields (a field may hold a + * single pad or a list of them). Anything listed here is off limits to every + * other layer — the SPICE-threshold connector above all. + * + * The single-wire sensors contribute their data pin plus whatever extra pins + * they declare. The other two entries are models that own a pad WITHOUT being + * single-wire, which is why a plain "is it a single-wire sensor?" test was not + * enough: + * + * - a matrix keypad's COLUMNS are driven by the model (the QEMU worker even + * keeps a `_keypad_cols_owned` set); the firmware scans them as inputs; + * - an ePaper panel drives BUSY to tell the firmware it is refreshing. Its + * DC / CS / RST are the opposite case — the host drives those, so they are + * deliberately absent. + * + * Neither is broken with plain wiring today, because the connector's older + * `sourcedNets` gate happens to skip a net nothing else sits on. Put a pull-up + * on a keypad column and that gate stops holding — which is exactly how a + * level-shifted HC-SR04 lost its echo. + */ +const MODEL_OWNED_PIN_FIELDS: Readonly> = { + ...Object.fromEntries( + Object.values(SINGLE_WIRE_SENSOR_MODELS).map((m) => [ + m.sensorType, + ['pin', ...Object.keys(m.extraPins ?? {})], + ]), + ), + 'matrix-keypad': ['cols'], + 'epaper-ssd168x': ['busy_pin'], +}; + /** `sensor_type` values whose model drives its own line. Derived, never typed twice. */ export const SINGLE_WIRE_SENSOR_TYPES: ReadonlySet = new Set( Object.values(SINGLE_WIRE_SENSOR_MODELS).map((m) => m.sensorType), @@ -68,19 +100,18 @@ export function isSingleWireSensorRecord(rec: Record): boolean } /** - * Pads a single-wire sensor record owns: its data pin plus every extra pin its - * model declares (`echo_pin` for an HC-SR04). Read off the record's own fields - * rather than a hard-coded field list, so a new extra pin is covered by the - * declaration above alone. + * Pads a registered sensor record owns, per the declaration above: the fields + * are read off the record itself, so a model that grows a line is covered by + * its entry alone. A record of a kind that owns nothing (an I2C device on a + * virtual pin, a panel's host-driven DC/CS/RST) answers false for every pad. */ export function sensorRecordOwnsPin(rec: Record, gpioPin: number): boolean { - if (!isSingleWireSensorRecord(rec)) return false; - if (rec['pin'] === gpioPin) return true; - const spec = Object.values(SINGLE_WIRE_SENSOR_MODELS).find( - (m) => m.sensorType === rec['sensor_type'], - ); - for (const field of Object.keys(spec?.extraPins ?? {})) { - if (rec[field] === gpioPin) return true; + const fields = MODEL_OWNED_PIN_FIELDS[String(rec['sensor_type'] ?? '')]; + if (!fields) return false; + for (const field of fields) { + const v = rec[field]; + if (v === gpioPin) return true; + if (Array.isArray(v) && v.includes(gpioPin)) return true; } return false; } diff --git a/frontend/src/simulation/spice/componentToSpice.ts b/frontend/src/simulation/spice/componentToSpice.ts index aa4c8788..f675fa26 100644 --- a/frontend/src/simulation/spice/componentToSpice.ts +++ b/frontend/src/simulation/spice/componentToSpice.ts @@ -648,6 +648,11 @@ const MAPPERS: Record = { // just 1.l/2.l) means a user can wire GND/GPIO to any leg and it behaves like // hardware — and wiring both a GPIO and GND to the SAME terminal is a dead // short, exactly as on a real button. Back-compat: 2-pin variants expose A/B. + // The 6mm variant is the same switch with a smaller body and the same pad + // names, and it is registered as a part that DEFERS to the circuit. Without + // a model here it deferred to nothing: on every board with spiceDrivenInputs + // (which is all of them) pressing it did nothing at all. Aliased below the + // definition so the two can never drift. pushbutton: (comp, netLookup) => { const t1l = netLookup('1.l'); const t1r = netLookup('1.r'); @@ -1327,6 +1332,16 @@ export function componentToSpice( return mapper(comp, netLookup, ctx); } +// Variants that are electrically the SAME device as an entry above: one model, +// several catalog ids. Keep this next to MAPPERS so adding a body size or a +// silkscreen variant is one line and cannot forget the netlist. +const MAPPER_ALIASES: Readonly> = { + 'pushbutton-6mm': 'pushbutton', +}; +for (const [variant, base] of Object.entries(MAPPER_ALIASES)) { + MAPPERS[variant] = MAPPERS[base]; +} + /** True if we have a mapping for this metadataId. */ export function isSpiceMapped(metadataId: string): boolean { return metadataId in MAPPERS || metadataId in proMappers;