feat(planning): explain and trace planning decisions
This commit is contained in:
1 parent
609b3dece9
commit
d7a13b0b1e
24 files changed
+6073
-342
No files matched your search
@@ -1,5 +1,10 @@
|
||||
import { vi } from 'vitest';
|
||||
import { PlanningOutcome, PlanningRequest, planLeave } from './leave-planner.service';
|
||||
import {
|
||||
PlanningOutcome,
|
||||
PlanningRequest,
|
||||
createPlanningCalculation,
|
||||
planLeave,
|
||||
} from './leave-planner.service';
|
||||
|
||||
const horizon = { start: '2024-01-01', end: '2025-01-01' };
|
||||
const balance = (
|
||||
@@ -1113,6 +1118,43 @@ describe('planLeave transport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not charge reported trace instrumentation time to the planning deadline', async () => {
|
||||
const expected = isPlan(
|
||||
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
|
||||
);
|
||||
class TraceAwareWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
postMessage(): void {
|
||||
this.onmessage?.({ data: { type: 'trace-overhead', totalMs: 30 } } as MessageEvent);
|
||||
setTimeout(() => {
|
||||
this.onmessage?.({
|
||||
data: { type: 'result', outcome: expected },
|
||||
} as MessageEvent);
|
||||
this.onmessage?.({
|
||||
data: { type: 'artifacts', artifacts: {} },
|
||||
} as MessageEvent);
|
||||
}, 15);
|
||||
}
|
||||
|
||||
terminate(): void {}
|
||||
}
|
||||
vi.stubGlobal('Worker', TraceAwareWorker);
|
||||
try {
|
||||
expect(
|
||||
await planLeave({
|
||||
horizon,
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
deadlineMs: 10,
|
||||
}),
|
||||
).toEqual(expected);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends a detached, callback-free request to the worker transport', async () => {
|
||||
const sourceDate = new Date('2024-01-01T00:00:00Z');
|
||||
const request = {
|
||||
@@ -1197,3 +1239,602 @@ describe('planLeave transport', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('planning calculation context', () => {
|
||||
it('keeps the planning outcome identical when trace instrumentation is enabled', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [balance('cp', 'cp', 3, '2024-01-31')],
|
||||
rules: [],
|
||||
};
|
||||
const withoutTrace = core.calculatePlanningInternal(request);
|
||||
let traceStatus: string | undefined;
|
||||
const withTrace = core.calculatePlanningInternal(request, undefined, (artifacts) => {
|
||||
traceStatus = artifacts.trace?.status;
|
||||
});
|
||||
|
||||
expect(withTrace).toEqual(withoutTrace);
|
||||
expect(traceStatus).toBe('ready');
|
||||
});
|
||||
|
||||
it('keeps an immutable submitted snapshot beside the outcome', async () => {
|
||||
const request = {
|
||||
horizon,
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
labels: { cp: 'Paid leave' },
|
||||
};
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation(request);
|
||||
request.balances[0].quantity = 99;
|
||||
request.labels.cp = 'Changed later';
|
||||
|
||||
expect(calculation.snapshot.balances[0].quantity).toBe(1);
|
||||
expect(calculation.snapshot.labels['cp']).toBe('Paid leave');
|
||||
expect((await calculation.outcome).kind).toBe('Plan');
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('materializes a chronological explanation after the unchanged outcome', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-15' },
|
||||
balances: [
|
||||
{
|
||||
...balance('cp', 'cp', 1, '2026-01-01'),
|
||||
name: 'Paid leave entitlement',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'winter',
|
||||
name: 'Winter break',
|
||||
type: 'time_window',
|
||||
startDate: '2024-01-02',
|
||||
endDate: '2024-01-12',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
labels: { winter: 'Winter break' },
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
expect('explanation' in outcome).toBe(false);
|
||||
const explanation = await calculation.explanation;
|
||||
expect(explanation?.kind).toBe('PlanExplanation');
|
||||
if (explanation?.kind !== 'PlanExplanation') return;
|
||||
expect(explanation.placements).toHaveLength(outcome.placements.length);
|
||||
expect(explanation.placements[0].balance?.name).toBe('Paid leave entitlement');
|
||||
expect(explanation.placements[0].ruleIds).toContain('winter');
|
||||
expect(explanation.placements[0].objectiveVector.objectives.length).toBeGreaterThan(1);
|
||||
expect('score' in explanation.placements[0].objectiveVector).toBe(false);
|
||||
expect(explanation.placements[0].firstDecisiveObjective).toBe(
|
||||
explanation.placements[0].comparison.firstDecisiveObjective,
|
||||
);
|
||||
expect(explanation.placements[0].firstDecisiveObjective).toBe('preference:winter');
|
||||
const alternatives = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
expect(alternatives.kind).toBe('Counterfactual');
|
||||
if (alternatives.kind === 'Counterfactual')
|
||||
expect(
|
||||
alternatives.results.find(
|
||||
(result) => result.date === outcome.placements[0].date,
|
||||
)?.comparison?.firstDecisiveObjective,
|
||||
).toBe('preference:winter');
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports every rule contribution when one placement is shared', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-06' },
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [
|
||||
{
|
||||
id: 'wide',
|
||||
name: 'Wide window',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-02',
|
||||
endDate: '2024-01-05',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
{
|
||||
id: 'exact',
|
||||
name: 'Exact day',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-05',
|
||||
endDate: '2024-01-05',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
const explanation = await calculation.explanation;
|
||||
expect(explanation?.kind).toBe('PlanExplanation');
|
||||
if (explanation?.kind !== 'PlanExplanation') return;
|
||||
expect(
|
||||
explanation.placements[0].contributions
|
||||
.filter((contribution) => contribution.kind === 'rule')
|
||||
.map((contribution) => contribution.ruleId),
|
||||
).toEqual(['exact', 'wide']);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('compares the selected placement across the half-open horizon without changing the plan', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2024-12-31')],
|
||||
rules: [],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
const before = structuredClone(outcome);
|
||||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
expect(analysis.kind).toBe('Counterfactual');
|
||||
if (analysis.kind !== 'Counterfactual') return;
|
||||
expect(analysis.results).toHaveLength(9);
|
||||
expect(analysis.results.some((result) => result.reason?.code === 'non-working')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
analysis.results.some(
|
||||
(result) => result.status === 'eligible' && result.comparison,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(outcome).toEqual(before);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves supporting provenance in counterfactual placements', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-05', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2025-01-01')],
|
||||
rules: [],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
const selected = outcome.placements.find((placement) => placement.supporting);
|
||||
expect(selected).toBeDefined();
|
||||
if (!selected) return;
|
||||
|
||||
const analysis = await calculation.analyzeCounterfactual(selected);
|
||||
expect(analysis.kind).toBe('Counterfactual');
|
||||
if (analysis.kind !== 'Counterfactual') return;
|
||||
const restored = analysis.results.find((result) => result.date === selected.date);
|
||||
expect(
|
||||
restored?.comparison?.after.objectives.find(
|
||||
(objective) => objective.id === 'supporting-placements',
|
||||
)?.value,
|
||||
).toBe(1);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('discards counterfactual work from an older calculation', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const older = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
});
|
||||
const olderOutcome = await older.outcome;
|
||||
expect(olderOutcome.kind).toBe('Plan');
|
||||
if (olderOutcome.kind !== 'Plan') return;
|
||||
createPlanningCalculation({ horizon, balances: [], rules: [] });
|
||||
|
||||
const analysis = await older.analyzeCounterfactual(olderOutcome.placements[0]);
|
||||
expect(analysis.kind).toBe('CounterfactualStale');
|
||||
expect(analysis.results).toEqual([]);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps earned-entitlement availability and provenance in counterfactuals', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-04-25', end: '2024-05-15' },
|
||||
balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 0, '2026-01-01')],
|
||||
rules: [
|
||||
{
|
||||
id: 'wednesday-holidays',
|
||||
type: 'if_then',
|
||||
force: 'obligation',
|
||||
dayOfWeek: 3,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'after',
|
||||
rewardDays: 1,
|
||||
rewardType: 'rtt',
|
||||
},
|
||||
{
|
||||
id: 'thursday-consumer',
|
||||
type: 'if_then',
|
||||
force: 'obligation',
|
||||
dayOfWeek: 4,
|
||||
placeDays: 1,
|
||||
placeType: 'rtt',
|
||||
placement: 'before',
|
||||
rewardDays: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
const earned = outcome.placements.find((placement) =>
|
||||
placement.balanceId.startsWith('earned:'),
|
||||
);
|
||||
expect(earned).toBeDefined();
|
||||
if (!earned) return;
|
||||
const analysis = await calculation.analyzeCounterfactual(earned);
|
||||
expect(analysis.kind).toBe('Counterfactual');
|
||||
if (analysis.kind !== 'Counterfactual') return;
|
||||
expect(analysis.placement).toEqual(earned);
|
||||
expect(
|
||||
analysis.results.find((result) => result.date === '2024-04-25')?.reason?.code,
|
||||
).toBe('unavailable');
|
||||
const trace = await calculation.trace;
|
||||
const earnedBalanceDelta = trace?.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.flatMap((decision) => decision.deltas)
|
||||
.find((delta) => delta.balance?.id === earned.balanceId);
|
||||
expect(earnedBalanceDelta?.balance).toMatchObject({
|
||||
id: earned.balanceId,
|
||||
type: earned.type,
|
||||
earned: true,
|
||||
});
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('replays a trace decision from its pre-decision state', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-01-31')],
|
||||
rules: [
|
||||
{
|
||||
id: 'wide',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
const decision = trace.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.find((candidate) =>
|
||||
candidate.deltas.some((delta) => delta.kind === 'placement-added'),
|
||||
);
|
||||
const placement = decision?.deltas.find(
|
||||
(delta) => delta.kind === 'placement-added',
|
||||
)?.placement;
|
||||
expect(decision).toBeDefined();
|
||||
expect(placement).toBeDefined();
|
||||
if (!decision || !placement) return;
|
||||
const laterPlacement = outcome.placements.find(
|
||||
(candidate) => candidate.date !== placement.date,
|
||||
);
|
||||
expect(laterPlacement).toBeDefined();
|
||||
if (!laterPlacement) return;
|
||||
|
||||
const analysis = await calculation.analyzeCounterfactual({
|
||||
decisionId: decision.id,
|
||||
placement,
|
||||
});
|
||||
expect(analysis.kind).toBe('Counterfactual');
|
||||
if (analysis.kind !== 'Counterfactual') return;
|
||||
expect(
|
||||
analysis.results.find((result) => result.date === laterPlacement.date)?.status,
|
||||
).toBe('eligible');
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('reconstructs the returned plan from the original trace deltas', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [balance('cp', 'cp', 4, '2024-01-31')],
|
||||
rules: [],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
expect(trace?.status).toBe('ready');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
|
||||
const placements = new Map(
|
||||
trace.initialState.placements.map((placement) => [placement.date, placement]),
|
||||
);
|
||||
for (const delta of trace.phases.flatMap((phase) =>
|
||||
phase.decisions.flatMap((decision) => decision.deltas),
|
||||
)) {
|
||||
if (delta.previousPlacement) placements.delete(delta.previousPlacement.date);
|
||||
if (delta.placement) placements.set(delta.placement.date, delta.placement);
|
||||
}
|
||||
expect([...placements.values()].sort((a, b) => a.date.localeCompare(b.date))).toEqual(
|
||||
outcome.placements,
|
||||
);
|
||||
expect(trace.phases.map((phase) => phase.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'obligations',
|
||||
'at-risk',
|
||||
'holiday-week-value',
|
||||
'non-working-day-gain',
|
||||
'longest-work-sequence',
|
||||
'short-sequence-deficit',
|
||||
'supporting-placements',
|
||||
'expiry',
|
||||
'dates',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
trace.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.flatMap((decision) => decision.candidates).length,
|
||||
).toBeGreaterThan(20);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('records loaded-plan pruning and full quality comparisons without truncation', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-04-01' },
|
||||
balances: [balance('cp', 'cp', 25, '2024-03-31')],
|
||||
rules: [],
|
||||
});
|
||||
expect((await calculation.outcome).kind).toBe('Plan');
|
||||
const trace = await calculation.trace;
|
||||
const candidates =
|
||||
trace?.phases.flatMap((phase) =>
|
||||
phase.decisions.flatMap((decision) => decision.candidates),
|
||||
) ?? [];
|
||||
expect(
|
||||
candidates.some(
|
||||
(candidate) =>
|
||||
candidate.status === 'not-examined' &&
|
||||
candidate.reason?.code === 'loaded-plan-sampling',
|
||||
),
|
||||
).toBe(true);
|
||||
const comparison = candidates.find((candidate) => candidate.comparison)?.comparison;
|
||||
expect(comparison?.before.objectives.length).toBeGreaterThan(1);
|
||||
expect(comparison?.after.objectives.length).toBe(comparison?.before.objectives.length);
|
||||
expect(
|
||||
candidates.some((candidate) => candidate.comparison && candidate.sourceDate),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('offers no explanation trace for invalid input and explains conflicts', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const invalidCalculation = createPlanningCalculation({
|
||||
horizon: { start: '2025-01-01', end: '2024-01-01' },
|
||||
balances: [],
|
||||
rules: [],
|
||||
});
|
||||
expect((await invalidCalculation.outcome).kind).toBe('InvalidInput');
|
||||
expect(await invalidCalculation.explanation).toBeUndefined();
|
||||
expect(await invalidCalculation.trace).toBeUndefined();
|
||||
|
||||
const conflictCalculation = createPlanningCalculation({
|
||||
horizon,
|
||||
balances: [balance('rtt', 'rtt', 1)],
|
||||
rules: [
|
||||
{
|
||||
id: 'cp-only',
|
||||
name: 'Paid leave only',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-04-01',
|
||||
endDate: '2024-04-05',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect((await conflictCalculation.outcome).kind).toBe('PlanningConflict');
|
||||
const explanation = await conflictCalculation.explanation;
|
||||
expect(explanation?.kind).toBe('ConflictExplanation');
|
||||
if (explanation?.kind === 'ConflictExplanation')
|
||||
expect(explanation.conflictingRules).toEqual([
|
||||
{ id: 'cp-only', label: 'Paid leave only' },
|
||||
]);
|
||||
const trace = await conflictCalculation.trace;
|
||||
expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('marks interrupted and unexecuted phases when no incumbent exists', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon,
|
||||
balances: [balance('cp', 'cp', 5)],
|
||||
rules: [],
|
||||
deadlineMs: 0,
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('PlanningIncomplete');
|
||||
const explanation = await calculation.explanation;
|
||||
expect(explanation?.kind).toBe('IncompleteExplanation');
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.some((phase) => phase.status === 'interrupted')).toBe(true);
|
||||
expect(trace?.phases.some((phase) => phase.status === 'not-run')).toBe(true);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('explains an incomplete incumbent without reusing the expired planning deadline', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
|
||||
const outcome = core.calculatePlanningInternal(
|
||||
{
|
||||
horizon,
|
||||
balances: Array.from({ length: 12 }, (_, index) =>
|
||||
balance(`balance-${index}`, index % 2 ? 'rtt' : 'cp', 12),
|
||||
),
|
||||
rules: [],
|
||||
deadlineMs: 20,
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome.kind).toBe('PlanningIncomplete');
|
||||
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||
expect(outcome.incumbent).toBeDefined();
|
||||
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
|
||||
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
|
||||
expect(artifacts.explanation.incomplete?.reason.code).toBe('deadline');
|
||||
expect(artifacts.explanation.placements).toHaveLength(
|
||||
outcome.incumbent?.placements.length ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it('publishes the worker outcome before delayed explanation artifacts', async () => {
|
||||
const expected = isPlan(
|
||||
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
|
||||
);
|
||||
const trace = {
|
||||
status: 'ready' as const,
|
||||
initialState: {
|
||||
horizon,
|
||||
balances: [],
|
||||
placements: [],
|
||||
earnedActivations: [],
|
||||
},
|
||||
phases: [],
|
||||
completedStages: [],
|
||||
};
|
||||
class DelayedArtifactsWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
postMessage(): void {
|
||||
queueMicrotask(() =>
|
||||
this.onmessage?.({
|
||||
data: { type: 'result', outcome: expected },
|
||||
} as MessageEvent),
|
||||
);
|
||||
setTimeout(
|
||||
() =>
|
||||
this.onmessage?.({
|
||||
data: { type: 'artifacts', artifacts: { trace } },
|
||||
} as MessageEvent),
|
||||
20,
|
||||
);
|
||||
}
|
||||
|
||||
terminate(): void {}
|
||||
}
|
||||
vi.stubGlobal('Worker', DelayedArtifactsWorker);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon,
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
});
|
||||
let traceReady = false;
|
||||
void calculation.trace.then(() => (traceReady = true));
|
||||
expect((await calculation.outcome).kind).toBe('Plan');
|
||||
expect(traceReady).toBe(false);
|
||||
expect((await calculation.trace)?.status).toBe('ready');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a valid outcome when trace delivery fails afterward', async () => {
|
||||
const expected = isPlan(
|
||||
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
|
||||
);
|
||||
class FailingArtifactsWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
postMessage(): void {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: { type: 'result', outcome: expected },
|
||||
} as MessageEvent);
|
||||
queueMicrotask(() => this.onerror?.());
|
||||
});
|
||||
}
|
||||
|
||||
terminate(): void {}
|
||||
}
|
||||
vi.stubGlobal('Worker', FailingArtifactsWorker);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon,
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
});
|
||||
expect(await calculation.outcome).toEqual(expected);
|
||||
expect((await calculation.trace)?.status).toBe('failed');
|
||||
expect((await calculation.explanationError)?.code).toBe('worker-error');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user