Replace Vitest with bun test across the whole suite. A preload compiles components through Angular's JIT transform with inlined templates, sets up jsdom, TestBed and zoneless change detection, and a small shim covers the Vitest APIs bun:test lacks. Files run isolated, real-worker specs spawn the running Bun, and line coverage from lcov is converted to the existing HTML and Cobertura reports. The CI benchmark replaces its heap cap with a container memory limit. Vitest, Jest, Nx and their Vite configuration are removed. Refs #291 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
5.0 KiB
TypeScript
173 lines
5.0 KiB
TypeScript
import { vi } from '../../testing/vitest-compat';
|
|
import {
|
|
createPlanningCalculation,
|
|
type LeavePlacement,
|
|
type PlanningCalculationContext,
|
|
} from './leave-planner.service';
|
|
|
|
const request = { horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [], rules: [] };
|
|
const plan = {
|
|
kind: 'Plan',
|
|
placements: [{ date: '2024-01-05', balanceId: 'cp' } as unknown as LeavePlacement],
|
|
completedStages: [],
|
|
};
|
|
const state = { marker: 'state' };
|
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
class FakeWorker {
|
|
static all: FakeWorker[] = [];
|
|
static constructThrows = false;
|
|
static postThrows = false;
|
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
terminate = vi.fn();
|
|
postMessage = vi.fn(() => {
|
|
if (this.throwOnPost) throw new Error('post failed');
|
|
});
|
|
private throwOnPost = FakeWorker.postThrows;
|
|
constructor() {
|
|
if (FakeWorker.constructThrows) throw new Error('no worker');
|
|
FakeWorker.all.push(this);
|
|
}
|
|
emit(data: unknown) {
|
|
this.onmessage?.({ data } as MessageEvent);
|
|
}
|
|
}
|
|
|
|
interface Started {
|
|
worker?: FakeWorker;
|
|
succeeded: Promise<boolean>;
|
|
deliver: () => void;
|
|
late: () => void;
|
|
}
|
|
type Flags = { construct?: boolean; post?: boolean };
|
|
const arm = (flags: Flags) => {
|
|
FakeWorker.constructThrows = !!flags.construct;
|
|
FakeWorker.postThrows = !!flags.post;
|
|
};
|
|
|
|
const jobs: Record<string, (flags: Flags) => Promise<Started>> = {
|
|
plan: async (flags) => {
|
|
arm(flags);
|
|
const progress = vi.fn();
|
|
const calculation = createPlanningCalculation({ ...request, progress });
|
|
const worker = FakeWorker.all[0];
|
|
return {
|
|
worker,
|
|
succeeded: calculation.outcome.then((outcome) => outcome.kind === 'Plan'),
|
|
deliver: () => {
|
|
worker?.emit({ type: 'result', outcome: plan });
|
|
worker?.emit({ type: 'artifacts', artifacts: {} });
|
|
},
|
|
late: () => {
|
|
worker?.emit({ type: 'progress', stage: 'late' });
|
|
expect(progress).not.toHaveBeenCalled();
|
|
},
|
|
};
|
|
},
|
|
counterfactual: async (flags) => {
|
|
const calculation = createPlanningCalculation(request);
|
|
FakeWorker.all[0].emit({ type: 'result', outcome: plan });
|
|
FakeWorker.all[0].emit({ type: 'artifacts', artifacts: {} });
|
|
await calculation.timings;
|
|
arm(flags);
|
|
const analysis = (calculation as PlanningCalculationContext).analyzeCounterfactual(
|
|
plan.placements[0],
|
|
);
|
|
const succeeded = analysis.then(
|
|
(result) => result.kind !== 'CounterfactualStale',
|
|
() => false,
|
|
);
|
|
await flush();
|
|
const worker = FakeWorker.all[1];
|
|
return {
|
|
worker,
|
|
succeeded,
|
|
deliver: () =>
|
|
worker?.emit({ type: 'counterfactual-result', analysis: { results: [] } }),
|
|
late: () => worker?.emit({ type: 'error', message: 'late' }),
|
|
};
|
|
},
|
|
'explain-checkpoint': async (flags) => {
|
|
const calculation = createPlanningCalculation(request);
|
|
const first = FakeWorker.all[0];
|
|
first.emit({ type: 'checkpoint', plan, state });
|
|
arm(flags);
|
|
first.emit({ type: 'error', message: 'planner crashed' });
|
|
const worker = FakeWorker.all[1];
|
|
return {
|
|
worker,
|
|
succeeded: calculation.explanation.then((explanation) => explanation !== undefined),
|
|
deliver: () =>
|
|
worker?.emit({
|
|
type: 'explanation',
|
|
explanation: { kind: 'IncompleteExplanation' },
|
|
}),
|
|
late: () => worker?.emit({ type: 'error', message: 'late' }),
|
|
};
|
|
},
|
|
};
|
|
|
|
describe('worker job lifetime', () => {
|
|
beforeEach(() => {
|
|
FakeWorker.all = [];
|
|
arm({});
|
|
vi.stubGlobal('window', {});
|
|
vi.stubGlobal('Worker', FakeWorker);
|
|
});
|
|
afterEach(() => {
|
|
arm({});
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe.each(Object.keys(jobs))('%s job', (name) => {
|
|
const start = jobs[name];
|
|
const released = (worker: FakeWorker) => {
|
|
expect(worker.terminate).toHaveBeenCalledOnce();
|
|
expect(worker.onmessage).toBeNull();
|
|
expect(worker.onerror).toBeNull();
|
|
};
|
|
|
|
it('releases the worker once on success and leaves no handler for late messages', async () => {
|
|
const job = await start({});
|
|
job.deliver();
|
|
expect(await job.succeeded).toBe(true);
|
|
released(job.worker!);
|
|
job.late();
|
|
job.deliver();
|
|
released(job.worker!);
|
|
});
|
|
|
|
it('fails without a worker when construction throws', async () => {
|
|
const job = await start({ construct: true });
|
|
expect(job.worker).toBeUndefined();
|
|
expect(await job.succeeded).toBe(false);
|
|
});
|
|
|
|
it('releases the worker once when postMessage throws', async () => {
|
|
const job = await start({ post: true });
|
|
expect(await job.succeeded).toBe(false);
|
|
released(job.worker!);
|
|
});
|
|
|
|
it('releases the worker once on a runtime error', async () => {
|
|
const job = await start({});
|
|
job.worker!.onerror!();
|
|
expect(await job.succeeded).toBe(false);
|
|
released(job.worker!);
|
|
job.deliver();
|
|
expect(await job.succeeded).toBe(false);
|
|
released(job.worker!);
|
|
});
|
|
|
|
it('releases the worker once when a newer calculation supersedes it', async () => {
|
|
const job = await start({});
|
|
createPlanningCalculation(request);
|
|
expect(await job.succeeded).toBe(false);
|
|
released(job.worker!);
|
|
job.deliver();
|
|
released(job.worker!);
|
|
});
|
|
});
|
|
});
|