///
import type { Sim } from "./simulate.ts";
type MatcherContext = {
isNot?: boolean;
};
type MatcherResult = {
pass: boolean;
message: () => string;
};
type SimLike =
| Sim
| {
executed?: readonly string[];
status?: string;
};
function executedOf(received: SimLike): readonly string[] {
return Array.isArray(received.executed) ? received.executed : [];
}
function formatValue(value: unknown): string {
return JSON.stringify(value);
}
function hasSubsequence(actual: readonly string[], expected: readonly string[]): boolean {
let expectedIndex = 0;
for (const id of actual) {
if (id === expected[expectedIndex]) {
expectedIndex += 1;
}
if (expectedIndex === expected.length) {
return true;
}
}
return expectedIndex === expected.length;
}
export function toHaveExecuted(this: MatcherContext | void, received: SimLike, ids: string[]): MatcherResult {
const executed = executedOf(received);
const missing = ids.filter((id) => !executed.includes(id));
const pass = missing.length === 0;
return {
pass,
message: (): string =>
pass
? `Expected simulation not to have executed ${formatValue(ids)}, but actual executed nodes were ${formatValue(executed)}.`
: `Expected simulation to have executed ${formatValue(ids)}, but missing ids were ${formatValue(missing)}. Actual executed nodes were ${formatValue(executed)}.`,
};
}
export function toHaveExecutedInOrder(this: MatcherContext | void, received: SimLike, ids: string[]): MatcherResult {
const executed = executedOf(received);
const pass = hasSubsequence(executed, ids);
return {
pass,
message: (): string =>
pass
? `Expected simulation not to have executed in order ${formatValue(ids)}, but actual executed order was ${formatValue(executed)}.`
: `Expected simulation to have executed in order ${formatValue(ids)}, but actual executed order was ${formatValue(executed)}.`,
};
}
export function toHaveFinished(this: MatcherContext | void, received: SimLike): MatcherResult {
const pass = received.status === "finished";
return {
pass,
message: (): string =>
pass
? 'Expected simulation not to have finished, but status was "finished".'
: `Expected simulation to have finished, but status was ${formatValue(received.status)}.`,
};
}
export const simMatchers: {
toHaveExecuted: typeof toHaveExecuted;
toHaveExecutedInOrder: typeof toHaveExecutedInOrder;
toHaveFinished: typeof toHaveFinished;
} = { toHaveExecuted, toHaveExecutedInOrder, toHaveFinished };
// Augment `bun:test`'s `Matchers` interface so `expect(sim).toHaveExecuted(...)`
// et al. typecheck after `expect.extend(simMatchers)`. This lives in a tsup
// entry (`matchers.ts`) so the augmentation is re-emitted into `matchers.d.ts`
// on every build; a hand-authored `.d.ts` would be wiped by `clean-dts` and
// silently dropped from the published package. The augmentation applies wherever
// the matchers module is imported (i.e. wherever `simMatchers` is used).
declare module "bun:test" {
interface Matchers {
toHaveExecuted(ids: string[]): void;
toHaveExecutedInOrder(ids: string[]): void;
toHaveFinished(): void;
}
}
// Compile-time guard (fully erased at runtime): fails `pnpm -C packages/testing
// typecheck` — which covers `src/**/*` — if the augmentation above ever stops
// applying to `bun:test`'s `Matchers`, e.g. because a build step dropped it. This
// is what makes the type augmentation gated rather than silently ship-or-not.
type _Assert = T;
type _AugmentationApplies = _Assert<
import("bun:test").Matchers extends {
toHaveExecuted(ids: string[]): void;
toHaveExecutedInOrder(ids: string[]): void;
toHaveFinished(): void;
}
? true
: false
>;