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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user