feat(planning): make optimization rules composable

Evaluate explicit, ordered optimization configurations through one shared
planner model. Add six editable presets, durable one-time legacy migration,
and consistent completed and cancelled plan provenance.

Include functional regressions and the agreed trace-removal and benchmark
follow-up specs. Performance acceptance remains in #78 under unchanged gates.

Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
This commit is contained in:
nessar committed 2026-10-10 16:07:34 +02:00
1 parent 209bf05f36
commit 6811142299
45 files changed
+8795 -3075

No files matched your search

+340 -222
View File
@@ -1,3 +1,4 @@
import { createOptimizationPresetRules } from './rule-service';
import { vi } from 'vitest';
import {
applyReplayDeltas,
@@ -15,6 +16,18 @@ import type {
PlanningTraceUpdate,
} from './leave-planner.service';
import type { CounterfactualBaselineOptions } from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
const supportingEconomy = {
id: 'economy',
type: 'optimization' as const,
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
};
const nonWorkingGain = {
id: 'gain',
type: 'optimization' as const,
configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain,
};
const horizon = { start: '2024-01-01', end: '2025-01-01' };
const balance = (
@@ -48,6 +61,8 @@ async function runInNonBrowser(request: Parameters<typeof planLeave>[0]) {
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('calculated work-sequence and time-off-period statistics', () => {
it('keeps fractional work means and merges consecutive declared days across a weekend', async () => {
const result = isPlan(
@@ -1292,7 +1307,7 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: { start: '2024-01-02', end: '2024-01-06' },
balances: [balance('remaining-cp', 'cp', 4, '2024-01-05')],
rules: [],
rules: [presets[0]],
declaredDays,
}),
);
@@ -1358,7 +1373,7 @@ describe('planLeave', () => {
balances: [
{ ...balance('cp', 'cp', 1, '2024-05-13'), availableFrom: '2024-05-13' },
],
rules: [],
rules: [presets[0]],
declaredDays,
}),
);
@@ -1438,7 +1453,7 @@ describe('planLeave', () => {
balances: [
{ ...balance('cp', 'cp', 2, '2024-01-04'), availableFrom: '2024-01-04' },
],
rules: [],
rules: [presets[0]],
declaredDays,
}),
);
@@ -1466,7 +1481,7 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: selectedHorizon,
balances: [],
rules: [],
rules: [presets[0]],
declaredDays: declaredDays.filter(({ date }) => date !== '2024-01-03'),
}),
);
@@ -1521,7 +1536,7 @@ describe('planLeave', () => {
const result = isPlan(
await runInNonBrowser({
horizon: { start, end },
rules: [],
rules: [presets[0]],
declaredDays,
balances: mixed
? [{ ...balance('cp', 'cp', 1, '2024-05-10'), availableFrom: '2024-05-10' }]
@@ -1602,7 +1617,7 @@ describe('planLeave', () => {
const request: PlanningRequest = {
horizon: { start: '2026-11-01', end: '2026-12-01' },
balances: [balance('cp', 'cp', 2, '2026-11-30')],
rules: [],
rules: presets,
declaredDays: [
{ date: '2026-11-09', type: 'other' },
{ date: '2026-11-10', type: 'rtt' },
@@ -1619,15 +1634,19 @@ describe('planLeave', () => {
await runInNonBrowser({
...request,
rules: [
{
id: 'preferred-friday',
type: 'time_window',
force: 'preference',
startDate: '2026-11-20',
endDate: '2026-11-20',
placeDays: 1,
placeType: 'cp',
},
presets[0],
...[
{
id: 'preferred-friday',
type: 'time_window',
force: 'preference',
startDate: '2026-11-20',
endDate: '2026-11-20',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
}),
);
@@ -1927,7 +1946,7 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon,
balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 1)],
rules,
rules: [presets[0], ...rules],
}),
);
@@ -2015,15 +2034,18 @@ describe('planLeave', () => {
},
],
rules: [
{
id: 'rtt-obligation',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-03',
placeDays: 1,
placeType: 'rtt',
},
presets[0],
...[
{
id: 'rtt-obligation',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-03',
placeDays: 1,
placeType: 'rtt',
},
],
],
}),
);
@@ -2256,20 +2278,28 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: { start: '2024-01-05', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2025-01-01')],
rules: [],
rules: [nonWorkingGain, supportingEconomy],
}),
);
const supporting = result.placements.find((placement) => placement.supporting);
expect(supporting?.ruleIds).toEqual([]);
expect(supporting?.ruleIds).toEqual(['gain']);
expect(supporting?.balanceId).toBe('cp');
});
it('does not trade a short-sequence deficit for supporting placement gain', async () => {
it('does not trade an earlier short-sequence deficit for supporting placement gain', async () => {
const result = isPlan(
await runInNonBrowser({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp-four', 'cp', 4, '2025-01-01')],
rules: [],
rules: [
{
id: 'deficit',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit,
},
nonWorkingGain,
supportingEconomy,
],
}),
);
expect(result.metrics.shortSequenceDeficit).toBe(0);
@@ -2280,7 +2310,15 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: { start: '2024-01-01', end: '2024-01-29' },
balances: [balance('cp-six', 'cp', 6, '2025-01-01')],
rules: [],
rules: [
nonWorkingGain,
{
id: 'longest',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.longestWorkSequence,
},
supportingEconomy,
],
}),
);
expect(result.metrics.supportingPlacements).toBe(
@@ -2295,19 +2333,24 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: { start: '2024-02-01', end: '2024-03-01' },
balances: [balance('cp', 'cp', 2, '2024-02-29')],
rules: [],
rules: presets,
}),
);
const fourDayPlan = isPlan(
await runInNonBrowser({
horizon: { start: '2024-02-01', end: '2024-03-01' },
balances: [balance('cp', 'cp', 4, '2024-02-29')],
rules: [],
rules: presets,
}),
);
expect(twoDayPlan.metrics).toMatchObject({
nonWorkingDayGain: 4,
longestWorkSequence: 9,
shortSequenceDeficit: 0,
});
expect(twoDayPlan.placements.map((placement) => placement.date)).toEqual([
'2024-02-09',
'2024-02-19',
'2024-02-02',
'2024-02-16',
]);
expect(fourDayPlan.placements.map((placement) => placement.date)).toEqual([
'2024-02-02',
@@ -2335,7 +2378,7 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: holidayHorizon,
balances: [balance('cp', 'cp', 4, expiryDate)],
rules: [],
rules: presets,
}),
);
@@ -2344,7 +2387,7 @@ describe('planLeave', () => {
expect(result.timeOffPeriods[0].duration).toBe(9);
});
// Async calculation yields to the test timer; allow the planner's ten-second budget.
// This full configured year is measured separately; allow instrumentation overhead without a planning deadline.
it('uses the 11 November and 14 July weeks in the representative default plan', async () => {
const result = isPlan(
await runInNonBrowser({
@@ -2364,28 +2407,32 @@ describe('planLeave', () => {
},
],
rules: [
{
id: 'default-samedi-malin',
name: 'Samedi Malin',
type: 'if_then',
force: 'preference',
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'other',
},
{
id: 'default-christmas',
name: 'Christmas holidays',
type: 'time_window',
force: 'preference',
startDate: '2026-12-20',
endDate: '2027-01-03',
placeDays: 5,
placeType: 'any',
},
presets[0],
...[
{
id: 'default-samedi-malin',
name: 'Samedi Malin',
type: 'if_then',
force: 'preference',
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'other',
},
{
id: 'default-christmas',
name: 'Christmas holidays',
type: 'time_window',
force: 'preference',
startDate: '2026-12-20',
endDate: '2027-01-03',
placeDays: 5,
placeType: 'any',
},
],
...presets.slice(1),
],
}),
);
@@ -2402,7 +2449,7 @@ describe('planLeave', () => {
'2027-07-16',
]),
);
}, 15_000);
}, 120_000);
it('moves a satisfied time-window preference to complete a holiday week', async () => {
const result = isPlan(
@@ -2410,15 +2457,19 @@ describe('planLeave', () => {
horizon: { start: '2027-07-01', end: '2027-08-01' },
balances: [balance('cp', 'cp', 4, '2027-07-31')],
rules: [
{
id: 'july',
type: 'time_window',
force: 'preference',
startDate: '2027-07-01',
endDate: '2027-07-31',
placeDays: 4,
placeType: 'any',
},
presets[0],
...[
{
id: 'july',
type: 'time_window',
force: 'preference',
startDate: '2027-07-01',
endDate: '2027-07-31',
placeDays: 4,
placeType: 'any',
},
],
...presets.slice(1),
],
}),
);
@@ -2437,7 +2488,14 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: { start: '2027-07-01', end: '2027-08-01' },
balances: [balance('cp', 'cp', 4, '2028-07-31')],
rules: [],
rules: [
{
id: 'holiday',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
},
supportingEconomy,
],
}),
);
@@ -2456,15 +2514,19 @@ describe('planLeave', () => {
horizon: { start: '2024-01-08', end: '2024-01-15' },
balances: [balance('cp', 'cp', 1)],
rules: [
{
id: 'window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-09',
endDate: '2024-01-12',
placeDays: 1,
placeType: 'cp',
},
presets[0],
...[
{
id: 'window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-09',
endDate: '2024-01-12',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
}),
);
@@ -2749,15 +2811,19 @@ describe('planLeave', () => {
},
],
rules: [
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'cp',
},
presets[0],
...[
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
};
const expected = isPlan(await runInNonBrowser(request));
@@ -2767,7 +2833,8 @@ describe('planLeave', () => {
const outcome = await runInNonBrowser({
...request,
progress: (stage) => {
if (stage === 'at-risk') queueMicrotask(() => (elapsed = 20_000));
if (stage === 'optimization:default-unplaced-at-risk')
queueMicrotask(() => (elapsed = 20_000));
},
});
expect(elapsed).toBeGreaterThan(10_000);
@@ -2953,8 +3020,8 @@ describe('planLeave transport', () => {
if (expected.kind !== 'Plan' || !checkpointState) return;
checkpoint = {
...expected,
completedStages: ['validation', 'obligations', 'at-risk'],
lastCompletedStage: 'at-risk',
completedStages: ['validation', 'obligations', 'optimization:default-unplaced-at-risk'],
lastCompletedStage: 'optimization:default-unplaced-at-risk',
};
const initialState: PlanningTraceState = {
horizon: request.horizon,
@@ -3271,7 +3338,7 @@ describe('planning calculation context', () => {
const calculation = createPlanningCalculation(submitted);
submitted.declaredDays![0].date = '2024-01-11';
submitted.declaredDays![0].type = 'rtt';
submitted.rules[0].placeDays = 4;
if ('placeDays' in submitted.rules[0]) submitted.rules[0].placeDays = 4;
if ('endDate' in submitted.rules[0]) submitted.rules[0].endDate = '2024-01-19';
submitted.horizon.end = '2024-01-11';
submitted.labels = { exact: 'Edited later' };
@@ -3378,7 +3445,7 @@ describe('planning calculation context', () => {
balances: [
{ ...balance('cp', 'cp', 1, '2024-12-30'), availableFrom: '2024-12-30' },
],
rules: [],
rules: [presets[0]],
declaredDays,
});
const outcome = isPlan(await calculation.outcome);
@@ -3407,7 +3474,7 @@ describe('planning calculation context', () => {
const submitted: PlanningRequest = {
horizon: { start: '2026-11-01', end: '2026-12-01' },
balances: [balance('cp', 'cp', 2, '2026-11-30')],
rules: [],
rules: presets,
declaredDays: [
{ date: '2026-11-09', type: 'other' },
{ date: '2026-11-10', type: 'rtt' },
@@ -3427,7 +3494,9 @@ describe('planning calculation context', () => {
for (const placement of explanation.placements) {
expect(placement.timeOffPeriod).toEqual(outcome.timeOffPeriods[0]);
expect(placement.finalMetrics).toEqual(outcome.metrics);
expect(placement.firstDecisiveObjective).toBe('unplaced-at-risk');
expect(placement.firstDecisiveObjective).toBe(
'optimization:default-unplaced-at-risk',
);
}
const trace = await calculation.trace;
expect(trace?.initialState.declaredDays).toEqual(original);
@@ -3446,11 +3515,13 @@ describe('planning calculation context', () => {
(candidate) =>
candidate.comparison?.after.objectives.some(
(objective) =>
objective.id === 'holiday-week-value' && objective.value === 3,
objective.id === 'optimization:default-holiday-week-value' &&
objective.value === 3,
) &&
candidate.comparison.after.objectives.some(
(objective) =>
objective.id === 'non-working-day-gain' && objective.value === 5,
objective.id === 'optimization:default-non-working-day-gain' &&
objective.value === 5,
),
),
).toBe(true);
@@ -3463,10 +3534,16 @@ describe('planning calculation context', () => {
const alternative = analysis.results.find(({ date }) => date === '2026-11-20');
expect(alternative?.status).toBe('eligible');
expect(alternative?.comparison?.after.objectives).toContainEqual(
expect.objectContaining({ id: 'holiday-week-value', value: 0 }),
expect.objectContaining({
id: 'optimization:default-holiday-week-value',
value: 0,
}),
);
expect(alternative?.comparison?.after.objectives).toContainEqual(
expect.objectContaining({ id: 'non-working-day-gain', value: 7 }),
expect.objectContaining({
id: 'optimization:default-non-working-day-gain',
value: 7,
}),
);
expect(outcome).toEqual(before);
} finally {
@@ -3481,7 +3558,7 @@ describe('planning calculation context', () => {
const submitted: PlanningRequest = {
horizon: { start: '2024-01-02', end: '2024-02-01' },
balances: [balance('cp', 'cp', 4, '2024-01-05')],
rules: [],
rules: [presets[0]],
declaredDays: [
{ date: '2024-01-02', type: 'cp' },
{ date: '2024-01-03', type: 'rtt' },
@@ -3828,7 +3905,7 @@ describe('planning calculation context', () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-12-31')],
rules: [],
rules: [presets[3]],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
@@ -3886,7 +3963,7 @@ describe('planning calculation context', () => {
if (!firstOccurrence || !secondOccurrence) return;
const alternateSecondDate =
secondOccurrence.date === '2024-05-07' ? '2024-05-09' : '2024-05-07';
secondOccurrence.date === '2024-05-07' ? '2024-05-10' : '2024-05-07';
const analysis = await calculation.analyzeCounterfactual(firstOccurrence);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
@@ -3976,7 +4053,7 @@ describe('planning calculation context', () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-05', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2025-01-01')],
rules: [],
rules: [nonWorkingGain, supportingEconomy],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
@@ -3991,7 +4068,7 @@ describe('planning calculation context', () => {
const restored = analysis.results.find((result) => result.date === selected.date);
expect(
restored?.comparison?.after.objectives.find(
(objective) => objective.id === 'supporting-placements',
(objective) => objective.id === 'optimization:economy',
)?.value,
).toBe(1);
} finally {
@@ -4097,7 +4174,7 @@ describe('planning calculation context', () => {
const older = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1)],
rules: [],
rules: [presets[3]],
});
const olderOutcome = await older.outcome;
expect(olderOutcome.kind).toBe('Plan');
@@ -4116,7 +4193,7 @@ describe('planning calculation context', () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1)],
rules: [],
rules: [presets[3]],
};
const expected = isPlan(await runInNonBrowser(request));
const selected = expected.placements[0];
@@ -4213,7 +4290,7 @@ describe('planning calculation context', () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1)],
rules: [],
rules: [presets[3]],
};
const expected = isPlan(await runInNonBrowser(request));
const trace = {
@@ -4343,15 +4420,18 @@ describe('planning calculation context', () => {
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',
},
presets[0],
...[
{
id: 'wide',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-01',
endDate: '2024-01-31',
placeDays: 1,
placeType: 'cp',
},
],
],
});
const outcome = await calculation.outcome;
@@ -4404,7 +4484,7 @@ describe('planning calculation context', () => {
availableFrom: '2024-01-04',
},
],
rules: [],
rules: [presets[0]],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
@@ -4466,7 +4546,7 @@ describe('planning calculation context', () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1)],
rules: [],
rules: [presets[3]],
};
const plan = isPlan(await runInNonBrowser(request));
const firstPlacement = plan.placements[0];
@@ -4745,81 +4825,98 @@ describe('planning calculation context', () => {
});
it('does not inherit unrelated rule-invalid history into a trace decision', 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-01-31')],
rules: [
{
id: 'early-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-04',
placeDays: 1,
placeType: 'cp',
},
{
id: 'late-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-03',
endDate: '2024-01-05',
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 candidates = trace.phases.flatMap((phase) =>
phase.decisions.flatMap((item) => item.candidates),
);
const decisionDates = new Set(
decision.candidates.flatMap(
(candidate) => candidate.dates ?? (candidate.date ? [candidate.date] : []),
const core = await import('./leave-planner.core');
const request: PlanningRequest = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-01-10')],
rules: [presets[0]],
};
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
request,
undefined,
(value) => (artifacts = value),
);
expect(outcome.kind).toBe('Plan');
const trace = artifacts.trace && {
...artifacts.trace,
phases: [...artifacts.trace.phases],
};
artifacts = { ...artifacts, trace };
if (outcome.kind !== 'Plan' || !trace)
throw new Error('Expected an actual plan and trace.');
const decision = trace.phases
.flatMap((phase) => phase.decisions)
.find((item) => item.deltas.some((delta) => delta.kind === 'placement-added'));
const placement = decision?.deltas.find(
(delta) => delta.kind === 'placement-added',
)?.placement;
if (!decision || !placement) throw new Error('Expected an actual placement decision.');
// An unrelated decision's rejection must not become the selected decision's history.
const unrelatedDate = '2024-01-04';
decision.candidates = decision.candidates.filter(
(candidate) =>
!(candidate.dates ?? (candidate.date ? [candidate.date] : [])).includes(
unrelatedDate,
),
);
const unrelatedIneligible = candidates.find(
(candidate) =>
candidate.status === 'ineligible' &&
candidate.reason?.code === 'rule-invalid' &&
candidate.date &&
!decisionDates.has(candidate.date),
);
expect(unrelatedIneligible).toBeDefined();
if (!unrelatedIneligible?.date) return;
);
trace.phases.push({
id: 'unrelated-phase',
name: 'unrelated',
completed: true,
status: 'completed',
decisions: [
{
id: 'unrelated-decision',
action: 'reject',
dates: [unrelatedDate],
deltas: [],
candidates: [
{
status: 'ineligible',
date: unrelatedDate,
reason: {
code: 'rule-invalid',
message: 'Another decision rejected this date.',
},
},
],
},
],
});
class HistoricalTraceWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(): void {
queueMicrotask(() => {
this.onmessage?.({ data: { type: 'result', outcome } } as MessageEvent);
this.onmessage?.({ data: { type: 'artifacts', artifacts } } as MessageEvent);
});
}
terminate(): void {}
}
const browserWindow = globalThis.window;
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', HistoricalTraceWorker);
try {
const calculation = createPlanningCalculation(request);
await calculation.trace;
vi.stubGlobal('window', undefined);
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 === unrelatedIneligible.date)
?.historicalStatus,
).toBe('not-examined');
expect(analysis.results.find((result) => result.date === unrelatedDate)).toMatchObject({
historicalStatus: 'not-examined',
historicalReason: undefined,
});
expect(
analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus,
).toBe('ineligible');
} finally {
vi.unstubAllGlobals();
vi.stubGlobal('window', browserWindow);
}
});
@@ -4831,7 +4928,7 @@ describe('planning calculation context', () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 4, '2024-01-31')],
rules: [],
rules: presets,
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
@@ -4854,12 +4951,12 @@ describe('planning calculation context', () => {
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',
'optimization:default-unplaced-at-risk',
'optimization:default-holiday-week-value',
'optimization:default-non-working-day-gain',
'optimization:default-longest-work-sequence',
'optimization:default-short-sequence-deficit',
'optimization:default-supporting-placements',
'expiry',
'dates',
]),
@@ -4881,7 +4978,7 @@ describe('planning calculation context', () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-04-01' },
balances: [balance('cp', 'cp', 25, '2024-03-31')],
rules: [],
rules: presets,
});
expect((await calculation.outcome).kind).toBe('Plan');
const trace = await calculation.trace;
@@ -4907,16 +5004,18 @@ describe('planning calculation context', () => {
);
expect(chosenComparisons.length).toBeGreaterThan(0);
for (const { decision, candidate: chosen } of chosenComparisons) {
const changedDates = chosen.dates ?? (chosen.date ? [chosen.date] : []);
expect(
decision.deltas.some(
(delta) =>
(delta.kind === 'placement-moved' &&
delta.toDate === chosen.date &&
delta.balanceId === chosen.placement?.balanceId) ||
changedDates.includes(delta.toDate ?? '')) ||
(delta.kind === 'placement-added' &&
delta.date === chosen.date &&
delta.balanceId === chosen.placement?.balanceId) ||
(delta.kind === 'placement-removed' && delta.date === chosen.date),
changedDates.includes(delta.date ?? '')) ||
(delta.kind === 'placement-removed' &&
changedDates.includes(delta.date ?? '')) ||
(delta.kind === 'placement-updated' &&
changedDates.includes(delta.date ?? '')),
),
).toBe(true);
}
@@ -4942,7 +5041,7 @@ describe('planning calculation context', () => {
}
});
it('records every eligible balance when a preference keeps the first assignment', async () => {
it('evaluates every eligible balance and retains the earlier expiry for an equal preference', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
@@ -4980,10 +5079,12 @@ describe('planning calculation context', () => {
status: candidate.status,
reason: candidate.reason?.code,
})) ?? [];
expect(candidates.slice(0, 2)).toEqual([
{ balanceId: 'earlier', status: 'chosen', reason: undefined },
{ balanceId: 'later', status: 'not-examined', reason: 'first-compatible-balance' },
]);
expect(candidates).toEqual(
expect.arrayContaining([
{ balanceId: 'earlier', status: 'chosen', reason: undefined },
{ balanceId: 'later', status: 'evaluated-rejected', reason: undefined },
]),
);
} finally {
vi.stubGlobal('window', browserWindow);
}
@@ -5026,7 +5127,11 @@ describe('planning calculation context', () => {
{ id: 'cp-only', label: 'Paid leave only' },
]);
const trace = await conflictCalculation.trace;
expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false);
expect(
trace?.phases.some(
(phase) => phase.name === 'optimization:default-holiday-week-value',
),
).toBe(false);
} finally {
vi.stubGlobal('window', browserWindow);
}
@@ -5056,28 +5161,41 @@ describe('planning calculation context', () => {
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,
);
let clock = 0;
let expired = false;
const clockSpy = vi.spyOn(performance, 'now').mockImplementation(() => {
if (expired) clock += 100;
return clock;
});
try {
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: presets,
deadlineMs: 20,
progress: (stage: string) => {
if (stage === `optimization:${presets[0].id}`) expired = true;
},
},
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,
);
} finally {
clockSpy.mockRestore();
}
});
it('publishes the worker outcome before delayed explanation artifacts', async () => {