velxio/frontend/src/__tests__/examples-analog.test.ts

145 lines
5.4 KiB
TypeScript
Raw Normal View History

/**
* Analog examples SPICE netlist validation.
*
* For each board-less analog example:
* 1. It's exported as boardFilter:'analog' (so the gallery groups it).
* 2. Its wires reference only components that actually exist.
* 3. buildNetlist produces a non-empty netlist with a ground node.
* 4. Every component metadataId used is actually mapped to SPICE.
*
* We deliberately do NOT run ngspice against every circuit here that's
* ~30 × ~500 ms of WASM work per CI run. A handful of representative
* topologies are spot-checked for solver convergence; the rest rely on
* componentToSpice smoke-tests and NetlistBuilder unit tests for correctness.
*/
import { describe, it, expect } from 'vitest';
import { analogExamples } from '../data/examples-analog';
import { buildNetlist } from '../simulation/spice/NetlistBuilder';
import { mappedMetadataIds } from '../simulation/spice/componentToSpice';
function toSpiceComponents(example: (typeof analogExamples)[number]) {
return example.components.map((c) => ({
id: c.id,
metadataId: c.type.replace(/^(wokwi|velxio)-/, ''),
properties: c.properties ?? {},
}));
}
function toSpiceWires(example: (typeof analogExamples)[number]) {
return example.wires.map((w) => ({
id: w.id,
start: { componentId: w.start.componentId, pinName: w.start.pinName },
end: { componentId: w.end.componentId, pinName: w.end.pinName },
}));
}
describe('analogExamples — shape', () => {
it('exports exactly 30 board-less analog circuits', () => {
expect(analogExamples.length).toBe(30);
});
it('every example uses boardFilter: "analog" and category: "circuits"', () => {
for (const ex of analogExamples) {
expect(ex.boardFilter, `${ex.id} boardFilter`).toBe('analog');
expect(ex.category, `${ex.id} category`).toBe('circuits');
}
});
it('no example lists a board in its components[]', () => {
const BOARD_PREFIXES = [
'wokwi-arduino-',
'wokwi-esp32',
'wokwi-raspberry-',
'wokwi-nano-rp',
'velxio-esp32',
'velxio-raspberry-',
'velxio-pi-pico-w',
];
for (const ex of analogExamples) {
const boards = ex.components.filter((c) => BOARD_PREFIXES.some((p) => c.type.startsWith(p)));
expect(
boards.map((b) => b.id),
`${ex.id}`,
).toEqual([]);
}
});
it('no example sets a boardType (analog circuits are board-less)', () => {
for (const ex of analogExamples) {
expect(ex.boardType, `${ex.id}`).toBeUndefined();
}
});
it('every component type has a SPICE mapping', () => {
const mapped = new Set(mappedMetadataIds());
const unmapped = new Set<string>();
for (const ex of analogExamples) {
for (const c of ex.components) {
const id = c.type.replace(/^(wokwi|velxio)-/, '');
if (!mapped.has(id)) unmapped.add(`${ex.id}:${c.id}(${id})`);
}
}
expect(Array.from(unmapped)).toEqual([]);
});
it('every wire endpoint references a component that exists', () => {
for (const ex of analogExamples) {
const ids = new Set(ex.components.map((c) => c.id));
for (const w of ex.wires) {
expect(ids.has(w.start.componentId), `${ex.id}:${w.id}.start(${w.start.componentId})`).toBe(
true,
);
expect(ids.has(w.end.componentId), `${ex.id}:${w.id}.end(${w.end.componentId})`).toBe(true);
}
}
});
it('every example has at least one signal-generator to seed ground', () => {
for (const ex of analogExamples) {
const hasSig = ex.components.some((c) => c.type === 'wokwi-signal-generator');
expect(hasSig, `${ex.id} has no signal-generator (no ground reference)`).toBe(true);
}
});
});
describe('analogExamples — netlist generation', () => {
it('each example produces a non-empty netlist with ground net', () => {
for (const ex of analogExamples) {
const { netlist } = buildNetlist({
components: toSpiceComponents(ex),
wires: toSpiceWires(ex),
boards: [],
analysis: { kind: 'op' },
});
expect(netlist.length, `${ex.id} netlist empty`).toBeGreaterThan(20);
expect(netlist, `${ex.id} missing .end`).toContain('.end');
// Every signal-generator must drop a V-source whose second node is 0
// (the GND pin is canonicalised). That's how we know ground is wired.
const sigs = ex.components.filter((c) => c.type === 'wokwi-signal-generator');
for (const sig of sigs) {
const re = new RegExp(`^V_${sig.id}\\s+\\S+\\s+0\\b`, 'm');
expect(netlist, `${ex.id}: ${sig.id} GND not canonicalised to 0`).toMatch(re);
}
}
});
it('each example emits at least one card referencing every component id', () => {
for (const ex of analogExamples) {
const { netlist } = buildNetlist({
components: toSpiceComponents(ex),
wires: toSpiceWires(ex),
boards: [],
analysis: { kind: 'op' },
});
for (const c of ex.components) {
// Cards begin with an element prefix (R/C/L/D/Q/M/E/S/V/B/X) followed by
// "_<id>" somewhere. The voltmeter e.g. emits "R_vm_vmR ...", so the
// id may be followed by any non-word-boundary character. X is the
// SPICE subcircuit instance prefix (Phase 1d #9 LM358 macro-model).
const re = new RegExp(`^[RCLDQMESVBX]_${c.id}(?:_|\\b)`, 'm');
expect(netlist, `${ex.id} missing card for ${c.id} (${c.type})`).toMatch(re);
}
}
});
});