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:
parent
f9e5c19f95
commit
33570d7690
|
|
@ -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 : ''}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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' },
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue