diff --git a/frontend/src/__tests__/part-simulators-coverage.test.ts b/frontend/src/__tests__/part-simulators-coverage.test.ts new file mode 100644 index 00000000..1a1b8ca1 --- /dev/null +++ b/frontend/src/__tests__/part-simulators-coverage.test.ts @@ -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)[RAF_KEY]; +const originalCAF = (globalThis as Record)[CAF_KEY]; + +beforeAll(() => { + (globalThis as Record)[RAF_KEY] = (cb: FrameRequestCallback): number => { + return setTimeout(() => cb(performance.now()), 0) as unknown as number; + }; + (globalThis as Record)[CAF_KEY] = (id: number): void => { + clearTimeout(id as unknown as NodeJS.Timeout); + }; +}); + +afterAll(() => { + (globalThis as Record)[RAF_KEY] = originalRAF; + (globalThis as Record)[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 void>>(); + const pwmListeners = new Map 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 = {}; + const handlers = new Map>(); + 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)[key]; + return properties[key as string]; + }, + set(target, key, value) { + if (key in target) { + (target as unknown as Record)[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[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(); + } + }, + ); +}); diff --git a/frontend/src/__tests__/solver-determinism.test.ts b/frontend/src/__tests__/solver-determinism.test.ts new file mode 100644 index 00000000..9da6cdae --- /dev/null +++ b/frontend/src/__tests__/solver-determinism.test.ts @@ -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, + b: Record, + 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: '', 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}`, + ); + } + }); +});