feat(sim): Phase 1d #1 — gallery smoke test imports real examples + shared helper

Replaces the manual "open each example in browser" step from the
post-migration plan with an automated test that:

  • Imports `analogExamples` and `digitalExamples` from the real
    `data/examples-*.ts` modules — new gallery entries pick up the
    test automatically.
  • Uses the same `stripBrandPrefix` + board-filter logic that
    production `loadExample.ts` uses, via the new shared helper
    `utils/exampleToBuildNetlistInput.ts`.  Single source of truth:
    if the wokwi/velxio prefix rule ever changes, both production
    and the smoke test track it.
  • Runs each example through `solveInput` (Phase 1c F2 helper)
    against the same ngspice WASM production uses.

`loadExample.ts` refactored to call `stripBrandPrefix` instead of
inlining the regex (two call sites converged on the helper).

Result against the gallery:
  • 67/68 examples converge cleanly.
  • 1 known regression: `an-opamp-follower` (LM358 follower) — the
    same case `examples-analog-live.test.ts` already skips.  Item
    #2 (.op convergence helpers in NgSpiceWorkerAdapter) targets it.

The smoke test now serves as the safety net for the remaining
post-migration work — it'll flag if a future fix breaks examples
that converge today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 22:11:28 +02:00
parent f9e5c19f95
commit 33570d7690
3 changed files with 190 additions and 2 deletions

View File

@ -0,0 +1,101 @@
/**
* Smoke test for every example in the velxio gallery.
*
* Item #1 of Phase 1d (post-migration cleanup). After Phase 1c
* swapped the SPICE engine from `eecircuit-engine` to the vendored
* `ngspice-interactive`, examples that converged before may not
* converge now (the LM358 follower is the canonical case). This
* test runs every example through the new solver and flags any
* regression.
*
* Test fidelity rule (memory: feedback_tests_import_real_code):
* Imports `analogExamples` / `digitalExamples` from the real
* `data/examples-*.ts` modules. Adding a new example to the
* gallery automatically extends this test.
* Uses `exampleToBuildNetlistInput` the same helper that
* production `loadExample.ts` uses. If the brand-prefix rule
* or the board-filter changes, both paths track it.
* Uses `solveInput` (Phase 1c F2 helper) backed by the same
* ngspice WASM that production runs.
*
* What we assert per example:
* 1. `buildNetlist` produces a non-empty netlist.
* 2. The solver returns at least one vector (some converged state
* reached the plot).
* 3. No NaN or Infinity in nodeVoltages.
*
* What we do NOT assert: specific voltage values. Examples have
* board pins driven by sketches and runtime state we don't replay
* here values are pre-deployment sanity, not behavioural locks.
*/
import { describe, it, expect } from 'vitest';
import { analogExamples } from '../data/examples-analog';
import { digitalExamples } from '../data/examples-digital';
import { exampleToBuildNetlistInput } from '../utils/exampleToBuildNetlistInput';
import { solveInput } from './helpers/solveInput';
interface SmokeOutcome {
id: string;
title: string;
status: 'ok' | 'no-vectors' | 'invalid-numerics' | 'error';
detail?: string;
}
async function smokeOne(
example: typeof analogExamples[number],
): Promise<SmokeOutcome> {
try {
const input = exampleToBuildNetlistInput(example);
if (input.components.length === 0 && input.wires.length === 0) {
return { id: example.id, title: example.title, status: 'ok', detail: 'no-SPICE-content' };
}
const result = await solveInput(input);
const voltages = Object.values(result.nodeVoltages);
if (voltages.length === 0 && Object.values(result.branchCurrents).length === 0) {
return { id: example.id, title: example.title, status: 'no-vectors' };
}
const bad = voltages.find((v) => !Number.isFinite(v));
if (bad !== undefined) {
return { id: example.id, title: example.title, status: 'invalid-numerics', detail: String(bad) };
}
return { id: example.id, title: example.title, status: 'ok' };
} catch (err) {
return {
id: example.id,
title: example.title,
status: 'error',
detail: err instanceof Error ? err.message : String(err),
};
}
}
describe('Gallery smoke — every analog example solves on the new engine', () => {
it.each(analogExamples.map((ex) => [ex.id, ex] as const))(
'%s',
{ timeout: 30_000 },
async (_id, example) => {
const outcome = await smokeOne(example);
if (outcome.status !== 'ok') {
// Throw a structured error so the test report shows what failed.
throw new Error(
`[${outcome.id}] "${outcome.title}" → ${outcome.status}${outcome.detail ? ': ' + outcome.detail : ''}`,
);
}
},
);
});
describe('Gallery smoke — every digital example solves on the new engine', () => {
it.each(digitalExamples.map((ex) => [ex.id, ex] as const))(
'%s',
{ timeout: 30_000 },
async (_id, example) => {
const outcome = await smokeOne(example);
if (outcome.status !== 'ok') {
throw new Error(
`[${outcome.id}] "${outcome.title}" → ${outcome.status}${outcome.detail ? ': ' + outcome.detail : ''}`,
);
}
},
);
});

