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
+3330 -212

No files matched your search

@@ -7,6 +7,8 @@ import {
} from './leave-planner-benchmark'; } from './leave-planner-benchmark';
describe('leave planner benchmark protocol', () => { describe('leave planner benchmark protocol', () => {
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
// Warm-up and two repetitions include complete trace delivery after planning. // Warm-up and two repetitions include complete trace delivery after planning.
it('keeps seven deterministic scenarios including two and three years and a later earned consumer', async () => { it('keeps seven deterministic scenarios including two and three years and a later earned consumer', async () => {
const scenarios = benchmarkScenarios(); const scenarios = benchmarkScenarios();
@@ -407,6 +407,288 @@ describe('CalendarYear planning outcome boundary', () => {
beforeEach(() => localStorage.clear()); beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear()); afterEach(() => localStorage.clear());
it.each([false, true])(
'shows fixed rule credits separately from balance-consuming placements (partial: %s)',
async (partial) => {
const core = await import('../../services/leave-planner.core');
const declaredDays = partial
? [{ date: '2024-01-10', type: 'cp' as const }]
: ['08', '09', '10'].map((day) => ({
date: `2024-01-${day}`,
type: 'cp' as const,
}));
const submitted = {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: partial
? [
{
id: 'cp',
type: 'cp' as const,
quantity: 1,
availableFrom: '2024-01-12',
expiryDate: '2024-01-12',
},
]
: [],
declaredDays,
labels: { 'two-cp': 'Two CP including booked leave' },
rules: [
{
id: 'two-cp',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-08',
endDate: '2024-01-12',
placeDays: 2,
placeType: 'cp',
},
],
};
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
submitted,
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue({
...calculationFor(outcome),
explanation: Promise.resolve(artifacts.explanation),
trace: Promise.resolve(artifacts.trace),
});
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-01-08', '2024-01-12');
TestBed.inject(TranslationService).setLanguage('en');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
const summary =
root.querySelector('.declared-rule')?.textContent?.replace(/\s+/g, ' ') ?? '';
expect(summary).toContain('Two CP including booked leave');
expect(summary).toContain(
partial
? '1 Already booked + 1 leave days used = 2 / 2'
: '2 Already booked + 0 leave days used = 2 / 2',
);
expect(summary).toContain('Paid Leave');
expect(summary).toContain('no remaining balance debit');
for (const day of declaredDays) {
const cell = root.querySelector<HTMLButtonElement>(
`button[data-date="${day.date}"]`,
)!;
expect(cell.classList).toContain('cp');
expect(cell.getAttribute('aria-label')).toContain('Already booked');
}
if (!partial)
expect(summary).not.toContain(
fixture.componentInstance['displayDate']('2024-01-10'),
);
expect(artifacts.explanation).toMatchObject({
timeWindowContributions: outcome.timeWindowContributions,
});
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-10', 'rtt');
settings.setPlanningDates('2025-01-01', '2025-01-31');
fixture.detectChanges();
expect(root.querySelector('.declared-rule')?.textContent?.replace(/\s+/g, ' ')).toBe(
summary,
);
},
);
it.each([
['2024-05-31', '2024-06-01', '2024-06-02', [], 3, 2],
[
'2024-07-31',
'2024-08-01',
'2024-08-01',
[{ date: '2024-08-01', type: 'cp' as const }],
2,
0,
],
] as const)(
'shows complete declared-only periods and context dates from %s',
async (start, end, contextDate, outsideDeclarations, duration, gain) => {
const core = await import('../../services/leave-planner.core');
const submitted = {
horizon: { start, end },
balances: [],
rules: [],
declaredDays: [{ date: start, type: 'other' as const }, ...outsideDeclarations],
};
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
submitted,
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const calculation = {
...calculationFor(outcome),
explanation: Promise.resolve(artifacts.explanation),
trace: Promise.resolve(artifacts.trace),
};
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
calculation,
);
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates(start, start);
const input = TestBed.inject(UserInputService);
input.toggleDeclaredDay(start, 'other');
TestBed.inject(TranslationService).setLanguage('en');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
const summary =
root.querySelector('.declared-period')?.textContent?.replace(/\s+/g, ' ') ?? '';
expect(summary).toContain(`${duration} days off in a row`);
expect(summary).toContain(`${gain} weekend & holiday days`);
expect(summary).toContain('0 leave days used');
expect(summary).toContain('no remaining balance debit');
const declared = root.querySelector<HTMLButtonElement>(`button[data-date="${start}"]`)!;
expect(declared.classList).toContain('other');
expect(declared.getAttribute('aria-label')).toContain('Already booked');
const context = root.querySelector<HTMLButtonElement>(
`button[data-date="${contextDate}"]`,
)!;
expect(context.disabled).toBe(true);
expect(context.classList).toContain('context-only');
expect(context.classList).toContain('selected');
expect(context.getAttribute('aria-label')).toContain(
'Context date outside the planning horizon',
);
if (outsideDeclarations.length) {
expect(context.classList).toContain('cp');
expect(context.getAttribute('aria-label')).toContain('Already booked');
}
input.toggleDeclaredDay(start, 'cp');
settings.setPlanningDates('2025-01-01', '2025-01-31');
fixture.detectChanges();
expect(root.querySelector('.declared-period')?.textContent?.replace(/\s+/g, ' ')).toBe(
summary,
);
expect(declared.getAttribute('aria-label')).toContain('Other');
expect(fixture.componentInstance['displayedPlan']()?.metrics.nonWorkingDayGain).toBe(
gain,
);
expect(input.vacationNumberSignal()).toEqual([]);
},
);
it.each([false, true])(
'keeps adjacent context declarations attached to the historical anchors (fixed anchor: %s)',
async (fixedAnchor) => {
const core = await import('../../services/leave-planner.core');
const placementDate = fixedAnchor ? '2024-07-30' : '2024-07-31';
const submitted = {
horizon: { start: '2024-07-30', end: '2024-08-01' },
balances: [
{
id: 'rtt',
type: 'rtt' as const,
quantity: 1,
availableFrom: placementDate,
expiryDate: placementDate,
},
],
rules: [],
declaredDays: [
{ date: '2024-08-01', type: 'cp' as const },
...(fixedAnchor ? [{ date: '2024-07-31', type: 'other' as const }] : []),
],
};
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
submitted,
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace)
throw new Error('Expected a plan and its original trace.');
const trace = artifacts.trace;
const phaseIndex = trace.phases.findIndex((phase) =>
phase.decisions.some((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === placementDate,
),
),
);
const decisionIndex = trace.phases[phaseIndex].decisions.findIndex((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === placementDate,
),
);
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const calculation = calculationFor(outcome, artifacts.explanation, trace);
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
calculation,
);
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-07-30', '2024-07-31');
TestBed.inject(TranslationService).setLanguage('en');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
activateWithKeyboard(root.querySelector<HTMLElement>('.trace-explorer summary')!);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
activateWithKeyboard(
root.querySelectorAll<HTMLElement>('.trace-phases > button')[phaseIndex],
);
fixture.detectChanges();
for (let index = 0; index < decisionIndex; index++) {
activateWithKeyboard(
root.querySelector<HTMLElement>('[aria-label="Next decision"]')!,
);
fixture.detectChanges();
}
activateWithKeyboard(root.querySelector<HTMLElement>('.trace-toolbar button')!);
fixture.detectChanges();
const before = root.querySelector<HTMLButtonElement>('button[data-date="2024-08-01"]');
if (fixedAnchor) {
expect(before?.classList).toContain('selected');
expect(before?.classList).toContain('cp');
} else expect(before).toBeNull();
activateWithKeyboard(root.querySelectorAll<HTMLElement>('.trace-toolbar button')[1]);
fixture.detectChanges();
const after = root.querySelector<HTMLButtonElement>('button[data-date="2024-08-01"]')!;
expect(after.classList).toContain('selected');
expect(after.classList).toContain('cp');
expect(after.classList).toContain('context-only');
expect(after.disabled).toBe(true);
expect(after.getAttribute('aria-label')).toContain('Already booked');
expect(fixture.componentInstance['traceState']()?.placements).toEqual(
outcome.placements,
);
TestBed.inject(UserInputService).toggleDeclaredDay('2024-08-01', 'rtt');
fixture.detectChanges();
expect(after.getAttribute('aria-label')).toContain('CP');
},
);
it('previews a growing and shrinking range across months, then saves it once on release', async () => { it('previews a growing and shrinking range across months, then saves it once on release', async () => {
localStorage.setItem('app.language', '"fr"'); localStorage.setItem('app.language', '"fr"');
localStorage.setItem('leaveBalances', '[]'); localStorage.setItem('leaveBalances', '[]');
@@ -146,6 +146,87 @@
<dd>{{ plan.metrics.longestWorkSequence }}</dd> <dd>{{ plan.metrics.longestWorkSequence }}</dd>
</div> </div>
</dl> </dl>
@for (contribution of plan.timeWindowContributions ?? []; track contribution.ruleId) {
<section class="outcome-explanation declared-rule">
<h3>{{ contribution.label }}</h3>
<p>
{{ contribution.declaredDays.length }} {{ tr().bookedDays.booked }} +
{{ contribution.placementDates.length }}
{{ tr().planning.explanation.leaveDays }} =
<strong
>{{
contribution.declaredDays.length + contribution.placementDates.length
}}
/ {{ contribution.requested }}</strong
>
</p>
<p>
@for (day of contribution.declaredDays; track day.date) {
<span>{{ displayDate(day.date) }} · {{ tr().dayTypes[day.type] }} </span>
}
</p>
<p>{{ tr().bookedDays.fixed }}</p>
</section>
}
@if (plan.calculationDate) {
<p>{{ tr().planning.calculationDate }} : {{ displayDate(plan.calculationDate) }}</p>
}
@for (
contribution of plan.ifThenContributions ?? [];
track contribution.ruleId + contribution.occurrence
) {
<section class="outcome-explanation declared-rule">
<h3>{{ contribution.label }} · {{ displayDate(contribution.occurrence) }}</h3>
<p>
{{ contribution.declaredDays.length }} {{ tr().bookedDays.booked }} +
{{ contribution.placementDates.length }}
{{ tr().planning.explanation.leaveDays }} =
<strong>{{ contribution.requested }} / {{ contribution.requested }}</strong>
</p>
@for (day of contribution.declaredDays; track day.date) {
<span>{{ displayDate(day.date) }} · {{ tr().dayTypes[day.type] }} </span>
}
@if (contribution.declaredDays.length) {
<p>{{ tr().bookedDays.fixed }}</p>
}
@if (contribution.earnedEntitlement; as earned) {
<p>
{{
earned.added
? tr().planning.futureEntitlement
: tr().planning.includedEntitlement
}}
: {{ earned.quantity }} {{ tr().dayTypes[earned.type] }}
</p>
<p>
{{ tr().planning.details.available }}
{{ displayDate(earned.availableFrom) }} ·
{{ tr().planning.details.expires }} {{ displayDate(earned.expiryDate) }}
</p>
}
</section>
}
@for (period of plan.timeOffPeriods; track period.start) {
@if (period.declaredDates?.length) {
<section class="outcome-explanation declared-period">
<h3>
{{ tr().planning.explanation.breakTitle }} ·
{{ displayDate(period.start) }} – {{ displayDate(period.end) }}
</h3>
<p>
{{ period.declaredDates!.length }} {{ tr().bookedDays.booked }} +
{{ period.placementDates.length }}
{{ tr().planning.explanation.leaveDays }} +
{{ period.nonWorkingDates.length }}
{{ tr().planning.explanation.freeDays }} =
<strong
>{{ period.duration }} {{ tr().planning.explanation.daysOff }}</strong
>
</p>
<p>{{ tr().bookedDays.fixed }}</p>
</section>
}
}
} }
<div class="plan-insights"> <div class="plan-insights">
@@ -257,6 +338,13 @@
{{ displayDate(period.start) }} – {{ displayDate(period.end) }} {{ displayDate(period.start) }} – {{ displayDate(period.end) }}
</h4> </h4>
<div class="break-equation"> <div class="break-equation">
@if (period.declaredDates?.length) {
<span
><strong>{{ period.declaredDates!.length }}</strong
>{{ tr().bookedDays.booked }}</span
>
<span>+</span>
}
<span <span
><strong>{{ period.placementDates.length }}</strong ><strong>{{ period.placementDates.length }}</strong
>{{ tr().planning.explanation.leaveDays }}</span >{{ tr().planning.explanation.leaveDays }}</span
@@ -32,6 +32,7 @@ import { addPlanningDays } from '../../services/planning-date';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days'; import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import { import {
CalendarService, CalendarService,
calendarProjectionHorizon,
DayType, DayType,
SelectedDates, SelectedDates,
SelectableDayType, SelectableDayType,
@@ -481,18 +482,14 @@ export class CalendarYear {
protected readonly months = computed(() => { protected readonly months = computed(() => {
const horizon = this.calendarHorizon(); const horizon = this.calendarHorizon();
if (!horizon) return []; if (!horizon) return [];
let first = new Date(`${horizon.start}T00:00:00`); const plan = this.traceState() ?? this.displayedPlan();
let last = new Date(`${horizon.end}T00:00:00`); const projectionHorizon =
!this.declarationEntry() && plan
? (calendarProjectionHorizon(plan) ?? horizon)
: horizon;
const first = new Date(`${projectionHorizon.start}T00:00:00`);
const last = new Date(`${projectionHorizon.end}T00:00:00`);
last.setDate(last.getDate() - 1); last.setDate(last.getDate() - 1);
for (const period of this.declarationEntry()
? []
: (this.displayedPlan()?.timeOffPeriods ?? [])) {
for (const date of period.nonWorkingDates) {
const contextDate = new Date(`${date}T00:00:00`);
if (date < horizon.start && contextDate < first) first = contextDate;
else if (date >= horizon.end && contextDate > last) last = contextDate;
}
}
const result: { monthIndex: number; year: number }[] = []; const result: { monthIndex: number; year: number }[] = [];
const cursor = new Date(first); const cursor = new Date(first);
cursor.setDate(1); cursor.setDate(1);
@@ -711,6 +708,7 @@ export class CalendarYear {
this.planningTrace.set({ this.planningTrace.set({
status: 'failed', status: 'failed',
initialState: { initialState: {
calculationDate: calculation.snapshot.calculationDate,
horizon: calculation.snapshot.horizon, horizon: calculation.snapshot.horizon,
...(calculation.snapshot.declaredDays ...(calculation.snapshot.declaredDays
? { declaredDays: calculation.snapshot.declaredDays } ? { declaredDays: calculation.snapshot.declaredDays }
@@ -1306,6 +1304,7 @@ export class CalendarYear {
})), })),
}; };
const snapshot = (): PlanningTraceState => ({ const snapshot = (): PlanningTraceState => ({
calculationDate: trace.initialState.calculationDate,
horizon: { ...trace.initialState.horizon }, horizon: { ...trace.initialState.horizon },
...(trace.initialState.declaredDays ...(trace.initialState.declaredDays
? { declaredDays: trace.initialState.declaredDays } ? { declaredDays: trace.initialState.declaredDays }
+71 -1
View File
@@ -1,6 +1,12 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core'; import { provideZonelessChangeDetection } from '@angular/core';
import { CalendarService, DayType, SelectedDate, SelectedDates } from './calendar-service'; import {
CalendarService,
calendarProjectionHorizon,
DayType,
SelectedDate,
SelectedDates,
} from './calendar-service';
import { DateRange } from '@angular/material/datepicker'; import { DateRange } from '@angular/material/datepicker';
describe('CalendarService', () => { describe('CalendarService', () => {
@@ -87,6 +93,70 @@ describe('CalendarService', () => {
expect(horizon).toEqual({ start: '2024-04-02', end: '2024-04-03' }); expect(horizon).toEqual({ start: '2024-04-02', end: '2024-04-03' });
}); });
it('projects a declared-only period and adjacent outside declarations while leaving unrelated declarations unselected', async () => {
const { calculatePlanningInternal } = await import('./leave-planner.core');
const horizon = { start: '2024-05-31', end: '2024-06-01' };
const outcome = calculatePlanningInternal({
horizon,
balances: [],
rules: [],
declaredDays: [
{ date: '2024-05-31', type: 'other' },
{ date: '2024-06-03', type: 'cp' },
{ date: '2024-06-05', type: 'rtt' },
],
});
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
const selected = service.selectedDatesForPlan(outcome);
const date = (key: string) => new Date(`${key}T00:00:00`);
expect(selected.getClasses(date('2024-05-31'))).toContain(DayType.OTHER);
expect(selected.getClasses(date('2024-06-01'))).toContain(DayType.WEEKEND);
expect(selected.getClasses(date('2024-06-03'))).toContain(DayType.CP);
expect(selected.isSelected(date('2024-06-05'))).toBe(false);
expect(
selected.datesSelected
.find(
(selection) =>
selection.type === null &&
selection.range.start?.getTime() === date('2024-05-31').getTime(),
)
?.getLengthInDays(),
).toBe(4);
expect(outcome.placements).toEqual([]);
expect(outcome.horizon).toEqual(horizon);
});
it('projects historical declarations from the actual anchors instead of the final plan periods', () => {
const horizon = { start: '2024-07-30', end: '2024-08-01' };
const declaredDays = [
{ date: '2024-08-01', type: 'cp' as const },
{ date: '2024-08-05', type: 'rtt' as const },
];
const before = { horizon, declaredDays, placements: [] };
expect(calendarProjectionHorizon(before)).toEqual(horizon);
expect(service.selectedDatesForPlan(before).isSelected(new Date('2024-08-01'))).toBe(false);
const after = {
...before,
placements: [
{
date: '2024-07-31',
balanceId: 'rtt',
type: 'rtt' as const,
ruleIds: [],
supporting: false,
},
],
};
const selected = service.selectedDatesForPlan(after);
expect(calendarProjectionHorizon(after)).toEqual({
start: horizon.start,
end: '2024-08-02',
});
expect(selected.getClasses(new Date('2024-08-01T00:00:00'))).toContain(DayType.CP);
expect(selected.isSelected(new Date('2024-08-05T00:00:00'))).toBe(false);
expect(horizon).toEqual({ start: '2024-07-30', end: '2024-08-01' });
});
describe('getHolidays', () => { describe('getHolidays', () => {
it('should return an array of holidays', () => { it('should return an array of holidays', () => {
const holidays = service.getHolidays(); const holidays = service.getHolidays();
+41 -17
View File
@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { DateRange } from '@angular/material/datepicker'; import { DateRange } from '@angular/material/datepicker';
import { frenchPublicHolidaysForYear } from './declared-non-working-days'; import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import weekOfYear from 'dayjs/plugin/weekOfYear'; import weekOfYear from 'dayjs/plugin/weekOfYear';
import isoWeek from 'dayjs/plugin/isoWeek'; import isoWeek from 'dayjs/plugin/isoWeek';
@@ -11,6 +11,7 @@ import {
LeaveType, LeaveType,
PlanOutcome, PlanOutcome,
PlanningReason, PlanningReason,
PlanningTraceState,
RemainingClassification, RemainingClassification,
} from './leave-planner.service'; } from './leave-planner.service';
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date'; import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
@@ -18,6 +19,41 @@ import { addPlanningDays, addPlanningYears, planningDateInParis } from './planni
dayjs.extend(weekOfYear); dayjs.extend(weekOfYear);
dayjs.extend(isoWeek); dayjs.extend(isoWeek);
type CalendarPlan = Pick<PlanningTraceState, 'placements' | 'declaredDays'> & {
horizon?: PlanningTraceState['horizon'];
timeOffPeriods?: PlanOutcome['timeOffPeriods'];
};
export function calendarProjectionHorizon(plan: CalendarPlan) {
if (!plan.horizon) return undefined;
const horizon = { ...plan.horizon };
if (plan.timeOffPeriods) {
for (const period of plan.timeOffPeriods) {
if (period.start < horizon.start) horizon.start = period.start;
if (period.end >= horizon.end) horizon.end = addPlanningDays(period.end, 1);
}
return horizon;
}
const declaredDates = new Set(plan.declaredDays?.map(({ date }) => date));
const anchors = [...plan.placements, ...(plan.declaredDays ?? [])]
.map(({ date }) => date)
.filter((date) => date >= horizon.start && date < horizon.end)
.sort();
if (!anchors.length) return horizon;
for (const [anchor, direction] of [
[anchors[0], -1],
[anchors[anchors.length - 1], 1],
] as const) {
let date = addPlanningDays(anchor, direction);
while (declaredDates.has(date) || !isWorkingPlanningDate(date)) {
if (date < horizon.start) horizon.start = date;
if (date >= horizon.end) horizon.end = addPlanningDays(date, 1);
date = addPlanningDays(date, direction);
}
}
return horizon;
}
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
@@ -32,20 +68,8 @@ export class CalendarService {
return { start, end: addPlanningYears(start, 1) }; return { start, end: addPlanningYears(start, 1) };
} }
selectedDatesForPlan( selectedDatesForPlan(plan: CalendarPlan): SelectedDates {
plan: Pick<PlanOutcome, 'placements' | 'declaredDays'> & { const projectionHorizon = calendarProjectionHorizon(plan);
horizon?: { start: string; end: string };
timeOffPeriods?: PlanOutcome['timeOffPeriods'];
},
): SelectedDates {
const projectionHorizon = plan.horizon ? { ...plan.horizon } : undefined;
if (projectionHorizon) {
for (const period of plan.timeOffPeriods ?? []) {
if (period.start < projectionHorizon.start) projectionHorizon.start = period.start;
if (period.end >= projectionHorizon.end)
projectionHorizon.end = addPlanningDays(period.end, 1);
}
}
const selected = projectionHorizon const selected = projectionHorizon
? this.getSelectedDatesForHorizon(projectionHorizon) ? this.getSelectedDatesForHorizon(projectionHorizon)
: this.getSelectedDatesForYear(); : this.getSelectedDatesForYear();
@@ -53,8 +77,8 @@ export class CalendarService {
...plan.placements, ...plan.placements,
...(plan.declaredDays ?? []).filter( ...(plan.declaredDays ?? []).filter(
(day) => (day) =>
!plan.horizon || !projectionHorizon ||
(day.date >= plan.horizon.start && day.date < plan.horizon.end), (day.date >= projectionHorizon.start && day.date < projectionHorizon.end),
), ),
]) { ]) {
selected.push( selected.push(
@@ -11,6 +11,9 @@ const request = {
rules: [], rules: [],
}; };
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
describe('user cancellation', () => { describe('user cancellation', () => {
const workers: CancellableWorker[] = []; const workers: CancellableWorker[] = [];
const currentWorker = () => workers[workers.length - 1]; const currentWorker = () => workers[workers.length - 1];
@@ -43,6 +46,128 @@ describe('user cancellation', () => {
expect((await calculation.trace)?.status).toBe('incomplete'); expect((await calculation.trace)?.status).toBe('incomplete');
}); });
it('retains the same declared-only periods and metrics in a cancelled valid plan', async () => {
const core = await import('./leave-planner.core');
const submitted = {
horizon: { start: '2024-01-02', end: '2024-01-09' },
balances: [],
rules: [],
declaredDays: [{ date: '2024-01-05', type: 'other' as const }],
};
let interrupted = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = await core.calculatePlanningAsyncInternal(
{
...submitted,
progress: (stage: string) => {
if (stage === 'non-working-day-gain') interrupted = true;
},
},
undefined,
(value) => (artifacts = value),
undefined,
undefined,
() => interrupted,
);
const completed = core.calculatePlanningInternal(submitted);
if (
outcome.kind !== 'PlanningIncomplete' ||
!outcome.incumbent ||
completed.kind !== 'Plan'
)
throw new Error('Expected an interrupted valid plan and its completed counterpart.');
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(outcome.incumbent.timeOffPeriods).toEqual(completed.timeOffPeriods);
expect(outcome.incumbent.metrics).toEqual(completed.metrics);
expect(artifacts.explanation).toMatchObject({
kind: 'PlanExplanation',
declaredDays: submitted.declaredDays,
timeOffPeriods: completed.timeOffPeriods,
placements: [],
incomplete: { reason: { code: 'user-cancelled' } },
});
expect(artifacts.trace?.status).toBe('incomplete');
expect(artifacts.finalState?.declaredDays).toEqual(submitted.declaredDays);
});
it('retains compatible fixed rule credits and incompatible declarations in a cancelled valid plan', async () => {
const core = await import('./leave-planner.core');
const submitted = {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [
{
id: 'cp',
type: 'cp' as const,
quantity: 1,
availableFrom: '2024-01-12',
expiryDate: '2025-01-01',
},
],
declaredDays: [
{ date: '2024-01-10', type: 'cp' as const },
{ date: '2024-01-11', type: 'rtt' as const },
],
rules: [
{ id: 'exact-cp', force: 'obligation', placeDays: 2, placeType: 'cp' },
{ id: 'preferred-rtt', force: 'preference', placeDays: 1, placeType: 'rtt' },
].map((rule) => ({
...rule,
type: 'time_window',
startDate: '2024-01-08',
endDate: '2024-01-12',
})),
};
let interrupted = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = await core.calculatePlanningAsyncInternal(
{
...submitted,
progress: (stage: string) => {
if (stage === 'holiday-week-value') interrupted = true;
},
},
undefined,
(value) => (artifacts = value),
undefined,
undefined,
() => interrupted,
);
const completed = core.calculatePlanningInternal(submitted);
if (
outcome.kind !== 'PlanningIncomplete' ||
!outcome.incumbent ||
completed.kind !== 'Plan'
)
throw new Error('Expected an interrupted valid plan and a completed counterpart.');
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(outcome.incumbent.declaredDays).toEqual(submitted.declaredDays);
expect(outcome.incumbent.placements).toMatchObject([
{ date: '2024-01-12', type: 'cp', ruleIds: ['exact-cp'] },
]);
expect(outcome.incumbent.preferences).toMatchObject([{ requested: 1, satisfied: 1 }]);
expect(outcome.incumbent.timeWindowContributions).toEqual(
completed.timeWindowContributions,
);
expect(outcome.incumbent.timeWindowContributions).toMatchObject([
{
ruleId: 'exact-cp',
declaredDays: [submitted.declaredDays[0]],
placementDates: ['2024-01-12'],
},
{
ruleId: 'preferred-rtt',
declaredDays: [submitted.declaredDays[1]],
placementDates: [],
},
]);
expect(artifacts.explanation).toMatchObject({
kind: 'PlanExplanation',
timeWindowContributions: completed.timeWindowContributions,
incomplete: { reason: { code: 'user-cancelled' } },
});
expect(artifacts.finalState?.declaredDays).toEqual(submitted.declaredDays);
});
it('uses the replacement reason consistently in non-browser outcomes and artifacts', async () => { it('uses the replacement reason consistently in non-browser outcomes and artifacts', async () => {
vi.stubGlobal('window', undefined); vi.stubGlobal('window', undefined);
const replaced = createPlanningCalculation(request); const replaced = createPlanningCalculation(request);
@@ -140,7 +265,7 @@ describe('user cancellation', () => {
type: 'result', type: 'result',
outcome: { kind: 'PlanningConflict', conflictingRuleIds: [], reasons: [] }, outcome: { kind: 'PlanningConflict', conflictingRuleIds: [], reasons: [] },
}); });
expect(await calculation.outcome).toEqual(cancelled); expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' });
expect(currentWorker().terminate).not.toHaveBeenCalled(); expect(currentWorker().terminate).not.toHaveBeenCalled();
currentWorker().emit({ type: 'artifacts', artifacts: {} }); currentWorker().emit({ type: 'artifacts', artifacts: {} });
await calculation.trace; await calculation.trace;
@@ -214,7 +339,7 @@ describe('user cancellation', () => {
}, },
}); });
currentWorker().emit({ type: 'artifacts-complete' }); currentWorker().emit({ type: 'artifacts-complete' });
expect(await calculation.outcome).toEqual(cancelled); expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' });
expect(await calculation.explanationError).toMatchObject({ code: 'explanation-error' }); expect(await calculation.explanationError).toMatchObject({ code: 'explanation-error' });
expect(await calculation.trace).toEqual(trace); expect(await calculation.trace).toEqual(trace);
}); });
@@ -280,7 +405,7 @@ describe('user cancellation', () => {
}; };
currentWorker().emit({ type: 'result', outcome }); currentWorker().emit({ type: 'result', outcome });
currentWorker().emit({ type: 'artifacts', artifacts: {} }); currentWorker().emit({ type: 'artifacts', artifacts: {} });
expect(await newer.outcome).toEqual(outcome); expect(await newer.outcome).toEqual({ ...outcome, calculationDate: '2024-01-01' });
await newer.trace; await newer.trace;
const sent = currentWorker().postMessage.mock.calls.length; const sent = currentWorker().postMessage.mock.calls.length;
newer.cancel(); newer.cancel();
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPlanningCalculation } from './leave-planner.service'; import { createPlanningCalculation, applyReplayDeltas } from './leave-planner.service';
const balance = ( const balance = (
id: string, id: string,
@@ -12,7 +12,245 @@ const balance = (
beforeEach(() => vi.stubGlobal('window', undefined)); beforeEach(() => vi.stubGlobal('window', undefined));
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
describe('planner explanation and trace seam', () => { describe('planner explanation and trace seam', () => {
it('replays a reported occurrence without borrowing its future balance from the final plan', async () => {
vi.setSystemTime(new Date('2024-05-03T12:00:00Z'));
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
{
horizon: { start: '2024-04-29', end: '2024-05-08' },
declaredDays: [
{ date: '2024-04-29', type: 'cp' },
{ date: '2024-04-30', type: 'cp' },
{ date: '2024-05-03', type: 'other' },
],
balances: [balance('cp', 'cp', 1, '2025-12-31')],
rules: [
{
id: 'producer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placement: 'before',
placeDays: 2,
placeType: 'cp',
rewardDays: 1,
rewardType: 'other',
},
{
id: 'extend',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-02',
endDate: '2024-05-02',
placeDays: 1,
placeType: 'cp',
},
{
id: 'consumer',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-29',
endDate: '2024-05-07',
placeDays: 2,
placeType: 'other',
},
],
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState)
throw new Error('Expected the extended generator to finance a traced plan.');
const replay = {
balances: artifacts.trace.initialState.balances.map((item) => ({ ...item })),
placements: new Map(
artifacts.trace.initialState.placements.map((item) => [item.date, item]),
),
earnedActivations: [...artifacts.trace.initialState.earnedActivations],
};
const earnedId = 'earned:producer:2024-05-01';
let recognized = false;
for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions)) {
applyReplayDeltas(replay, decision.deltas, artifacts.finalState);
if (
decision.deltas.some(
({ kind, balanceId }) => kind === 'earned-activated' && balanceId === earnedId,
)
) {
recognized = true;
expect(replay.earnedActivations.map(({ id }) => id)).toContain(earnedId);
expect(replay.balances.map(({ id }) => id)).not.toContain(earnedId);
}
}
expect(recognized).toBe(true);
expect(replay.balances).toEqual(artifacts.finalState.balances);
expect([...replay.placements.values()]).toEqual(artifacts.finalState.placements);
expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations);
expect(replay.balances.find(({ id }) => id === earnedId)).toMatchObject({
remaining: 0,
availableFrom: '2024-05-06',
expiryDate: '2025-05-06',
});
});
it.each([5, 8])(
'keeps %s unrelated obligation branches linear when a declared entitlement is already reported',
async (count) => {
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
const core = await import('./leave-planner.core');
const dates = [
'2024-04-17',
'2024-04-18',
'2024-04-19',
'2024-04-22',
'2024-04-23',
'2024-04-24',
'2024-04-25',
'2024-04-26',
].slice(0, count);
const branchCounts: number[] = [];
for (const rewardDays of [0, 1]) {
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
{
horizon: { start: '2024-04-17', end: '2024-05-08' },
declaredDays: [
{ date: '2024-04-29', type: 'cp' },
{ date: '2024-04-30', type: 'cp' },
],
balances: [
{
id: 'other',
type: 'other',
quantity: dates.length,
availableFrom: '2024-04-17',
expiryDate: '2025-12-31',
},
],
rules: [
...dates.map((date) => ({
id: date,
type: 'time_window' as const,
force: 'obligation' as const,
startDate: date,
endDate: date,
placeDays: 1,
placeType: 'other' as const,
})),
{
id: 'producer',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placement: 'before',
placeDays: 2,
placeType: 'cp',
rewardDays,
rewardType: 'other',
},
],
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace)
throw new Error('Expected a traced completed plan.');
expect(outcome.placements.map(({ date }) => date)).toEqual(dates);
expect(outcome.remainingBalances).toEqual([]);
branchCounts.push(
artifacts.trace.phases
.find(({ name }) => name === 'obligations')
?.decisions.filter(({ deltas }) =>
deltas.some(({ placement }) => placement !== undefined),
).length ?? 0,
);
if (rewardDays)
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
availableFrom: '2024-05-02',
added: false,
});
}
expect(branchCounts[0]).toBeGreaterThan(0);
expect(branchCounts[1]).toBe(branchCounts[0]);
},
);
it('removes an entitlement from the original replay when a real quality move shortens its generator into today', async () => {
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
{
horizon: { start: '2024-05-01', end: '2024-05-11' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 1,
availableFrom: '2024-05-06',
expiryDate: '2024-05-07',
},
],
declaredDays: [
{ date: '2024-05-02', type: 'cp' },
{ date: '2024-05-03', type: 'cp' },
],
rules: [
{
id: 'producer',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placeDays: 2,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'other',
},
],
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState)
throw new Error('Expected a traced completed plan.');
expect(outcome.placements).toMatchObject([{ date: '2024-05-07', balanceId: 'cp' }]);
expect(outcome.remainingBalances).toEqual([]);
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
availableFrom: '2024-05-06',
added: false,
});
const decisions = artifacts.trace.phases.flatMap(({ decisions }) => decisions);
expect(decisions.flatMap(({ deltas }) => deltas)).toContainEqual({
kind: 'balance-removed',
balanceId: 'earned:producer:2024-05-01',
remainingBefore: 1,
});
const initial = artifacts.trace.initialState;
const replay = {
balances: initial.balances.map((balance) => ({ ...balance })),
placements: new Map(
initial.placements.map((placement) => [
placement.date,
{ ...placement, ruleIds: [...placement.ruleIds] },
]),
),
earnedActivations: initial.earnedActivations.map((activation) => ({
...activation,
dates: [...activation.dates],
})),
};
for (const decision of decisions) applyReplayDeltas(replay, decision.deltas);
expect(replay.balances).toEqual(artifacts.finalState.balances);
expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations);
expect([...replay.placements.values()]).toEqual(outcome.placements);
});
it('records only actual evaluations and publishes non-working dates as metadata', async () => { it('records only actual evaluations and publishes non-working dates as metadata', async () => {
const calculation = createPlanningCalculation({ const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-07' }, horizon: { start: '2024-01-01', end: '2024-01-07' },
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+25 -5
View File
@@ -28,9 +28,11 @@ import type {
TraceCandidateStatus, TraceCandidateStatus,
TraceCounterfactualSelection, TraceCounterfactualSelection,
} from './leave-planner.core'; } from './leave-planner.core';
import { planningDateInParis } from './planning-date';
export type { export type {
IfThenPlanningRule, IfThenPlanningRule,
IfThenContribution,
ConflictExplanation, ConflictExplanation,
CounterfactualAnalysis, CounterfactualAnalysis,
CounterfactualSelection, CounterfactualSelection,
@@ -76,6 +78,7 @@ export type {
RemainingClassification, RemainingClassification,
RuleForce, RuleForce,
TimeOffPeriod, TimeOffPeriod,
TimeWindowContribution,
TimeWindowPlanningRule, TimeWindowPlanningRule,
TraceCounterfactualSelection, TraceCounterfactualSelection,
TraceCandidateStatus, TraceCandidateStatus,
@@ -117,6 +120,7 @@ interface UntrustedRecord {
} }
interface TransportSnapshot { interface TransportSnapshot {
calculationDate?: string;
horizon?: { start?: unknown; end?: unknown }; horizon?: { start?: unknown; end?: unknown };
balances?: unknown; balances?: unknown;
declaredDays?: unknown; declaredDays?: unknown;
@@ -154,7 +158,7 @@ function cloneUnknown(value: unknown, seen = new WeakMap<object, unknown>()): un
function snapshotValue(value: unknown): unknown { function snapshotValue(value: unknown): unknown {
if (value instanceof Date) if (value instanceof Date)
return Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString(); return Number.isNaN(value.getTime()) ? 'Invalid Date' : planningDateInParis(value);
if (Array.isArray(value)) return value.map(snapshotValue); if (Array.isArray(value)) return value.map(snapshotValue);
if (typeof value !== 'object' || value === null) return value; if (typeof value !== 'object' || value === null) return value;
const result: Record<string, unknown> = {}; const result: Record<string, unknown> = {};
@@ -189,6 +193,7 @@ function publicSnapshot(request: TransportSnapshot): PlanningSnapshot {
: []; : [];
const labels = stringMap(request.labels); const labels = stringMap(request.labels);
return deepFreeze({ return deepFreeze({
calculationDate: request.calculationDate,
horizon: { horizon: {
start: String(snapshotValue(horizon.start) ?? ''), start: String(snapshotValue(horizon.start) ?? ''),
end: String(snapshotValue(horizon.end) ?? ''), end: String(snapshotValue(horizon.end) ?? ''),
@@ -355,6 +360,7 @@ function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState {
const horizon = snapshot.horizon ?? {}; const horizon = snapshot.horizon ?? {};
const balances = Array.isArray(snapshot.balances) ? snapshot.balances : []; const balances = Array.isArray(snapshot.balances) ? snapshot.balances : [];
return { return {
calculationDate: snapshot.calculationDate,
horizon: { horizon: {
start: String(horizon.start ?? ''), start: String(horizon.start ?? ''),
end: String(horizon.end ?? ''), end: String(horizon.end ?? ''),
@@ -1043,6 +1049,10 @@ export function applyReplayDeltas(
ruleIds: [...delta.placement.ruleIds], ruleIds: [...delta.placement.ruleIds],
}); });
const balanceId = delta.balanceId ?? delta.balance?.id; const balanceId = delta.balanceId ?? delta.balance?.id;
if (delta.kind === 'balance-removed') {
state.balances = state.balances.filter((balance) => balance.id !== balanceId);
continue;
}
if (balanceId) { if (balanceId) {
let index = state.balances.findIndex((candidate) => candidate.id === balanceId); let index = state.balances.findIndex((candidate) => candidate.id === balanceId);
const changedBalance = delta.balance?.id === balanceId ? delta.balance : undefined; const changedBalance = delta.balance?.id === balanceId ? delta.balance : undefined;
@@ -1050,7 +1060,7 @@ export function applyReplayDeltas(
state.balances.push({ ...changedBalance }); state.balances.push({ ...changedBalance });
index = state.balances.length - 1; index = state.balances.length - 1;
} }
if (index < 0) { if (index < 0 && delta.kind !== 'earned-activated') {
const source = finalState?.balances.find((candidate) => candidate.id === balanceId); const source = finalState?.balances.find((candidate) => candidate.id === balanceId);
if (source) { if (source) {
state.balances.push({ ...source }); state.balances.push({ ...source });
@@ -1082,6 +1092,7 @@ export function applyReplayDeltas(
function replayedState(trace: PlanningTrace, state: ReplayState): PlanningTraceState { function replayedState(trace: PlanningTrace, state: ReplayState): PlanningTraceState {
return { return {
calculationDate: trace.initialState.calculationDate,
horizon: { ...trace.initialState.horizon }, horizon: { ...trace.initialState.horizon },
...(trace.initialState.declaredDays ...(trace.initialState.declaredDays
? { declaredDays: trace.initialState.declaredDays } ? { declaredDays: trace.initialState.declaredDays }
@@ -1562,8 +1573,9 @@ export interface PlanningCalculationContext {
} }
export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext { export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext {
const calculationDate = planningDateInParis(new Date());
cancelLatestWorkers(); cancelLatestWorkers();
const transport = snapshotRequest(request); const transport = { ...snapshotRequest(request), calculationDate };
const snapshot = publicSnapshot(transport); const snapshot = publicSnapshot(transport);
const id = `calculation:${++calculationSequence}`; const id = `calculation:${++calculationSequence}`;
latestCalculationId = id; latestCalculationId = id;
@@ -1764,8 +1776,16 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
id, id,
snapshot, snapshot,
cancel: () => run.cancel?.(), cancel: () => run.cancel?.(),
outcome: run.outcome, outcome: run.outcome.then((result) => ({
explanation: run.explanation, ...result,
calculationDate,
...(result.kind === 'PlanningIncomplete' && result.incumbent
? { incumbent: { ...result.incumbent, calculationDate } }
: {}),
})),
explanation: run.explanation.then((result) =>
result ? { ...result, calculationDate } : undefined,
),
explanationError: run.explanationError, explanationError: run.explanationError,
trace: retainedArtifacts.then((artifacts) => artifacts.trace), trace: retainedArtifacts.then((artifacts) => artifacts.trace),
timings: run.timings, timings: run.timings,
+220 -1
View File
@@ -5,13 +5,28 @@ import { join, resolve } from 'node:path';
import { Worker as NodeWorker } from 'node:worker_threads'; import { Worker as NodeWorker } from 'node:worker_threads';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { vi } from 'vitest'; 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', () => { describe('real planning worker cancellation', () => {
let directory: string; let directory: string;
let bundle: string; let bundle: string;
let afterCheckpoint: (() => void) | undefined; let afterCheckpoint: (() => void) | undefined;
let heartbeats = 0; let heartbeats = 0;
let holdArtifacts = false;
const heldArtifactMessages: (() => void)[] = [];
let simulateLongCalculation = false; let simulateLongCalculation = false;
let simulatedElapsed = 0; let simulatedElapsed = 0;
const threads = new Set<NodeWorker>(); const threads = new Set<NodeWorker>();
@@ -41,6 +56,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
beforeEach(() => { beforeEach(() => {
afterCheckpoint = undefined; afterCheckpoint = undefined;
holdArtifacts = false;
heldArtifactMessages.length = 0;
localStorage.clear();
heartbeats = 0; heartbeats = 0;
simulateLongCalculation = false; simulateLongCalculation = false;
simulatedElapsed = 0; simulatedElapsed = 0;
@@ -55,6 +73,18 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
simulatedElapsed = data.elapsed; simulatedElapsed = data.elapsed;
return; 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++; if (data.type === 'heartbeat') heartbeats++;
this.onmessage?.({ data } as MessageEvent); this.onmessage?.({ data } as MessageEvent);
if (data.type === 'checkpoint') afterCheckpoint?.(); if (data.type === 'checkpoint') afterCheckpoint?.();
@@ -74,10 +104,196 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
afterEach(async () => { afterEach(async () => {
await Promise.all([...threads].map((thread) => thread.terminate())); await Promise.all([...threads].map((thread) => thread.terminate()));
threads.clear(); threads.clear();
localStorage.clear();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
afterAll(() => rmSync(directory, { recursive: true, force: true })); 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])( it.each([false, true])(
'keeps declarations fixed in direct and worker planning and date comparisons (cancelled: %s)', 'keeps declarations fixed in direct and worker planning and date comparisons (cancelled: %s)',
async (cancelled) => { async (cancelled) => {
@@ -219,6 +435,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
let cancelledAt = 0; let cancelledAt = 0;
afterCheckpoint = () => { afterCheckpoint = () => {
afterCheckpoint = undefined; afterCheckpoint = undefined;
holdArtifacts = false;
heldArtifactMessages.length = 0;
localStorage.clear();
setTimeout(() => { setTimeout(() => {
cancelledAt = performance.now(); cancelledAt = performance.now();
calculation.cancel(); calculation.cancel();
+2
View File
@@ -23,6 +23,8 @@ import {
} from './leave-planner.core'; } from './leave-planner.core';
type WorkerPlanningRequest = { type WorkerPlanningRequest = {
calculationDate?: string;
declaredDays?: unknown;
horizon?: { start?: unknown; end?: unknown }; horizon?: { start?: unknown; end?: unknown };
balances?: unknown; balances?: unknown;
rules?: unknown; rules?: unknown;
+3
View File
@@ -128,6 +128,9 @@ export interface AppTranslations {
}; };
}; };
planning: { planning: {
calculationDate: string;
futureEntitlement: string;
includedEntitlement: string;
comparison: { comparison: {
title: string; title: string;
candidate: string; candidate: string;
+3
View File
@@ -127,6 +127,9 @@
} }
}, },
"planning": { "planning": {
"calculationDate": "Berechnungsdatum",
"futureEntitlement": "Erzeugter künftiger Anspruch",
"includedEntitlement": "Anspruch bereits in den gemeldeten Guthaben enthalten",
"title": "Ihr optimiertes Jahr", "title": "Ihr optimiertes Jahr",
"daysPlaced": "Eingeplante Urlaubstage", "daysPlaced": "Eingeplante Urlaubstage",
"daysGained": "Gewonnene arbeitsfreie Tage", "daysGained": "Gewonnene arbeitsfreie Tage",
+3
View File
@@ -127,6 +127,9 @@
} }
}, },
"planning": { "planning": {
"calculationDate": "Calculation date",
"futureEntitlement": "Future entitlement generated",
"includedEntitlement": "Entitlement already included in reported balances",
"title": "Your optimized year", "title": "Your optimized year",
"daysPlaced": "Leave days placed", "daysPlaced": "Leave days placed",
"daysGained": "Non-working days gained", "daysGained": "Non-working days gained",
+3
View File
@@ -127,6 +127,9 @@
} }
}, },
"planning": { "planning": {
"calculationDate": "Fecha del cálculo",
"futureEntitlement": "Derecho futuro generado",
"includedEntitlement": "Derecho ya incluido en los saldos declarados",
"title": "Tu año optimizado", "title": "Tu año optimizado",
"daysPlaced": "Días de vacaciones asignados", "daysPlaced": "Días de vacaciones asignados",
"daysGained": "Días no laborables ganados", "daysGained": "Días no laborables ganados",
+3
View File
@@ -127,6 +127,9 @@
} }
}, },
"planning": { "planning": {
"calculationDate": "Date du calcul",
"futureEntitlement": "Droit futur généré",
"includedEntitlement": "Droit déjà inclus dans les soldes reportés",
"title": "Votre année optimisée", "title": "Votre année optimisée",
"daysPlaced": "Jours de congé placés", "daysPlaced": "Jours de congé placés",
"daysGained": "Jours non travaillés gagnés", "daysGained": "Jours non travaillés gagnés",