With several obligation solutions, the planner ran the whole preference chain on candidate solutions as previews: every solution after a complete enumeration, or the eight best-ranked after a truncated window search. It now orders all solutions by the existing pre-preference ranking (active objectives, then tie-breaks, of each obligation-only plan; equal ranks keep enumeration order) and runs the preference stages once, on the first. If that run ends with earned leave available only after its final merged time-off period, the stages restart on the next solution, so a request that planned before never becomes a conflict. The loaded year drops from 43 to 5.5 seconds and typed obligations from 3.3 to 0.1 seconds, with byte-identical benchmark plans. Over the 5,000 seeded requests, 23 plans become worse (9 on a real objective) and 1 better; over 50 loaded-year-sized requests, 4 become worse on a real objective. ADR 0010 records the decision; docs/benchmark/issue-302.md records the comparison and the opt-in runner that reproduces it. The frozen-engine check fails until the frozen engine is rebuilt from this commit in the next one. Part of #302. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
738 lines
24 KiB
TypeScript
738 lines
24 KiB
TypeScript
import { vi } from '../../testing/vitest-compat';
|
||
import {
|
||
calculatePlanningAsyncInternal,
|
||
calculatePlanningInternal,
|
||
type OptimizationPlanningRule,
|
||
type PlanOutcome,
|
||
type PlanningArtifacts,
|
||
type PlanningRequest,
|
||
} from './leave-planner.core';
|
||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||
import { 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',
|
||
// The best-ranked obligation solution placed this date for the producer's
|
||
// after-pattern before gain ran, so the date keeps no gain credit (ADR 0010).
|
||
ruleIds: force === 'obligation' ? [] : ['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', () => {
|
||
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],
|
||
});
|
||
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 });
|
||
});
|
||
|
||
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 windowFirst = calculatePlanningInternal(
|
||
{
|
||
...request,
|
||
rules: [holiday, early, economy],
|
||
},
|
||
(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)
|
||
throw new Error('Expected matching explanation and state snapshots');
|
||
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);
|
||
}
|
||
}
|
||
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;
|
||
},
|
||
},
|
||
(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' }),
|
||
);
|
||
}
|
||
});
|
||
|
||
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('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 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();
|
||
}
|
||
});
|
||
});
|