- 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:
1 parent
036a8c0e1e
commit
9851edf906
18 files changed
+3347
-229
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user