test(sim): Phase 1d-tests E + F — part simulator coverage + solver determinism

E (part-simulators-coverage): iterates every metadataId returned by
PartSimulationRegistry.listRegisteredParts() and asserts the
attachEvents surface is valid (no throw, unsubscribe callable).  82
parts covered automatically + 1 sanity baseline.  Surfaces real Node
compat gaps — discovered servo + neopixel reach for
requestAnimationFrame, now shimmed in a beforeAll.

F (solver-determinism): 8 canonical examples run through solveInput
three times each; node voltages must agree within 1e-12.  Plus a
state-leak test (solve A, solve B, solve A again — A's results must
be bit-identical).  Catches RNG / residual-state regressions in the
NgSpiceNodeAdapter singleton.

Adding either a new part registration or a new canonical example
extends coverage automatically — no fixture duplication per the
test-fidelity rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 23:10:05 +02:00
parent 1770d51ccd
commit bed2bd90ef
2 changed files with 281 additions and 0 deletions

View File

@ -0,0 +1,155 @@
/**
* Part simulator coverage tests (Phase 1d-tests E).
*
* Iterates every metadataId registered in `PartSimulationRegistry`
* and asserts that the registered logic has a valid attach surface:
* `attachEvents` (when present) doesn't throw with a minimal
* mock element + simulator + pin helpers.
* The returned unsubscribe is callable.
*
* Why: catches the "I added a new component handler and forgot to
* wire its pins correctly" class of regressions. Doesn't validate
* behaviour (the existing simulation-parts + logic-gate-parts tests
* cover that for the specific parts that need it) just shape.
*
* Fidelity (memory `feedback_tests_import_real_code`): enumerates
* via the live `PartSimulationRegistry.listRegisteredParts()` helper
* added in Phase 1d-tests C. Adding a new `register()` call
* automatically extends this test.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import '../simulation/parts'; // side-effect: registers every part
import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry';
// Node doesn't ship `requestAnimationFrame` / `cancelAnimationFrame` —
// some part handlers (servo, neopixel) reach for them. Shim with a
// microtask so the attach surface remains testable in Node.
const RAF_KEY = 'requestAnimationFrame' as const;
const CAF_KEY = 'cancelAnimationFrame' as const;
const originalRAF = (globalThis as Record<string, unknown>)[RAF_KEY];
const originalCAF = (globalThis as Record<string, unknown>)[CAF_KEY];
beforeAll(() => {
(globalThis as Record<string, unknown>)[RAF_KEY] = (cb: FrameRequestCallback): number => {
return setTimeout(() => cb(performance.now()), 0) as unknown as number;
};
(globalThis as Record<string, unknown>)[CAF_KEY] = (id: number): void => {
clearTimeout(id as unknown as NodeJS.Timeout);
};
});
afterAll(() => {
(globalThis as Record<string, unknown>)[RAF_KEY] = originalRAF;
(globalThis as Record<string, unknown>)[CAF_KEY] = originalCAF;
});
/**
* Tiny mock simulator that exposes the shape parts read. Enough for
* the attach to succeed without raising; behaviour assertions live in
* the dedicated part-specific test files.
*/
function makeMockSimulator(): unknown {
const listeners = new Map<number, Array<(pin: number, state: boolean) => void>>();
const pwmListeners = new Map<number, Array<(pin: number, duty: number) => void>>();
return {
pinManager: {
onPinChange(pin: number, cb: (p: number, s: boolean) => void): () => void {
if (!listeners.has(pin)) listeners.set(pin, []);
listeners.get(pin)!.push(cb);
return () => {
const arr = listeners.get(pin);
if (arr) listeners.set(pin, arr.filter((c) => c !== cb));
};
},
onPwmChange(pin: number, cb: (p: number, d: number) => void): () => void {
if (!pwmListeners.has(pin)) pwmListeners.set(pin, []);
pwmListeners.get(pin)!.push(cb);
return () => {
const arr = pwmListeners.get(pin);
if (arr) pwmListeners.set(pin, arr.filter((c) => c !== cb));
};
},
triggerPinChange(): void {},
getPinState(): boolean { return false; },
getPwmValue(): number { return 0; },
},
cpu: {
addClockEvent(fn: () => void, _cycles: number): void { fn(); },
data: new Uint8Array(256),
},
setPinState(): void {},
getCurrentCycles(): number { return 0; },
getADC(): null { return null; },
};
}
function makeMockElement(): HTMLElement {
// Vitest runs in node env; we don't have a real DOM, so build a
// duck-typed element with the surface parts touch. Parts only read
// a few attributes and event listeners, never query selectors.
const properties: Record<string, unknown> = {};
const handlers = new Map<string, Array<EventListener>>();
const el = {
id: 'mock-component',
tagName: 'WOKWI-MOCK',
addEventListener(type: string, listener: EventListener) {
if (!handlers.has(type)) handlers.set(type, []);
handlers.get(type)!.push(listener);
},
removeEventListener(type: string, listener: EventListener) {
const arr = handlers.get(type);
if (arr) handlers.set(type, arr.filter((l) => l !== listener));
},
dispatchEvent(): boolean { return true; },
setAttribute(): void {},
getAttribute(): string | null { return null; },
// Proxy properties so reads/writes work like a custom element
};
return new Proxy(el as unknown as HTMLElement, {
get(target, key) {
if (key in target) return (target as unknown as Record<string | symbol, unknown>)[key];
return properties[key as string];
},
set(target, key, value) {
if (key in target) {
(target as unknown as Record<string | symbol, unknown>)[key] = value;
} else {
properties[key as string] = value;
}
return true;
},
});
}
const registered = PartSimulationRegistry.listRegisteredParts();
describe('Part simulators — every registered part has a valid attach surface', () => {
it('registry has at least 50 entries (sanity baseline)', () => {
expect(registered.length).toBeGreaterThanOrEqual(50);
});
it.each(registered.map((id) => [id] as const))(
'%s — attachEvents (if present) returns a callable unsubscribe',
{ timeout: 5_000 },
(id) => {
const logic = PartSimulationRegistry.get(id);
expect(logic, `${id} should be registered`).toBeDefined();
if (!logic?.attachEvents) return; // some parts only define `onPinStateChange`
const element = makeMockElement();
const simulator = makeMockSimulator() as Parameters<typeof logic.attachEvents>[1];
const getArduinoPinHelper = (_pin: string): number | null => null;
const componentId = `${id}-test-1`;
// attach with the modern 5-arg signature; legacy 3/4-arg handlers
// ignore the extras gracefully.
let unsubscribe: (() => void) | undefined;
expect(() => {
unsubscribe = logic.attachEvents!(element, simulator, getArduinoPinHelper, componentId);
}, `${id}.attachEvents threw`).not.toThrow();
if (unsubscribe) {
expect(() => unsubscribe!(), `${id} unsubscribe threw`).not.toThrow();
}
},
);
});

