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:
parent
1770d51ccd
commit
bed2bd90ef
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue