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

+160 -24
View File
@@ -15,11 +15,14 @@ import { CalendarYear } from '../components/calendar-year/calendar-year';
import { CalendarService, SelectableDayType } from './calendar-service';
import { CalendarSettingsService } from './calendar-settings-service';
import { UserInputService } from './user-input-service';
import { RuleService, RuleType } from './rule-service';
import { RuleService, RuleType, createOptimizationPresetRules } from './rule-service';
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('real planning worker cancellation', () => {
let directory: string;
let bundle: string;
@@ -43,7 +46,7 @@ const originalNow = performance.now.bind(performance);
if (workerData.simulateLongCalculation)
Object.defineProperty(performance, 'now', { value: () => originalNow() + elapsed });
globalThis.self = { postMessage: (message) => {
if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'at-risk')
if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'optimization:default-unplaced-at-risk')
setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0);
parentPort.postMessage(message);
} };
@@ -109,6 +112,123 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
});
afterAll(() => rmSync(directory, { recursive: true, force: true }));
it('preserves a complete optimization configuration and active order in the real worker snapshot', async () => {
const configuration: OptimizationConfiguration = structuredClone(
OPTIMIZATION_PRESETS.unplacedAtRisk,
);
configuration.aggregation = 'maximum';
const submitted = structuredClone(configuration);
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-05' },
balances: [{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-04' }],
rules: [
{
id: 'personal-loss',
name: 'Captured loss rule',
type: 'optimization',
force: 'preference',
configuration,
},
],
});
configuration.conditions = [];
configuration.direction = 'maximize';
configuration.aggregation = 'count';
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
expect(outcome.placements).toHaveLength(1);
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(calculation.snapshot.rules[0]).toMatchObject({
name: 'Captured loss rule',
configuration: submitted,
});
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected worker explanation');
expect(
explanation.placements[0].objectiveVector.objectives.map((objective) => objective.id),
).toEqual([
'obligation-validity',
'optimization:personal-loss',
'balance-expiry',
'placement-date',
]);
const trace = await calculation.trace;
expect(trace?.phases.map((phase) => phase.name)).toEqual([
'validation',
'obligations',
'optimization:personal-loss',
'expiry',
'dates',
]);
const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (comparison.kind !== 'Counterfactual') throw new Error('Expected worker comparison');
expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual([
'obligation-validity',
'optimization:personal-loss',
'balance-expiry',
'placement-date',
]);
});
it('keeps the submitted work-sequence threshold in the real worker, trace and counterfactuals', async () => {
const configuration: OptimizationConfiguration = structuredClone(
OPTIMIZATION_PRESETS.shortSequenceDeficit,
);
const calculation = createPlanningCalculation({
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: [
{
id: 'deficit',
name: 'Captured five-day preference',
type: 'optimization',
configuration,
},
],
});
configuration.thresholdDays = 3;
configuration.conditions = [];
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
expect(outcome.placements.map(({ date }) => date)).toEqual([
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
expect(calculation.snapshot.rules[0]).toMatchObject({
configuration: { thresholdDays: 5, conditions: ['internal', 'non-empty'] },
});
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
expect(
explanation.placements[0].comparison.before.objectives.find(
({ id }) => id === 'optimization:deficit',
)?.value,
).toBe(4);
const trace = await calculation.trace;
const chosen = trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.find((candidate) => candidate.status === 'chosen' && candidate.dates?.length === 3);
expect(chosen?.comparison?.firstDecisiveObjective).toBe('optimization:deficit');
expect(
chosen?.comparison?.before.objectives.find(({ id }) => id === 'optimization:deficit')
?.value,
).toBe(2);
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (counterfactual.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
expect(
counterfactual.baseline.objectives.find(({ id }) => id === 'optimization:deficit')
?.value,
).toBe(4);
expect(
counterfactual.results
.find(({ date }) => date === '2024-01-09')
?.comparison?.after.objectives.find(({ id }) => id === 'optimization:deficit')
?.value,
).toBe(0);
});
it.each([
[false, false],
[true, false],
@@ -120,7 +240,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
localStorage.clear();
localStorage.setItem('app.language', '"fr"');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
vi.setSystemTime(new Date('2024-05-01T21:59:59Z'));
await TestBed.configureTestingModule({
imports: [CalendarYear],
@@ -131,6 +251,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
for (const date of ['2024-04-29', '2024-04-30']) input.toggleDeclaredDay(date, 'cp');
input.toggleDeclaredDay('2024-05-06', 'other');
const rules = TestBed.inject(RuleService);
rules.addRule(presets[0]);
rules.addRule({
id: 'producer',
name: 'CP autour du férié',
@@ -155,6 +276,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
placeDays: 2,
placeType: SelectableDayType.OTHER,
});
for (const preset of presets.slice(1)) rules.addRule(preset);
const calendar = TestBed.inject(CalendarService);
const original = calendar.createPlanningCalculation.bind(calendar);
let calculation!: PlanningCalculationContext;
@@ -168,7 +290,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
...request,
progress: (stage) => {
request.progress?.(stage);
if (cancelled && stage === 'non-working-day-gain') {
if (
cancelled &&
stage === 'optimization:default-non-working-day-gain'
) {
fixture.detectChanges();
fixture.nativeElement
.querySelector('button.cancel-calculation')
@@ -249,7 +374,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expect(dialog.textContent?.includes('Planning partiel')).toBe(cancelled);
const originalDeclarations = structuredClone(calculation.snapshot.declaredDays);
input.toggleDeclaredDay('2024-04-30', 'rtt');
rules.updateRule({ ...rules.rules[0], name: 'Règle modifiée après calcul' });
rules.updateRule({
...rules.rules.find(({ id }) => id === 'producer')!,
name: 'Règle modifiée après calcul',
});
rules.deleteRule('producer');
input.updateBalances(() => []);
if (worker) {
@@ -355,7 +483,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
balances: [
{ id: 'cp', type: 'cp' as const, quantity: 4, expiryDate: '2024-01-05' },
],
rules: [],
rules: presets,
declaredDays: [
{ date: '2024-01-02', type: 'cp' as const },
{ date: '2024-01-03', type: 'rtt' as const },
@@ -370,7 +498,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
{
...submitted,
progress: (stage: string) => {
if (stage === 'non-working-day-gain') interruption = true;
if (stage === 'optimization:default-non-working-day-gain')
interruption = true;
},
},
undefined,
@@ -383,7 +512,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
const calculation = createPlanningCalculation({
...submitted,
progress: (stage) => {
if (cancelled && stage === 'non-working-day-gain') calculation.cancel();
if (cancelled && stage === 'optimization:default-non-working-day-gain')
calculation.cancel();
},
});
const outcome = await calculation.outcome;
@@ -546,26 +676,30 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
rules: [
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-02',
placeDays: 1,
placeType: 'cp',
},
presets[0],
...[
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-02',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
progress: (stage) => {
if (stage === 'non-working-day-gain') calculation.cancel();
if (stage === 'optimization:default-non-working-day-gain') calculation.cancel();
},
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('PlanningIncomplete');
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(outcome.completedStages).toContain('holiday-week-value');
expect(outcome.completedStages).not.toContain('non-working-day-gain');
expect(outcome.completedStages).toContain('optimization:default-holiday-week-value');
expect(outcome.completedStages).not.toContain('optimization:default-non-working-day-gain');
const incumbent = outcome.incumbent!;
expect(incumbent.placements).toHaveLength(3);
expect(incumbent.placements).toContainEqual({
@@ -585,9 +719,11 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
const trace = await calculation.trace;
expect(trace?.status).toBe('incomplete');
expect(trace?.error?.code).toBe('user-cancelled');
expect(trace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status).toBe(
'interrupted',
);
expect(
trace?.phases.find(
(phase) => phase.name === 'optimization:default-non-working-day-gain',
)?.status,
).toBe('interrupted');
const frozenOutcome = structuredClone(outcome);
calculation.cancel();
expect(await calculation.outcome).toEqual(frozenOutcome);
@@ -605,7 +741,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expiryDate: '2026-12-24',
},
],
rules: [],
rules: presets,
};
const { calculatePlanningInternal } = await import('./leave-planner.core');
const expected = calculatePlanningInternal(submitted);