From eaf3fffd36d4c45b19d7631831e0d963c53a9233 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Tue, 21 Apr 2026 12:59:59 -0300 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- .../__tests__/examples-analog-live.test.ts | 53 ++ .../src/__tests__/examples-analog.test.ts | 132 +++ .../src/components/editor/EditorToolbar.tsx | 8 +- .../components/examples/CircuitPreview.tsx | 9 +- .../components/examples/ExamplesGallery.tsx | 3 +- .../examples/InlineComponentSVGs.tsx | 17 + .../components/simulator/SimulatorCanvas.tsx | 34 +- frontend/src/data/examples-analog.ts | 883 ++++++++++++++++++ frontend/src/data/examples.ts | 12 +- frontend/src/utils/loadExample.ts | 23 +- 10 files changed, 1147 insertions(+), 27 deletions(-) create mode 100644 frontend/src/__tests__/examples-analog-live.test.ts create mode 100644 frontend/src/__tests__/examples-analog.test.ts create mode 100644 frontend/src/data/examples-analog.ts diff --git a/frontend/src/__tests__/examples-analog-live.test.ts b/frontend/src/__tests__/examples-analog-live.test.ts new file mode 100644 index 00000000..111ee41d --- /dev/null +++ b/frontend/src/__tests__/examples-analog-live.test.ts @@ -0,0 +1,53 @@ +/** + * Live ngspice check for a handful of representative analog examples. + * + * We don't run every one of the 30 circuits through WASM ngspice here — + * that'd be ~15 s of CI time. Instead, four archetypes cover the main + * element classes: + * - an-voltage-divider (resistors + DC source) + * - an-half-wave-rectifier (diode + AC source, .tran) + * - an-bjt-switch (BJT + switch waveform) + * - an-opamp-follower (LM358 behavioral op-amp) + * + * Anything else should be covered by the per-mapper tests in + * component-to-spice.test.ts + the shape tests in examples-analog.test.ts. + */ +import { describe, it, expect } from 'vitest'; +import { analogExamples } from '../data/examples-analog'; +import { buildNetlist } from '../simulation/spice/NetlistBuilder'; +import { runNetlist } from '../simulation/spice/SpiceEngine'; + +const ARCHETYPES = [ + 'an-voltage-divider', + 'an-half-wave-rectifier', + 'an-bjt-switch', + 'an-opamp-follower', +]; + +describe('analogExamples — representative ngspice solves', () => { + for (const id of ARCHETYPES) { + it(`${id} is solvable by ngspice`, { timeout: 30_000 }, async () => { + const ex = analogExamples.find((e) => e.id === id); + expect(ex, `${id} not found in analogExamples`).toBeDefined(); + + const { netlist } = buildNetlist({ + components: ex!.components.map((c) => ({ + id: c.id, + metadataId: c.type.replace(/^wokwi-/, ''), + properties: c.properties ?? {}, + })), + wires: ex!.wires.map((w) => ({ + id: w.id, + start: { componentId: w.start.componentId, pinName: w.start.pinName }, + end: { componentId: w.end.componentId, pinName: w.end.pinName }, + })), + boards: [], + analysis: { kind: 'op' }, + }); + + const result = await runNetlist(netlist); + expect(result.variableNames.length).toBeGreaterThan(0); + expect(Number.isFinite(result.dcValue(result.variableNames[0]))).toBe(true); + }); + } +}); diff --git a/frontend/src/__tests__/examples-analog.test.ts b/frontend/src/__tests__/examples-analog.test.ts new file mode 100644 index 00000000..52023aec --- /dev/null +++ b/frontend/src/__tests__/examples-analog.test.ts @@ -0,0 +1,132 @@ +/** + * 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-/, ''), + 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']; + 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(); + for (const ex of analogExamples) { + for (const c of ex.components) { + const id = c.type.replace(/^wokwi-/, ''); + 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) followed by + // "_" somewhere. The voltmeter e.g. emits "R_vm_vmR ...", so the + // id may be followed by any non-word-boundary character. + const re = new RegExp(`^[RCLDQMESVB]_${c.id}(?:_|\\b)`, 'm'); + expect(netlist, `${ex.id} missing card for ${c.id} (${c.type})`).toMatch(re); + } + } + }); +}); diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 82c4f7a2..178221df 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -506,9 +506,9 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi {/* Compile */}