velxio/frontend/src/__tests__/ngspice-node-adapter.test.ts

104 lines
3.2 KiB
TypeScript
Raw Normal View History

feat(sim): Phase 1c F1 — NgSpiceNodeAdapter runs real WASM in Node tests Loads the vendored ngspice-interactive WASM directly in the Vitest Node process, no Web Worker required. Implements the same SolverPort contract as NgSpiceWorkerAdapter, so production code and tests share ONE solver — closing the "no hybrid" gap. loadNgSpiceForNode (Node-only loader): - Reads ngspice-lib.js as text, wraps with a hoisted `var Module = config` so the emscripten singleton picks up our locateFile + callbacks. - Re-wires Module.onRuntimeInitialized to copy closure-local FS / HEAP* into Module._velxio_* (the vendored build doesn't export them via EXPORTED_RUNTIME_METHODS so direct Module.FS triggers an abort accessor). NgSpiceNodeAdapter: - bindApi (cwrap), registerCallbacks (no-op via addFunction), stageFilesystem (recursive mkdir + writeFile of model .cm + spinit), initialiseNgspice (null callback pointers; the build still solves fine without print/data hooks). - loadCircuit writes the netlist to /circuit.spc on the FS and issues `source /circuit.spc` — sidesteps `_malloc` (not exported by this build) that the obvious ngSpice_Circ path would need. - solve() dispatches op/tran/ac, reads requested vectors via ngGet_Vec_Info using the actual struct offsets verified against the live build dump: flags=8, realdata=12, imagdata=16, length=20. - alterSource issues `alter` for incremental re-solves. 5/5 SolverPort contract tests pass against real ngspice: - init idempotent - DC op solves a 100Ω/100Ω divider → V(mid) = 2.5 V exactly - omits requested vectors that don't exist - alterSource changes V1 → V(mid) tracks the new voltage - transient RC charge (τ=1ms) reaches >4.5V after 5τ Next: F2 — migrate the ~22 test files that use eecircuit-engine via `runNetlist` to this adapter. After F2, F3 deletes eecircuit-engine and `SpiceEngine.ts` for good. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:49:39 +07:00
/**
* NgSpiceNodeAdapter integration test — runs the SAME SolverPort
* contract suite as solver-port-contract.test.ts but against the
* real WASM running in the Vitest Node process.
*
* If this passes, we have proof that production code and tests can
* share a single solver path (the "no hybrid" rule).
*
* Test cases include a real DC operating-point solve so we know
* the netlist pipeline + readVec extraction actually work.
*/
import { describe, it, expect, afterAll } from 'vitest';
import { NgSpiceNodeAdapter } from '../simulation/spice/adapters/NgSpiceNodeAdapter';
const adapter = new NgSpiceNodeAdapter();
afterAll(() => {
adapter.dispose();
});
describe('NgSpiceNodeAdapter — SolverPort contract on real WASM', () => {
it('init is idempotent', { timeout: 30_000 }, async () => {
await adapter.init();
await adapter.init();
// No throw.
});
it(
'loads + solves an .op on a simple divider, returns the expected v(net)',
{ timeout: 30_000 },
async () => {
const netlist = [
'* simple divider',
'V1 1 0 DC 5',
'R1 1 mid 100',
'R2 mid 0 100',
'.end',
].join('\n');
await adapter.loadCircuit(netlist);
const result = await adapter.solve(
{ kind: 'op' },
{ vectorsOfInterest: ['v(1)', 'v(mid)'] },
);
expect(result.vectors.size).toBeGreaterThanOrEqual(2);
expect(result.vectors.get('v(1)')?.real[0]).toBeCloseTo(5, 3);
expect(result.vectors.get('v(mid)')?.real[0]).toBeCloseTo(2.5, 3);
},
);
it(
'omits missing vectors silently',
{ timeout: 30_000 },
async () => {
const result = await adapter.solve(
{ kind: 'op' },
{ vectorsOfInterest: ['v(1)', 'v(does_not_exist)'] },
);
expect(result.vectors.has('v(1)')).toBe(true);
expect(result.vectors.has('v(does_not_exist)')).toBe(false);
},
);
it(
'alterSource updates a V source and the next solve sees the new value',
{ timeout: 30_000 },
async () => {
// Same divider as before — V1 is still loaded.
await adapter.alterSource('V1', 10);
const result = await adapter.solve(
{ kind: 'op' },
{ vectorsOfInterest: ['v(mid)'] },
);
expect(result.vectors.get('v(mid)')?.real[0]).toBeCloseTo(5, 3);
},
);
it(
'transient analysis returns a time axis + samples for an RC step',
{ timeout: 30_000 },
async () => {
// Single step at t=0 — uic forces V_cap(0)=0, then DC source
// charges through R into C. τ = R·C = 1k·1u = 1 ms.
const netlist = [
'* RC charge from a DC step',
'V1 1 0 DC 5',
'R1 1 mid 1k',
'C1 mid 0 1u IC=0',
'.end',
].join('\n');
await adapter.loadCircuit(netlist);
const result = await adapter.solve(
{ kind: 'tran', step: '1e-5', stop: '5e-3' },
{ vectorsOfInterest: ['v(mid)'] },
);
expect(result.timeAxis.length).toBeGreaterThan(10);
expect(result.vectors.get('v(mid)')?.real.length).toBe(result.timeAxis.length);
// After 5τ, vmid ≈ 0.993·5 ≈ 4.97 V.
const lastV = result.vectors.get('v(mid)')?.real.at(-1) ?? 0;
expect(lastV).toBeGreaterThan(4.5);
expect(lastV).toBeLessThan(5.1);
},
);
});