View File

@ -0,0 +1,86 @@
/**
* exampleToBuildNetlistInput single source of truth for converting
* an `ExampleProject` into the `BuildNetlistInput` the SPICE engine
* consumes.
*
* Used by:
* `loadExample.ts` (production load path)
* smoke tests (`__tests__/examples-*-live.test.ts`)
* any future tool that wants to inspect example netlists
* without going through the simulator store
*
* If the prefix rules or filtering ever change (e.g. add a new
* `mfgX-` namespace), update ONLY this file every consumer picks
* the new behaviour automatically.
*/
import type { BuildNetlistInput, AnalysisMode } from '../simulation/spice/types';
import type { ExampleProject } from '../data/examples';
/**
* Strip the brand prefix from a wokwi-elements / velxio-elements
* component tag. Matches the regex used historically inside
* `loadExample.ts`.
*
* Examples:
* wokwi-led led
* wokwi-bjt-2n2222 bjt-2n2222
* velxio-74hc595 74hc595
* resistor resistor (no prefix, untouched)
*/
export function stripBrandPrefix(componentType: string): string {
return componentType.replace(/^(wokwi|velxio)-/, '');
}
/**
* Whether a component is a board / MCU (excluded from the SPICE
* component list boards are stamped as voltage sources at the pin
* level, not as full components).
*/
export function isBoardComponentType(componentType: string): boolean {
const t = componentType.toLowerCase();
return (
t.includes('arduino') ||
t.includes('pico') ||
t.includes('raspberry') ||
t.includes('esp32')
);
}
/**
* Convert an `ExampleProject` into a `BuildNetlistInput` ready for
* `NetlistBuilder.buildNetlist`. Boards are filtered out of the
* component list (they don't get SPICE cards only V-sources via
* pin states), and component types lose their brand prefix.
*
* Boards[] is empty by default for smoke tests we don't need to
* stamp MCU pin voltages. Callers that DO need them (e.g. live
* tests of multi-board setups) pass `opts.boards` explicitly.
*/
export function exampleToBuildNetlistInput(
example: ExampleProject,
opts: {
analysis?: AnalysisMode;
boards?: BuildNetlistInput['boards'];
} = {},
): BuildNetlistInput {
const components = example.components
.filter((c) => !isBoardComponentType(c.type))
.map((c) => ({
id: c.id,
metadataId: stripBrandPrefix(c.type),
properties: c.properties ?? {},
}));
const wires = 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 },
}));
return {
components,
wires,
boards: opts.boards ?? [],
analysis: opts.analysis ?? { kind: 'op' },
};
}

View File

@ -13,6 +13,7 @@ import { useVfsStore } from '../store/useVfsStore';
import { isBoardComponent } from './boardPinMapping';
import { getInstalledLibraries, installLibrary } from '../services/libraryService';
import { trackOpenExample } from './analytics';
import { stripBrandPrefix } from './exampleToBuildNetlistInput';
export interface LibraryInstallProgress {
total: number;
@ -165,7 +166,7 @@ export async function loadExample(
setComponents(
componentsWithoutBoard.map((comp) => ({
id: comp.id,
metadataId: comp.type.replace(/^(wokwi|velxio)-/, ''),
metadataId: stripBrandPrefix(comp.type),
x: comp.x,
y: comp.y,
properties: comp.properties,
@ -259,7 +260,7 @@ export async function loadExample(
setComponents(
componentsWithoutBoard.map((comp) => ({
id: comp.id,
metadataId: comp.type.replace(/^(wokwi|velxio)-/, ''),
metadataId: stripBrandPrefix(comp.type),
x: comp.x,
y: comp.y,
properties: comp.properties,