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

145 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
}
}
});
});