pcbjam/tests/kicad/ngspice-probe.spec.ts

176 lines
8.2 KiB
TypeScript
Raw Normal View History

import { test, expect } from './fixtures';
/**
* Minimal ngspice_service probe (no eeschema boot): drive the sharedspice RPC
* surface directly from the page. Isolates worker/module behavior from the
* editor entirely when the simulator breaks, this answers "service module
* or editor-side bridge?" in seconds. The provider arrives via the fixtures'
* ambient init script (tests/kicad/utils/ngspice-service.ts), which also
* captures every event frame into window.__ngspiceEvents.
*
* Covers Gate 2 of docs/features/ngspice-split/: browser-side parity of the
* Gate-1 node smoke foreground transient numerics, XSPICE via the static
* code-model registry, CIDER, live event streaming during bg_run, mid-run
* bg_halt, and the lazy-load boundary.
*/
type SvcRes = {
ret?: number; error?: string; found?: boolean; length?: number;
real?: number[] | Float64Array | null; name?: string; names?: string[];
running?: boolean;
};
async function svcRequest(page: import('@playwright/test').Page, req: unknown): Promise<SvcRes> {
return await page.evaluate(async (r: any) => {
const res = await (globalThis as any).ngspiceService.request(r);
// Float64Array doesn't survive evaluate serialization on all engines —
// flatten to a plain array (probe vectors are small).
if (res && res.real) res.real = Array.from(res.real);
if (res && res.comp) res.comp = Array.from(res.comp);
return res;
}, req as any);
}
test.describe('ngspice_service probe', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(240000);
test('lazy boundary + RC transient + vector readback', async ({ page }) => {
const ngspiceFetches: string[] = [];
page.on('request', (r) => {
if (r.url().includes('ngspice_service')) ngspiceFetches.push(r.url());
});
// Any harness page gives the COI (COOP/COEP) context; don't wait for wasm.
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
expect(ngspiceFetches, 'ngspice_service must NOT be fetched before first use')
.toHaveLength(0);
const init = await svcRequest(page, { kind: 'init' });
expect(init.error, 'init error').toBeUndefined();
expect(init.ret, 'ngSpice_Init').toBe(0);
expect(ngspiceFetches.length, 'ngspice_service was fetched lazily by init')
.toBeGreaterThan(0);
const circ = await svcRequest(page, {
kind: 'circ',
lines: ['rc probe', 'V1 in 0 1', 'R1 in out 1k', 'C1 out 0 1u',
'.tran 10u 5m', '.end'],
});
expect(circ.ret, 'ngSpice_Circ').toBe(0);
const run = await svcRequest(page, { kind: 'command', cmd: 'run' });
expect(run.ret, 'run command').toBe(0);
const plot = await svcRequest(page, { kind: 'cur_plot' });
expect(plot.name, 'a tran plot exists').toMatch(/^tran/);
const vecs = await svcRequest(page, { kind: 'all_vecs', plot: plot.name! });
expect(vecs.names, 'tran vectors').toContain('out');
const vi = await svcRequest(page, { kind: 'get_vec_info', name: 'out' });
expect(vi.found, 'v(out) found').toBe(true);
expect(vi.length ?? 0, 'plausible point count').toBeGreaterThan(100);
const last = (vi.real as number[])[(vi.length ?? 1) - 1];
// DC operating point seeds the transient at the steady state: flat 1V.
expect(last, 'v(out) end value').toBeGreaterThan(0.98);
expect(last, 'v(out) end value').toBeLessThanOrEqual(1.0);
});
test('XSPICE code model resolves through the static registry', async ({ page }) => {
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
await svcRequest(page, { kind: 'init' });
const circ = await svcRequest(page, {
kind: 'circ',
lines: ['xspice probe', 'V1 in 0 2', 'A1 in aout gainblk',
'.model gainblk gain(gain=3)', 'R1 aout 0 1k', '.op', '.end'],
});
expect(circ.ret, 'circ with a-device').toBe(0);
expect((await svcRequest(page, { kind: 'command', cmd: 'run' })).ret).toBe(0);
const vi = await svcRequest(page, { kind: 'get_vec_info', name: 'aout' });
expect(vi.found, 'v(aout) found').toBe(true);
expect((vi.real as number[])[0], 'gain block output 2*3').toBeCloseTo(6.0, 3);
});
test('CIDER numd device simulates', async ({ page }) => {
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
await svcRequest(page, { kind: 'init' });
const circ = await svcRequest(page, {
kind: 'circ',
lines: ['cider probe - silicon resistor',
'VPP 1 0 2v', 'VNN 2 0 0.0v', 'D1 1 2 M_RES AREA=1',
'.MODEL M_RES numd level=1',
'+ options resistor defa=1p',
'+ x.mesh loc=0.0 num=1', '+ x.mesh loc=1.0 num=21',
'+ domain num=1 material=1', '+ material num=1 silicon',
'+ doping unif n.type conc=2.5e16',
'+ models bgn srh conctau auger concmob fieldmob',
'.DC VPP 0.0v 2.01v 0.5v', '.END'],
});
expect(circ.ret, 'circ with numd model').toBe(0);
expect((await svcRequest(page, { kind: 'command', cmd: 'run' })).ret).toBe(0);
const vi = await svcRequest(page, { kind: 'get_vec_info', name: 'vpp#branch' });
expect(vi.found, 'sweep current vector found').toBe(true);
expect(vi.length ?? 0, 'DC sweep points').toBeGreaterThanOrEqual(4);
const iLast = (vi.real as number[])[(vi.length ?? 1) - 1];
expect(iLast, 'resistor draws current (negative through VPP)').toBeLessThan(0);
expect(Math.abs(iLast), 'plausible magnitude').toBeLessThan(1.0);
});
test('bg_run streams events live and bg_halt stops mid-run', async ({ page, testLogger }) => {
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
await svcRequest(page, { kind: 'init' });
// Heavy enough that the halt lands mid-run: 150-stage nonlinear
// RC/diode ladder, 20s transient, storage bounded via .save.
const deck = ['halt probe', 'V1 n0 0 SIN(0 5 10k)'];
for (let i = 0; i < 150; i++) {
deck.push(`R${i + 1} n${i} n${i + 1} 100`);
deck.push(`C${i + 1} n${i + 1} 0 10n`);
deck.push(`D${i + 1} n${i + 1} 0 dmod`);
}
deck.push('.model dmod d(is=1e-14)', '.save v(n150)', '.tran 100n 20', '.end');
expect((await svcRequest(page, { kind: 'circ', lines: deck })).ret).toBe(0);
const evtsBefore = await page.evaluate(
() => (window as any).__ngspiceEvents.length as number);
expect((await svcRequest(page, { kind: 'command', cmd: 'bg_run' })).ret,
'bg_run accepted').toBe(0);
// Live streaming: char/stat frames must arrive WHILE the background
// thread simulates (not only after completion).
await page.waitForFunction((n: number) => {
const evts = (window as any).__ngspiceEvents as Array<{ kind: string }>;
return evts.slice(n).filter((e) => e.kind === 'char' || e.kind === 'stat').length >= 3;
}, evtsBefore, { timeout: 60000 });
const midRunning = await svcRequest(page, { kind: 'running' });
expect(midRunning.running,
'still running while events streamed (deck heavy enough)').toBe(true);
expect((await svcRequest(page, { kind: 'command', cmd: 'bg_halt' })).ret,
'bg_halt accepted').toBe(0);
// BGThreadRunning(finished) must arrive after the halt joins the thread.
await page.waitForFunction((n: number) => {
const evts = (window as any).__ngspiceEvents as Array<{ kind: string; finished?: boolean }>;
return evts.slice(n).some((e) => e.kind === 'bg' && e.finished === true);
}, evtsBefore, { timeout: 60000 });
const after = await svcRequest(page, { kind: 'running' });
expect(after.running, 'stopped after bg_halt').toBe(false);
// Standard corruption gate.
const all = [...testLogger.consoleLogs, ...testLogger.errors];
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
});
});