feat(planning): make optimization rules composable

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

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

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

No files matched your search

@@ -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();
}
});
});