View File

@ -0,0 +1,126 @@
/**
* Solver determinism tests (Phase 1d-tests F).
*
* For a curated set of representative examples, run `solveInput` three
* times in a row and assert that every `nodeVoltage` is bit-identical
* across the runs (within 1e-12 numerical tolerance).
*
* Why this matters: ngspice should be deterministic given the same
* netlist + same convergence options. If a future change adds a
* stateful side-effect (random init, residual state from a prior
* solve, time-of-day in the netlist), this test catches it. Without
* determinism, the snapshot tests would flake.
*
* Imports the same examples + helpers as the gallery smoke adding
* another canonical example to the list below extends coverage.
*/
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';
import type { ExampleProject } from '../data/examples';
/**
* Canonical examples one per archetype the solver should never
* regress on. Adding to this list is cheap; removing requires a
* conscious decision.
*/
const CANONICAL_IDS = [
// From analog gallery
'an-voltage-divider',
'an-rc-low-pass',
'an-half-wave-rectifier',
'an-bjt-switch',
'an-opamp-follower',
// From digital gallery — picks one of each gate family
'digital-and-two-switches',
'digital-or-any-switch',
'digital-not-inverter',
];
function findExample(id: string): ExampleProject | undefined {
return [...analogExamples, ...digitalExamples].find((ex) => ex.id === id);
}
function deepEqualWithTolerance(
a: Record<string, number>,
b: Record<string, number>,
tol = 1e-12,
): { ok: boolean; mismatch?: { key: string; a: number; b: number } } {
const keysA = Object.keys(a).sort();
const keysB = Object.keys(b).sort();
if (keysA.length !== keysB.length) {
return {
ok: false,
mismatch: { key: '<key set differs>', a: keysA.length, b: keysB.length },
};
}
for (let i = 0; i < keysA.length; i++) {
if (keysA[i] !== keysB[i]) {
return {
ok: false,
mismatch: { key: keysA[i]!, a: a[keysA[i]!]!, b: b[keysB[i]!]!},
};
}
}
for (const key of keysA) {
const va = a[key]!;
const vb = b[key]!;
if (Math.abs(va - vb) > tol) {
return { ok: false, mismatch: { key, a: va, b: vb } };
}
}
return { ok: true };
}
describe('Solver determinism — same netlist → same vectors across runs', () => {
for (const id of CANONICAL_IDS) {
const example = findExample(id);
if (!example) {
it.skip(`${id} (not found in canonical examples — list out of sync)`, () => {});
continue;
}
it(`${id} converges to the same nodeVoltages across 3 consecutive solves`, { timeout: 30_000 }, async () => {
const input = exampleToBuildNetlistInput(example);
const run1 = await solveInput(input);
const run2 = await solveInput(input);
const run3 = await solveInput(input);
const cmp12 = deepEqualWithTolerance(run1.nodeVoltages, run2.nodeVoltages);
const cmp23 = deepEqualWithTolerance(run2.nodeVoltages, run3.nodeVoltages);
if (!cmp12.ok) {
throw new Error(
`[${id}] run1 != run2 at "${cmp12.mismatch?.key}": ${cmp12.mismatch?.a} vs ${cmp12.mismatch?.b}`,
);
}
if (!cmp23.ok) {
throw new Error(
`[${id}] run2 != run3 at "${cmp23.mismatch?.key}": ${cmp23.mismatch?.a} vs ${cmp23.mismatch?.b}`,
);
}
// Pin the analysisMode + converged flag too — the underlying
// analysis pick shouldn't flap.
expect(run1.analysisMode).toBe(run3.analysisMode);
expect(run1.converged).toBe(run3.converged);
});
}
it('solveInput state does not leak between unrelated examples', { timeout: 30_000 }, async () => {
// Solve example A, then B, then A again — assert A's two solves
// produced the same result despite B in between. Catches
// singleton-state bugs in the NgSpiceNodeAdapter.
const a = findExample('an-voltage-divider')!;
const b = findExample('an-bjt-switch')!;
const a1 = await solveInput(exampleToBuildNetlistInput(a));
await solveInput(exampleToBuildNetlistInput(b));
const a2 = await solveInput(exampleToBuildNetlistInput(a));
const cmp = deepEqualWithTolerance(a1.nodeVoltages, a2.nodeVoltages);
if (!cmp.ok) {
throw new Error(
`state leaked: ${cmp.mismatch?.key} = ${cmp.mismatch?.a}${cmp.mismatch?.b}`,
);
}
});
});