velxio/frontend/src/__tests__/solver-port-contract.test.ts

138 lines
5.3 KiB
TypeScript

/**
* Contract tests for the SolverPort interface.
*
* Any adapter (real or fake) must satisfy these invariants. Today
* `FakeSolverAdapter` is the SUT; once `NgSpiceNodeAdapter` lands
* (sub-step F1) it will run the same suite to confirm it honours the
* port contract exactly the same way.
*
* Contract:
* - init() is idempotent
* - loadCircuit() can be called multiple times (replaces previous)
* - solve() returns a SolveResult with vectors keyed by lower-case name
* - solve() ignores requested vectors that aren't in the engine state
* - alterSource() doesn't return data
* - dispose() is the final call; subsequent uses are caller's bug
*/
import { describe, it, expect } from 'vitest';
import { FakeSolverAdapter } from '../simulation/spice/adapters/FakeSolverAdapter';
describe('SolverPort contract — FakeSolverAdapter', () => {
it('init() is idempotent and counted per call', async () => {
const fake = new FakeSolverAdapter();
await fake.init();
await fake.init();
await fake.init();
expect(fake.calls.init).toBe(3);
});
it('loadCircuit() records every submission', async () => {
const fake = new FakeSolverAdapter();
await fake.loadCircuit('first netlist');
await fake.loadCircuit('second netlist');
expect(fake.calls.loadCircuit).toEqual(['first netlist', 'second netlist']);
});
it('solve() returns SolveResult with requested vectors', async () => {
const fake = new FakeSolverAdapter({
vectors: { 'v(n0)': 4.97, 'v(n1)': 0.1, 'i(v_sense)': 0.005 },
});
const result = await fake.solve(
{ kind: 'op' },
{ vectorsOfInterest: ['v(n0)', 'v(n1)', 'i(v_sense)'] },
);
expect(result.analysis).toEqual({ kind: 'op' });
expect(result.vectors.get('v(n0)')?.real[0]).toBeCloseTo(4.97);
expect(result.vectors.get('v(n1)')?.real[0]).toBeCloseTo(0.1);
expect(result.vectors.get('i(v_sense)')?.real[0]).toBeCloseTo(0.005);
expect(result.warnings).toEqual([]);
});
it('solve() omits requested vectors that are missing from engine state', async () => {
const fake = new FakeSolverAdapter({ vectors: { 'v(present)': 1.5 } });
const result = await fake.solve(
{ kind: 'op' },
{ vectorsOfInterest: ['v(present)', 'v(missing)'] },
);
expect(result.vectors.has('v(present)')).toBe(true);
expect(result.vectors.has('v(missing)')).toBe(false);
});
it('solve() normalises vector names to lower-case', async () => {
const fake = new FakeSolverAdapter({ vectors: { 'v(out)': 3.3 } });
const result = await fake.solve({ kind: 'op' }, { vectorsOfInterest: ['V(OUT)'] });
expect(result.vectors.has('v(out)')).toBe(true);
expect(result.vectors.has('V(OUT)')).toBe(false);
});
it('solve() honours dynamic vector supplier', async () => {
let voltage = 1.0;
const fake = new FakeSolverAdapter({
vectors: () => ({ 'v(out)': voltage }),
});
const r1 = await fake.solve({ kind: 'op' }, { vectorsOfInterest: ['v(out)'] });
expect(r1.vectors.get('v(out)')?.real[0]).toBeCloseTo(1.0);
voltage = 4.5;
const r2 = await fake.solve({ kind: 'op' }, { vectorsOfInterest: ['v(out)'] });
expect(r2.vectors.get('v(out)')?.real[0]).toBeCloseTo(4.5);
});
it('alterSource() records calls and invokes onAlter hook', async () => {
const fake = new FakeSolverAdapter();
let lastAlter: [string, number] | null = null;
fake.onAlter = (name, value) => {
lastAlter = [name, value];
};
await fake.alterSource('V_uno_9', 5);
expect(fake.calls.alterSource).toEqual([['V_uno_9', 5]]);
expect(lastAlter).toEqual(['V_uno_9', 5]);
});
it('exposes a .tran-shaped result when requested', async () => {
const fake = new FakeSolverAdapter({
vectors: { 'v(out)': new Float64Array([0, 1, 2, 3, 4]) },
timeAxis: new Float64Array([0, 1e-4, 2e-4, 3e-4, 4e-4]),
});
const result = await fake.solve(
{ kind: 'tran', step: '1e-4', stop: '4e-4' },
{ vectorsOfInterest: ['v(out)'] },
);
expect(result.timeAxis.length).toBe(5);
expect(result.vectors.get('v(out)')?.real.length).toBe(5);
expect(result.vectors.get('v(out)')?.real[3]).toBe(3);
});
it('solve records all calls with the analysis + vectorsOfInterest', async () => {
const fake = new FakeSolverAdapter();
await fake.solve({ kind: 'op' }, { vectorsOfInterest: ['v(a)'] });
await fake.solve(
{ kind: 'tran', step: '1u', stop: '10m' },
{ vectorsOfInterest: ['v(b)', 'i(v1)'] },
);
expect(fake.calls.solve).toEqual([
{ analysis: { kind: 'op' }, vectorsOfInterest: ['v(a)'] },
{
analysis: { kind: 'tran', step: '1u', stop: '10m' },
vectorsOfInterest: ['v(b)', 'i(v1)'],
},
]);
});
it('respects solveDelayMs (race-condition tests can use this)', async () => {
const fake = new FakeSolverAdapter({ solveDelayMs: 50 });
const t0 = performance.now();
await fake.solve({ kind: 'op' }, { vectorsOfInterest: [] });
expect(performance.now() - t0).toBeGreaterThanOrEqual(45);
});
it('dispose() is counted; resetCalls() clears the log', async () => {
const fake = new FakeSolverAdapter();
fake.dispose();
fake.dispose();
expect(fake.calls.dispose).toBe(2);
fake.resetCalls();
expect(fake.calls.dispose).toBe(0);
expect(fake.calls.init).toBe(0);
});
});