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:
1 parent
209bf05f36
commit
6811142299
45 files changed
+8795
-3075
No files matched your search
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user