feat(planning): #62 #63 #64 enhance leave planner with calculation date and entitlement details

- Introduced calculationDate in various service interfaces and snapshots to track the date of planning calculations.
- Added futureEntitlement and includedEntitlement fields to the translation service and respective language files for better user feedback.
- Updated leave-planner.worker to handle calculationDate and declaredDays in requests.
- Enhanced tests to ensure proper handling of calculation dates and artifact retention during planning processes.
- Improved snapshot and explanation outputs to include calculation date for clarity in planning results.
This commit is contained in:
nessar committed 2026-10-07 19:22:46 +02:00
1 parent 036a8c0e1e
commit 9851edf906
18 files changed
+3347 -229

No files matched your search

+220 -1
View File
@@ -5,13 +5,28 @@ import { join, resolve } from 'node:path';
import { Worker as NodeWorker } from 'node:worker_threads';
import { promisify } from 'node:util';
import { vi } from 'vitest';
import { createPlanningCalculation } from './leave-planner.service';
import {
createPlanningCalculation,
type PlanningCalculationContext,
} from './leave-planner.service';
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
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';
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
describe('real planning worker cancellation', () => {
let directory: string;
let bundle: string;
let afterCheckpoint: (() => void) | undefined;
let heartbeats = 0;
let holdArtifacts = false;
const heldArtifactMessages: (() => void)[] = [];
let simulateLongCalculation = false;
let simulatedElapsed = 0;
const threads = new Set<NodeWorker>();
@@ -41,6 +56,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
beforeEach(() => {
afterCheckpoint = undefined;
holdArtifacts = false;
heldArtifactMessages.length = 0;
localStorage.clear();
heartbeats = 0;
simulateLongCalculation = false;
simulatedElapsed = 0;
@@ -55,6 +73,18 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
simulatedElapsed = data.elapsed;
return;
}
if (
holdArtifacts &&
[
'explanation',
'artifacts-start',
'trace-status-index',
'artifacts-complete',
].includes(data.type)
) {
heldArtifactMessages.push(() => this.onmessage?.({ data } as MessageEvent));
return;
}
if (data.type === 'heartbeat') heartbeats++;
this.onmessage?.({ data } as MessageEvent);
if (data.type === 'checkpoint') afterCheckpoint?.();
@@ -74,10 +104,196 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
afterEach(async () => {
await Promise.all([...threads].map((thread) => thread.terminate()));
threads.clear();
localStorage.clear();
vi.unstubAllGlobals();
});
afterAll(() => rmSync(directory, { recursive: true, force: true }));
it.each([
[false, false],
[true, false],
[false, true],
[true, true],
])(
'submits declared earned chains from the calendar and retains actual artifacts after edits (worker: %s, cancelled: %s)',
async (worker, cancelled) => {
localStorage.clear();
localStorage.setItem('app.language', '"fr"');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
vi.setSystemTime(new Date('2024-05-01T21:59:59Z'));
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-04-29', '2024-05-07');
const input = TestBed.inject(UserInputService);
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({
id: 'producer',
name: 'CP autour du férié',
enabled: true,
force: 'preference',
type: RuleType.IF_THEN,
dayOfWeek: 3,
placement: 'before',
placeDays: 2,
placeType: SelectableDayType.CP,
rewardDays: 1,
rewardType: SelectableDayType.OTHER,
});
rules.addRule({
id: 'consumer',
name: 'Période avec droit futur',
enabled: true,
force: 'obligation',
type: RuleType.TIME_WINDOW,
startDate: new Date('2024-05-03T00:00:00'),
endDate: new Date('2024-05-06T00:00:00'),
placeDays: 2,
placeType: SelectableDayType.OTHER,
});
const calendar = TestBed.inject(CalendarService);
const original = calendar.createPlanningCalculation.bind(calendar);
let calculation!: PlanningCalculationContext;
const calculate = vi
.spyOn(calendar, 'createPlanningCalculation')
.mockImplementation((request) => {
const browserWindow = globalThis.window;
if (!worker) vi.stubGlobal('window', undefined);
try {
calculation = original({
...request,
progress: (stage) => {
request.progress?.(stage);
if (cancelled && stage === 'non-working-day-gain') {
fixture.detectChanges();
fixture.nativeElement
.querySelector('button.cancel-calculation')
.click();
}
},
});
return calculation;
} finally {
if (!worker) vi.stubGlobal('window', browserWindow);
}
});
holdArtifacts = worker;
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
expect(calculate).toHaveBeenCalledOnce();
expect(calculation.snapshot.calculationDate).toBe('2024-05-01');
vi.setSystemTime(new Date('2024-05-01T22:00:01Z'));
const outcome = await calculation.outcome;
expect(outcome.kind).toBe(cancelled ? 'PlanningIncomplete' : 'Plan');
const plan =
outcome.kind === 'Plan'
? outcome
: outcome.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
if (!plan) throw new Error('Expected a valid earned-chain plan.');
expect(plan.calculationDate).toBe('2024-05-01');
expect(plan.placements).toMatchObject([
{
date: '2024-05-03',
balanceId: 'earned:producer:2024-05-01',
type: 'other',
ruleIds: ['consumer'],
},
]);
if (outcome.kind === 'PlanningIncomplete')
expect(outcome.reasons[0].code).toBe('user-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' });
input.updateBalances(() => []);
if (worker) {
await vi.waitFor(() => expect(heldArtifactMessages.length).toBeGreaterThan(0));
let explained = false;
void calculation.explanation.then(() => (explained = true));
await Promise.resolve();
expect(explained).toBe(false);
holdArtifacts = false;
for (const deliver of heldArtifactMessages.splice(0)) deliver();
}
const explanation = await calculation.explanation;
const trace = await calculation.trace;
await fixture.whenStable();
fixture.detectChanges();
expect(explanation).toMatchObject({
kind: 'PlanExplanation',
calculationDate: '2024-05-01',
declaredDays: originalDeclarations,
ifThenContributions: [
{
label: 'CP autour du férié',
earnedEntitlement: {
availableFrom: '2024-05-02',
expiryDate: '2025-05-02',
added: true,
},
},
],
});
expect(trace?.initialState).toMatchObject({
calculationDate: '2024-05-01',
declaredDays: originalDeclarations,
});
expect(trace?.status).toBe(cancelled ? 'incomplete' : 'ready');
const core = await import('./leave-planner.core');
const direct = core.calculatePlanningInternal(calculation.snapshot);
if (direct.kind !== 'Plan')
throw new Error(`Expected the same direct plan: ${JSON.stringify(direct)}`);
expect(plan.placements).toEqual(direct.placements);
expect(plan.ifThenContributions).toEqual(direct.ifThenContributions);
const root: HTMLElement = fixture.nativeElement;
expect(root.textContent).toContain('Droit futur généré');
expect(root.textContent).toContain('CP autour du férié');
expect(root.textContent).not.toContain('Règle modifiée après calcul');
expect(
root.querySelector('[data-date="2024-04-30"]')?.getAttribute('aria-label'),
).toContain('CP');
root.querySelector<HTMLButtonElement>('button.compare-dates-button')!.click();
await vi.waitFor(() =>
expect(fixture.componentInstance['counterfactualLoading']()).toBe(false),
);
const comparison = fixture.componentInstance['counterfactual']();
expect(comparison?.kind).toBe('Counterfactual');
expect(
comparison?.results.find(({ date }) => date === '2024-04-30')?.reason?.code,
).toBe('declared-day');
expect(comparison?.results.find(({ date }) => date === '2024-05-02')?.status).toBe(
'ineligible',
);
expect(comparison?.results.find(({ date }) => date === '2024-05-03')?.status).toBe(
'eligible',
);
fixture.componentInstance['showFinalPlan']();
if (!trace) throw new Error('Expected the original trace.');
const phaseIndex = trace.phases.findIndex(({ decisions }) =>
decisions.some(({ deltas }) =>
deltas.some(({ placement }) => placement?.balanceId.startsWith('earned:')),
),
);
expect(phaseIndex).toBeGreaterThanOrEqual(0);
fixture.componentInstance['selectTracePhase'](phaseIndex);
const decisionIndex = trace.phases[phaseIndex].decisions.findIndex(({ deltas }) =>
deltas.some(({ placement }) => placement?.balanceId.startsWith('earned:')),
);
fixture.componentInstance['selectTraceDecision'](decisionIndex);
fixture.componentInstance['showTraceMoment']('after');
expect(fixture.componentInstance['selectedTraceTransition']()?.after).toMatchObject({
calculationDate: '2024-05-01',
declaredDays: originalDeclarations,
});
fixture.destroy();
},
);
it.each([false, true])(
'keeps declarations fixed in direct and worker planning and date comparisons (cancelled: %s)',
async (cancelled) => {
@@ -219,6 +435,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
let cancelledAt = 0;
afterCheckpoint = () => {
afterCheckpoint = undefined;
holdArtifacts = false;
heldArtifactMessages.length = 0;
localStorage.clear();
setTimeout(() => {
cancelledAt = performance.now();
calculation.cancel();