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
@@ -0,0 +1,823 @@
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
calculatePlanningAsyncInternal,
|
||||
calculatePlanningInternal,
|
||||
type OptimizationPlanningRule,
|
||||
type PlanOutcome,
|
||||
type PlanningArtifacts,
|
||||
type PlanningRequest,
|
||||
type PlanningTraceUpdate,
|
||||
} from './leave-planner.core';
|
||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||
import { applyReplayDeltas, createPlanningCalculation } from './leave-planner.service';
|
||||
|
||||
const economy: OptimizationPlanningRule = {
|
||||
id: 'economy',
|
||||
name: 'Économiser les jours d’appoint',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||||
};
|
||||
const gain: OptimizationPlanningRule = {
|
||||
id: 'gain',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain,
|
||||
};
|
||||
const requested: PlanningRequest['rules'][number] = {
|
||||
id: 'requested',
|
||||
type: 'time_window',
|
||||
startDate: '2024-01-05',
|
||||
endDate: '2024-01-05',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
};
|
||||
const weekend: PlanningRequest = {
|
||||
horizon: { start: '2024-01-05', end: '2024-01-10' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
|
||||
rules: [],
|
||||
};
|
||||
const bundles: {
|
||||
name: string;
|
||||
rule: OptimizationPlanningRule;
|
||||
request: PlanningRequest;
|
||||
dates: string[];
|
||||
}[] = [
|
||||
{
|
||||
name: 'complete public-holiday week',
|
||||
rule: {
|
||||
id: 'holiday',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
|
||||
},
|
||||
request: {
|
||||
horizon: { start: '2026-11-09', end: '2026-11-14' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2027-01-01' }],
|
||||
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [],
|
||||
},
|
||||
dates: ['2026-11-12', '2026-11-13'],
|
||||
},
|
||||
{
|
||||
name: 'non-working-day gain',
|
||||
rule: gain,
|
||||
request: weekend,
|
||||
dates: ['2024-01-05'],
|
||||
},
|
||||
{
|
||||
name: 'maximum non-working gain of a joined period',
|
||||
rule: {
|
||||
...gain,
|
||||
configuration: { ...gain.configuration, aggregation: 'maximum' },
|
||||
},
|
||||
request: {
|
||||
horizon: { start: '2024-01-05', end: '2024-01-13' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2025-01-01' }],
|
||||
declaredDays: ['2024-01-05', '2024-01-12'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [],
|
||||
},
|
||||
dates: ['2024-01-08', '2024-01-09', '2024-01-10', '2024-01-11'],
|
||||
},
|
||||
{
|
||||
name: 'complete short internal work sequence',
|
||||
rule: {
|
||||
id: 'deficit',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
},
|
||||
request: {
|
||||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
|
||||
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [],
|
||||
},
|
||||
dates: ['2024-01-09', '2024-01-10', '2024-01-11'],
|
||||
},
|
||||
{
|
||||
name: 'tied longest work sequences',
|
||||
rule: {
|
||||
id: 'longest',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.longestWorkSequence,
|
||||
},
|
||||
request: {
|
||||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-02-01' }],
|
||||
declaredDays: [{ date: '2024-01-10', type: 'other' }],
|
||||
rules: [],
|
||||
},
|
||||
dates: ['2024-01-08', '2024-01-11'],
|
||||
},
|
||||
];
|
||||
|
||||
function plan(request: PlanningRequest): PlanOutcome {
|
||||
const outcome = calculatePlanningInternal(request);
|
||||
if (outcome.kind !== 'Plan') throw new Error(`Expected a plan, got ${outcome.kind}`);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
describe('supporting placement economy and provenance', () => {
|
||||
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it.each(bundles)(
|
||||
'compares the whole $name bundle against economy in either order',
|
||||
({ rule, request, dates }) => {
|
||||
const improve = plan({ ...request, rules: [rule, economy] });
|
||||
const preserve = plan({ ...request, rules: [economy, rule] });
|
||||
expect(improve.placements.map(({ date }) => date)).toEqual(dates);
|
||||
expect(improve.placements.every(({ supporting }) => supporting)).toBe(true);
|
||||
expect(improve.metrics.supportingPlacements).toBe(dates.length);
|
||||
expect(improve.remainingBalances).toEqual([]);
|
||||
expect(preserve.placements).toEqual([]);
|
||||
expect(preserve.metrics.supportingPlacements).toBe(0);
|
||||
expect(preserve.remainingBalances[0]).toMatchObject({
|
||||
id: 'cp',
|
||||
quantity: request.balances[0].quantity,
|
||||
classification: 'deferred',
|
||||
reason: { code: 'preserved' },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(bundles)(
|
||||
'makes disabled economy identical to absence for the $name bundle',
|
||||
({ rule, request }) => {
|
||||
const absent = plan({ ...request, rules: [rule] });
|
||||
for (const rules of [
|
||||
[{ ...economy, enabled: false }, rule],
|
||||
[rule, { ...economy, enabled: false }],
|
||||
])
|
||||
expect(plan({ ...request, rules })).toEqual(absent);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not add placements merely because economy is disabled or removed', () => {
|
||||
for (const rules of [[], [economy], [{ ...economy, enabled: false }]]) {
|
||||
const outcome = plan({ ...weekend, rules });
|
||||
expect(outcome.placements).toEqual([]);
|
||||
expect(outcome.remainingBalances[0].quantity).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('credits an earlier supporting placement to a later explicit window without counting an extra day', () => {
|
||||
const outcome = plan({
|
||||
...weekend,
|
||||
rules: [gain, economy, requested],
|
||||
});
|
||||
expect(outcome.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-01-05',
|
||||
balanceId: 'cp',
|
||||
ruleIds: ['gain', 'requested'],
|
||||
supporting: false,
|
||||
}),
|
||||
]);
|
||||
expect(outcome.metrics).toMatchObject({ nonWorkingDayGain: 2, supportingPlacements: 0 });
|
||||
expect(outcome.preferences[0].satisfied).toBe(1);
|
||||
});
|
||||
|
||||
it('exempts only the requested window quantity, including fixed declared days', () => {
|
||||
const outcome = plan({
|
||||
...weekend,
|
||||
horizon: { start: '2024-01-05', end: '2024-01-20' },
|
||||
balances: [{ ...weekend.balances[0], quantity: 3 }],
|
||||
declaredDays: [{ date: '2024-01-09', type: 'cp' }],
|
||||
rules: [gain, economy, { ...requested, endDate: '2024-01-19', placeDays: 2 }],
|
||||
});
|
||||
expect(outcome.placements.map(({ date, supporting }) => ({ date, supporting }))).toEqual([
|
||||
{ date: '2024-01-05', supporting: false },
|
||||
{ date: '2024-01-12', supporting: true },
|
||||
{ date: '2024-01-19', supporting: true },
|
||||
]);
|
||||
expect(outcome.metrics.supportingPlacements).toBe(2);
|
||||
expect(outcome.timeWindowContributions?.[0]).toMatchObject({
|
||||
requested: 2,
|
||||
declaredDays: [{ date: '2024-01-09', type: 'cp' }],
|
||||
placementDates: ['2024-01-05'],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'bounds explicit calendar credit before accepting further window improvements (declared: %s)',
|
||||
(declared) => {
|
||||
const windowRule = { ...requested, endDate: '2024-01-19' };
|
||||
const request: PlanningRequest = {
|
||||
...weekend,
|
||||
horizon: { start: '2024-01-05', end: '2024-01-20' },
|
||||
balances: [{ ...weekend.balances[0], quantity: 4 }],
|
||||
declaredDays: declared ? [{ date: '2024-01-09', type: 'cp' }] : [],
|
||||
rules: [economy, gain, windowRule],
|
||||
};
|
||||
const preserve = plan(request);
|
||||
expect(preserve.placements).toEqual(
|
||||
declared
|
||||
? []
|
||||
: [
|
||||
expect.objectContaining({
|
||||
date: '2024-01-05',
|
||||
ruleIds: ['requested'],
|
||||
supporting: false,
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(preserve.metrics.supportingPlacements).toBe(0);
|
||||
expect(preserve.preferences[0].satisfied).toBe(1);
|
||||
expect(preserve.remainingBalances[0]).toMatchObject({
|
||||
id: 'cp',
|
||||
quantity: declared ? 4 : 3,
|
||||
});
|
||||
const improve = plan({ ...request, rules: [gain, economy, windowRule] });
|
||||
expect(improve.placements.map(({ date }) => date)).toEqual([
|
||||
'2024-01-05',
|
||||
'2024-01-12',
|
||||
'2024-01-19',
|
||||
]);
|
||||
expect(
|
||||
improve.placements.filter(({ ruleIds }) => ruleIds.includes('requested')),
|
||||
).toHaveLength(declared ? 0 : 1);
|
||||
expect(improve.metrics.supportingPlacements).toBe(declared ? 3 : 2);
|
||||
expect(
|
||||
plan({ ...request, rules: [{ ...economy, enabled: false }, gain, windowRule] }),
|
||||
).toEqual(plan({ ...request, rules: [gain, windowRule] }));
|
||||
},
|
||||
);
|
||||
|
||||
it('restores supporting classification when a moved placement loses its explicit calendar request', () => {
|
||||
const outcome = plan({
|
||||
...weekend,
|
||||
horizon: { start: '2024-01-05', end: '2024-01-20' },
|
||||
balances: [{ ...weekend.balances[0], quantity: 4 }],
|
||||
rules: [
|
||||
gain,
|
||||
{ ...requested, startDate: '2024-01-08', endDate: '2024-01-09' },
|
||||
bundles[4].rule,
|
||||
economy,
|
||||
],
|
||||
});
|
||||
expect(outcome.metrics).toMatchObject({
|
||||
nonWorkingDayGain: 6,
|
||||
longestWorkSequence: 3,
|
||||
supportingPlacements: 3,
|
||||
});
|
||||
expect(outcome.placements.filter(({ supporting }) => !supporting)).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-01-08',
|
||||
ruleIds: ['gain', 'requested'],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['preference', 'obligation'] as const)(
|
||||
'keeps explicitly requested deferred days outside economy (%s)',
|
||||
(force) => {
|
||||
const outcome = plan({ ...weekend, rules: [economy, { ...requested, force }, gain] });
|
||||
expect(outcome.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-01-05',
|
||||
ruleIds: ['requested'],
|
||||
supporting: false,
|
||||
}),
|
||||
]);
|
||||
expect(outcome.metrics).toMatchObject({
|
||||
nonWorkingDayGain: 2,
|
||||
supportingPlacements: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('credits the complete explicit IF_THEN pattern without treating its deferred day as support', () => {
|
||||
const outcome = plan({
|
||||
horizon: { start: '2024-05-06', end: '2024-05-11' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
|
||||
rules: [
|
||||
gain,
|
||||
economy,
|
||||
{
|
||||
id: 'thursday',
|
||||
type: 'if_then',
|
||||
dayOfWeek: 4,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'after',
|
||||
rewardDays: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(outcome.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-05-10',
|
||||
ruleIds: ['gain', 'thursday'],
|
||||
supporting: false,
|
||||
}),
|
||||
]);
|
||||
expect(outcome.metrics.supportingPlacements).toBe(0);
|
||||
expect(outcome.preferences[0].satisfied).toBe(1);
|
||||
});
|
||||
|
||||
it.each(['preference', 'obligation'] as const)(
|
||||
'limits IF_THEN credit to its declared producer pattern when another complete pattern improves rest (%s)',
|
||||
(force) => {
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2024-05-06', end: '2024-05-11' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2025-01-01' }],
|
||||
declaredDays: [{ date: '2024-05-07', type: 'cp' }],
|
||||
rules: [
|
||||
economy,
|
||||
gain,
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
force,
|
||||
dayOfWeek: 3,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: 'rtt',
|
||||
},
|
||||
],
|
||||
};
|
||||
const preserve = plan(request);
|
||||
expect(preserve.placements).toEqual([]);
|
||||
expect(preserve.metrics.supportingPlacements).toBe(0);
|
||||
expect(preserve.remainingBalances).toEqual([
|
||||
expect.objectContaining({ id: 'cp', quantity: 4 }),
|
||||
expect.objectContaining({
|
||||
id: 'earned:producer:2024-05-08',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-05-10',
|
||||
}),
|
||||
]);
|
||||
expect(preserve.ifThenContributions?.[0]).toMatchObject({
|
||||
declaredDays: [{ date: '2024-05-07', type: 'cp' }],
|
||||
placementDates: [],
|
||||
earnedEntitlement: { availableFrom: '2024-05-10', added: true },
|
||||
});
|
||||
const improve = plan({ ...request, rules: [gain, economy, request.rules[2]] });
|
||||
expect(improve.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-05-06',
|
||||
ruleIds: ['gain'],
|
||||
supporting: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: '2024-05-10',
|
||||
ruleIds: ['gain'],
|
||||
supporting: true,
|
||||
}),
|
||||
]);
|
||||
expect(improve.metrics).toMatchObject({
|
||||
nonWorkingDayGain: 6,
|
||||
supportingPlacements: 2,
|
||||
});
|
||||
expect(improve.remainingBalances).toEqual([
|
||||
expect.objectContaining({ id: 'cp', quantity: 2 }),
|
||||
expect.objectContaining({
|
||||
id: 'earned:producer:2024-05-08',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-05-13',
|
||||
}),
|
||||
]);
|
||||
expect(improve.ifThenContributions?.[0]).toMatchObject({
|
||||
declaredDays: [{ date: '2024-05-07', type: 'cp' }],
|
||||
placementDates: [],
|
||||
earnedEntitlement: { availableFrom: '2024-05-13', added: true },
|
||||
});
|
||||
expect(
|
||||
plan({
|
||||
...request,
|
||||
rules: [{ ...economy, enabled: false }, gain, request.rules[2]],
|
||||
}),
|
||||
).toEqual(plan({ ...request, rules: [gain, request.rules[2]] }));
|
||||
},
|
||||
);
|
||||
|
||||
it('uses expiry then dates when active rules tie, without restoring a removed gain objective', () => {
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2024-01-09', end: '2024-01-13' },
|
||||
balances: [
|
||||
{ id: 'later', type: 'cp', quantity: 1, expiryDate: '2024-03-01' },
|
||||
{ id: 'earlier', type: 'cp', quantity: 1, expiryDate: '2024-02-01' },
|
||||
],
|
||||
rules: [economy, { ...requested, startDate: '2024-01-09', endDate: '2024-01-12' }],
|
||||
};
|
||||
const outcome = plan(request);
|
||||
expect(outcome.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-01-09',
|
||||
balanceId: 'earlier',
|
||||
supporting: false,
|
||||
}),
|
||||
]);
|
||||
expect(outcome.metrics.nonWorkingDayGain).toBe(0);
|
||||
expect(
|
||||
plan({ ...request, rules: [{ ...gain, enabled: false }, ...request.rules] }),
|
||||
).toEqual(outcome);
|
||||
});
|
||||
|
||||
it('reclassifies a deferred support addition when an eligible earlier-expiring balance replaces it', () => {
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
horizon: { start: '2024-05-06', end: '2024-05-11' },
|
||||
balances: [
|
||||
{ id: 'deferred', type: 'cp', quantity: 3, expiryDate: '2025-01-01' },
|
||||
{
|
||||
id: 'at-risk',
|
||||
type: 'rtt',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-05-07',
|
||||
expiryDate: '2024-05-07',
|
||||
},
|
||||
],
|
||||
rules: [bundles[0].rule, economy],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
|
||||
expect(
|
||||
outcome.placements.map(({ date, balanceId, supporting }) => ({
|
||||
date,
|
||||
balanceId,
|
||||
supporting,
|
||||
})),
|
||||
).toEqual([
|
||||
{ date: '2024-05-06', balanceId: 'deferred', supporting: true },
|
||||
{ date: '2024-05-07', balanceId: 'at-risk', supporting: false },
|
||||
{ date: '2024-05-10', balanceId: 'deferred', supporting: true },
|
||||
]);
|
||||
expect(outcome.metrics.supportingPlacements).toBe(2);
|
||||
expect(outcome.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 1 });
|
||||
expect(
|
||||
artifacts?.trace?.phases.flatMap(({ decisions }) =>
|
||||
decisions.flatMap(({ deltas }) => deltas),
|
||||
),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'placement-updated',
|
||||
previousPlacement: expect.objectContaining({
|
||||
balanceId: 'deferred',
|
||||
supporting: true,
|
||||
}),
|
||||
placement: expect.objectContaining({ balanceId: 'at-risk', supporting: false }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('compares new deferred support when at-risk holiday placements relocate into an explicit window', async () => {
|
||||
const holiday = bundles[0].rule;
|
||||
const early: PlanningRequest['rules'][number] = {
|
||||
id: 'early',
|
||||
type: 'time_window',
|
||||
startDate: '2026-11-02',
|
||||
endDate: '2026-11-03',
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
};
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2026-11-02', end: '2026-11-14' },
|
||||
balances: [
|
||||
{ id: 'at-risk', type: 'cp', quantity: 2, expiryDate: '2026-11-13' },
|
||||
{
|
||||
id: 'deferred',
|
||||
type: 'cp',
|
||||
quantity: 2,
|
||||
availableFrom: '2026-11-12',
|
||||
expiryDate: '2027-01-01',
|
||||
},
|
||||
],
|
||||
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [],
|
||||
};
|
||||
const economyFirst = plan({ ...request, rules: [holiday, economy, early] });
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
const updates: { update: PlanningTraceUpdate; snapshot: string }[] = [];
|
||||
const windowFirst = calculatePlanningInternal(
|
||||
{
|
||||
...request,
|
||||
rules: [holiday, early, economy],
|
||||
traceProgress: (_time: number, update: PlanningTraceUpdate) =>
|
||||
updates.push({ update, snapshot: JSON.stringify(update) }),
|
||||
},
|
||||
(checkpoint, state) => expect(state?.placements).toEqual(checkpoint.placements),
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (windowFirst.kind !== 'Plan') throw new Error('Expected a plan');
|
||||
expect(economyFirst.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2026-11-12',
|
||||
balanceId: 'at-risk',
|
||||
supporting: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: '2026-11-13',
|
||||
balanceId: 'at-risk',
|
||||
supporting: false,
|
||||
}),
|
||||
]);
|
||||
expect(economyFirst.metrics.supportingPlacements).toBe(0);
|
||||
expect(economyFirst.preferences[0].satisfied).toBe(0);
|
||||
expect(economyFirst.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 2 });
|
||||
expect(windowFirst.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2026-11-02',
|
||||
balanceId: 'at-risk',
|
||||
ruleIds: ['early'],
|
||||
supporting: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: '2026-11-03',
|
||||
balanceId: 'at-risk',
|
||||
ruleIds: ['early'],
|
||||
supporting: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: '2026-11-12',
|
||||
balanceId: 'deferred',
|
||||
ruleIds: ['holiday'],
|
||||
supporting: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: '2026-11-13',
|
||||
balanceId: 'deferred',
|
||||
ruleIds: ['holiday'],
|
||||
supporting: true,
|
||||
}),
|
||||
]);
|
||||
expect(windowFirst.metrics.supportingPlacements).toBe(2);
|
||||
expect(windowFirst.preferences[0].satisfied).toBe(2);
|
||||
expect(windowFirst.metrics.holidayWeekValue).toBe(3);
|
||||
if (
|
||||
artifacts?.explanation?.kind !== 'PlanExplanation' ||
|
||||
!artifacts.finalState ||
|
||||
!artifacts.trace
|
||||
)
|
||||
throw new Error('Expected matching explanation and trace artifacts');
|
||||
expect(artifacts.finalState.placements).toEqual(windowFirst.placements);
|
||||
expect(artifacts.explanation.placements.map(({ placement }) => placement)).toEqual(
|
||||
windowFirst.placements,
|
||||
);
|
||||
for (const placement of artifacts.explanation.placements) {
|
||||
const contribution = expect.objectContaining({ kind: 'rule', ruleId: 'holiday' });
|
||||
if (placement.supporting) {
|
||||
expect(placement.ruleIds).toEqual(['holiday']);
|
||||
expect(placement.contributions).toContainEqual(contribution);
|
||||
expect(placement.firstDecisiveObjective).toBe('optimization:holiday');
|
||||
} else {
|
||||
expect(placement.ruleIds).toEqual(['early']);
|
||||
expect(placement.contributions).not.toContainEqual(contribution);
|
||||
}
|
||||
}
|
||||
const replay = {
|
||||
balances: artifacts.trace.initialState.balances.map((balance) => ({ ...balance })),
|
||||
placements: new Map(
|
||||
artifacts.trace.initialState.placements.map((placement) => [
|
||||
placement.date,
|
||||
placement,
|
||||
]),
|
||||
),
|
||||
earnedActivations: [...artifacts.trace.initialState.earnedActivations],
|
||||
};
|
||||
for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions))
|
||||
applyReplayDeltas(replay, decision.deltas, artifacts.finalState);
|
||||
expect(
|
||||
[...replay.placements.values()].sort((a, b) => a.date.localeCompare(b.date)),
|
||||
).toEqual(windowFirst.placements);
|
||||
expect(
|
||||
artifacts.trace.phases
|
||||
.filter(({ name }) => name === 'preference:early')
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates)),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
status: 'chosen',
|
||||
placement: expect.objectContaining({ date: '2026-11-03', ruleIds: ['early'] }),
|
||||
}),
|
||||
);
|
||||
expect(updates.length).toBeGreaterThan(0);
|
||||
for (const { update, snapshot } of updates) expect(JSON.stringify(update)).toBe(snapshot);
|
||||
let cancelled = false;
|
||||
let cancelledArtifacts: PlanningArtifacts | undefined;
|
||||
let published: PlanOutcome | undefined;
|
||||
const partial = await calculatePlanningAsyncInternal(
|
||||
{
|
||||
...request,
|
||||
rules: [holiday, early, economy],
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'optimization:economy') cancelled = true;
|
||||
},
|
||||
traceProgress: (_time: number, update: PlanningTraceUpdate) =>
|
||||
updates.push({ update, snapshot: JSON.stringify(update) }),
|
||||
},
|
||||
(checkpoint, state) => {
|
||||
expect(state?.placements).toEqual(checkpoint.placements);
|
||||
if (checkpoint.lastCompletedStage === 'preference:early') published = checkpoint;
|
||||
},
|
||||
(value) => (cancelledArtifacts = value),
|
||||
undefined,
|
||||
undefined,
|
||||
() => cancelled,
|
||||
);
|
||||
if (
|
||||
partial.kind !== 'PlanningIncomplete' ||
|
||||
!partial.incumbent ||
|
||||
cancelledArtifacts?.explanation?.kind !== 'PlanExplanation'
|
||||
)
|
||||
throw new Error('Expected an explained cancelled incumbent');
|
||||
expect(partial.reasons[0].code).toBe('user-cancelled');
|
||||
expect(partial.incumbent.placements).toEqual(windowFirst.placements);
|
||||
expect(partial.incumbent.placements).toEqual(published?.placements);
|
||||
expect(partial.incumbent.preferences).toMatchObject([{ ruleId: 'early', satisfied: 2 }]);
|
||||
expect(cancelledArtifacts.finalState?.placements).toEqual(partial.incumbent.placements);
|
||||
expect(cancelledArtifacts.explanation.placements.map(({ placement }) => placement)).toEqual(
|
||||
partial.incumbent.placements,
|
||||
);
|
||||
for (const placement of cancelledArtifacts.explanation.placements.slice(0, 2)) {
|
||||
expect(placement.ruleIds).toEqual(['early']);
|
||||
expect(placement.contributions).toContainEqual(
|
||||
expect.objectContaining({ kind: 'rule', ruleId: 'early' }),
|
||||
);
|
||||
}
|
||||
for (const { update, snapshot } of updates) expect(JSON.stringify(update)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('keeps established maximum credit when a later earned period masks its local contribution', () => {
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
horizon: { start: '2024-05-01', end: '2024-05-11' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-05-03' }],
|
||||
rules: [
|
||||
{ ...gain, configuration: { ...gain.configuration, aggregation: 'maximum' } },
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
dayOfWeek: 3,
|
||||
placement: 'after',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
rewardDays: 2,
|
||||
rewardType: 'rtt',
|
||||
},
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (outcome.kind !== 'Plan' || artifacts?.explanation?.kind !== 'PlanExplanation')
|
||||
throw new Error('Expected an explained plan');
|
||||
expect(outcome.placements).toMatchObject([
|
||||
{ date: '2024-05-02', ruleIds: ['gain', 'producer'], supporting: false },
|
||||
{ date: '2024-05-03', ruleIds: ['gain'], supporting: false },
|
||||
{ date: '2024-05-10', ruleIds: ['gain'], supporting: true },
|
||||
]);
|
||||
for (const placement of artifacts.explanation.placements.slice(0, 2)) {
|
||||
expect(placement.comparison.delta).toContainEqual(
|
||||
expect.objectContaining({ id: 'optimization:gain', value: 0 }),
|
||||
);
|
||||
expect(placement.contributions).toContainEqual(
|
||||
expect.objectContaining({ kind: 'rule', ruleId: 'gain' }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('counts reverse deferred reassignment for an edited optimization without explicit calendar credit', () => {
|
||||
const request = bundles[0].request;
|
||||
const forgoAtRisk: OptimizationPlanningRule = {
|
||||
id: 'forgo',
|
||||
type: 'optimization',
|
||||
configuration: { ...OPTIMIZATION_PRESETS.unplacedAtRisk, direction: 'maximize' },
|
||||
};
|
||||
const outcome = plan({
|
||||
...request,
|
||||
balances: [
|
||||
{ ...request.balances[0], id: 'at-risk', expiryDate: '2026-11-13' },
|
||||
{ ...request.balances[0], id: 'deferred' },
|
||||
],
|
||||
rules: [bundles[0].rule, forgoAtRisk, economy],
|
||||
});
|
||||
expect(
|
||||
outcome.placements.map(({ balanceId, supporting }) => ({ balanceId, supporting })),
|
||||
).toEqual([
|
||||
{ balanceId: 'deferred', supporting: true },
|
||||
{ balanceId: 'deferred', supporting: true },
|
||||
]);
|
||||
expect(outcome.metrics).toMatchObject({
|
||||
holidayWeekValue: 3,
|
||||
unplacedAtRisk: 2,
|
||||
supportingPlacements: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('records the calendar credit and classification change in the original calendar-rule phase', () => {
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
calculatePlanningInternal(
|
||||
{ ...weekend, rules: [gain, economy, requested] },
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
const changes = artifacts?.trace?.phases
|
||||
.find(({ name }) => name === 'preference:requested')
|
||||
?.decisions.flatMap(({ deltas }) => deltas);
|
||||
expect(changes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'placement-updated',
|
||||
previousPlacement: expect.objectContaining({ supporting: true }),
|
||||
placement: expect.objectContaining({
|
||||
ruleIds: ['gain', 'requested'],
|
||||
supporting: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps earned support chronological and preserves the captured economy rule in explanations and counterfactuals', async () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const capturedEconomy = structuredClone(economy);
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2024-04-29', end: '2024-05-08' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
|
||||
rules: [
|
||||
gain,
|
||||
capturedEconomy,
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
force: 'obligation',
|
||||
dayOfWeek: 3,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'after',
|
||||
rewardDays: 1,
|
||||
rewardType: 'rtt',
|
||||
},
|
||||
],
|
||||
};
|
||||
const calculation = createPlanningCalculation(request);
|
||||
capturedEconomy.configuration.conditions = [];
|
||||
capturedEconomy.name = 'Changed later';
|
||||
const outcome = await calculation.outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
|
||||
expect(outcome.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-05-02',
|
||||
ruleIds: ['producer'],
|
||||
supporting: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: '2024-05-06',
|
||||
balanceId: 'earned:producer:2024-05-01',
|
||||
supporting: true,
|
||||
}),
|
||||
]);
|
||||
expect(outcome.metrics).toMatchObject({
|
||||
nonWorkingDayGain: 3,
|
||||
supportingPlacements: 1,
|
||||
});
|
||||
expect(calculation.snapshot.rules[1]).toMatchObject({
|
||||
name: 'Économiser les jours d’appoint',
|
||||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||||
});
|
||||
const explanation = await calculation.explanation;
|
||||
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
|
||||
const supporting = explanation.placements.find(({ supporting }) => supporting)!;
|
||||
expect(supporting.firstDecisiveObjective).toBe('optimization:gain');
|
||||
expect(supporting.objectiveVector.objectives.map(({ id }) => id)).toEqual([
|
||||
'obligation-validity',
|
||||
'optimization:gain',
|
||||
'optimization:economy',
|
||||
'balance-expiry',
|
||||
'placement-date',
|
||||
]);
|
||||
expect(supporting.contributions).toContainEqual(
|
||||
expect.objectContaining({ kind: 'supporting' }),
|
||||
);
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.map(({ name }) => name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
'optimization:gain',
|
||||
'optimization:economy',
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[1]);
|
||||
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
|
||||
expect(analysis.results.find(({ date }) => date === '2024-05-03')).toMatchObject({
|
||||
status: 'ineligible',
|
||||
reason: { code: 'unavailable' },
|
||||
});
|
||||
expect(
|
||||
analysis.results.find(({ date }) => date === '2024-05-06')?.comparison?.after
|
||||
.objectives,
|
||||
).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 1 }));
|
||||
expect(
|
||||
analysis.results.find(({ date }) => date === '2024-05-07')?.comparison?.after
|
||||
.objectives,
|
||||
).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 1 }));
|
||||
expect(outcome.placements[1]).toMatchObject({ date: '2024-05-06', supporting: true });
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user