- 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
+3330
-212
No files matched your search
@@ -7,6 +7,8 @@ import {
|
||||
} from './leave-planner-benchmark';
|
||||
|
||||
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.
|
||||
it('keeps seven deterministic scenarios including two and three years and a later earned consumer', async () => {
|
||||
const scenarios = benchmarkScenarios();
|
||||
|
||||
@@ -407,6 +407,288 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
beforeEach(() => 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 () => {
|
||||
localStorage.setItem('app.language', '"fr"');
|
||||
localStorage.setItem('leaveBalances', '[]');
|
||||
|
||||
@@ -146,6 +146,87 @@
|
||||
<dd>{{ plan.metrics.longestWorkSequence }}</dd>
|
||||
</div>
|
||||
</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">
|
||||
@@ -257,6 +338,13 @@
|
||||
{{ displayDate(period.start) }} – {{ displayDate(period.end) }}
|
||||
</h4>
|
||||
<div class="break-equation">
|
||||
@if (period.declaredDates?.length) {
|
||||
<span
|
||||
><strong>{{ period.declaredDates!.length }}</strong
|
||||
>{{ tr().bookedDays.booked }}</span
|
||||
>
|
||||
<span>+</span>
|
||||
}
|
||||
<span
|
||||
><strong>{{ period.placementDates.length }}</strong
|
||||
>{{ tr().planning.explanation.leaveDays }}</span
|
||||
|
||||
@@ -32,6 +32,7 @@ import { addPlanningDays } from '../../services/planning-date';
|
||||
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
|
||||
import {
|
||||
CalendarService,
|
||||
calendarProjectionHorizon,
|
||||
DayType,
|
||||
SelectedDates,
|
||||
SelectableDayType,
|
||||
@@ -481,18 +482,14 @@ export class CalendarYear {
|
||||
protected readonly months = computed(() => {
|
||||
const horizon = this.calendarHorizon();
|
||||
if (!horizon) return [];
|
||||
let first = new Date(`${horizon.start}T00:00:00`);
|
||||
let last = new Date(`${horizon.end}T00:00:00`);
|
||||
const plan = this.traceState() ?? this.displayedPlan();
|
||||
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);
|
||||
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 cursor = new Date(first);
|
||||
cursor.setDate(1);
|
||||
@@ -711,6 +708,7 @@ export class CalendarYear {
|
||||
this.planningTrace.set({
|
||||
status: 'failed',
|
||||
initialState: {
|
||||
calculationDate: calculation.snapshot.calculationDate,
|
||||
horizon: calculation.snapshot.horizon,
|
||||
...(calculation.snapshot.declaredDays
|
||||
? { declaredDays: calculation.snapshot.declaredDays }
|
||||
@@ -1306,6 +1304,7 @@ export class CalendarYear {
|
||||
})),
|
||||
};
|
||||
const snapshot = (): PlanningTraceState => ({
|
||||
calculationDate: trace.initialState.calculationDate,
|
||||
horizon: { ...trace.initialState.horizon },
|
||||
...(trace.initialState.declaredDays
|
||||
? { declaredDays: trace.initialState.declaredDays }
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
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';
|
||||
|
||||
describe('CalendarService', () => {
|
||||
@@ -87,6 +93,70 @@ describe('CalendarService', () => {
|
||||
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', () => {
|
||||
it('should return an array of holidays', () => {
|
||||
const holidays = service.getHolidays();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
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 weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
LeaveType,
|
||||
PlanOutcome,
|
||||
PlanningReason,
|
||||
PlanningTraceState,
|
||||
RemainingClassification,
|
||||
} from './leave-planner.service';
|
||||
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
|
||||
@@ -18,6 +19,41 @@ import { addPlanningDays, addPlanningYears, planningDateInParis } from './planni
|
||||
dayjs.extend(weekOfYear);
|
||||
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({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -32,20 +68,8 @@ export class CalendarService {
|
||||
return { start, end: addPlanningYears(start, 1) };
|
||||
}
|
||||
|
||||
selectedDatesForPlan(
|
||||
plan: Pick<PlanOutcome, 'placements' | 'declaredDays'> & {
|
||||
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);
|
||||
}
|
||||
}
|
||||
selectedDatesForPlan(plan: CalendarPlan): SelectedDates {
|
||||
const projectionHorizon = calendarProjectionHorizon(plan);
|
||||
const selected = projectionHorizon
|
||||
? this.getSelectedDatesForHorizon(projectionHorizon)
|
||||
: this.getSelectedDatesForYear();
|
||||
@@ -53,8 +77,8 @@ export class CalendarService {
|
||||
...plan.placements,
|
||||
...(plan.declaredDays ?? []).filter(
|
||||
(day) =>
|
||||
!plan.horizon ||
|
||||
(day.date >= plan.horizon.start && day.date < plan.horizon.end),
|
||||
!projectionHorizon ||
|
||||
(day.date >= projectionHorizon.start && day.date < projectionHorizon.end),
|
||||
),
|
||||
]) {
|
||||
selected.push(
|
||||
|
||||
@@ -11,6 +11,9 @@ const request = {
|
||||
rules: [],
|
||||
};
|
||||
|
||||
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('user cancellation', () => {
|
||||
const workers: CancellableWorker[] = [];
|
||||
const currentWorker = () => workers[workers.length - 1];
|
||||
@@ -43,6 +46,128 @@ describe('user cancellation', () => {
|
||||
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 () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
const replaced = createPlanningCalculation(request);
|
||||
@@ -140,7 +265,7 @@ describe('user cancellation', () => {
|
||||
type: 'result',
|
||||
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();
|
||||
currentWorker().emit({ type: 'artifacts', artifacts: {} });
|
||||
await calculation.trace;
|
||||
@@ -214,7 +339,7 @@ describe('user cancellation', () => {
|
||||
},
|
||||
});
|
||||
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.trace).toEqual(trace);
|
||||
});
|
||||
@@ -280,7 +405,7 @@ describe('user cancellation', () => {
|
||||
};
|
||||
currentWorker().emit({ type: 'result', outcome });
|
||||
currentWorker().emit({ type: 'artifacts', artifacts: {} });
|
||||
expect(await newer.outcome).toEqual(outcome);
|
||||
expect(await newer.outcome).toEqual({ ...outcome, calculationDate: '2024-01-01' });
|
||||
await newer.trace;
|
||||
const sent = currentWorker().postMessage.mock.calls.length;
|
||||
newer.cancel();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPlanningCalculation } from './leave-planner.service';
|
||||
import { createPlanningCalculation, applyReplayDeltas } from './leave-planner.service';
|
||||
|
||||
const balance = (
|
||||
id: string,
|
||||
@@ -12,7 +12,245 @@ const balance = (
|
||||
beforeEach(() => vi.stubGlobal('window', undefined));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
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 () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
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
@@ -28,9 +28,11 @@ import type {
|
||||
TraceCandidateStatus,
|
||||
TraceCounterfactualSelection,
|
||||
} from './leave-planner.core';
|
||||
import { planningDateInParis } from './planning-date';
|
||||
|
||||
export type {
|
||||
IfThenPlanningRule,
|
||||
IfThenContribution,
|
||||
ConflictExplanation,
|
||||
CounterfactualAnalysis,
|
||||
CounterfactualSelection,
|
||||
@@ -76,6 +78,7 @@ export type {
|
||||
RemainingClassification,
|
||||
RuleForce,
|
||||
TimeOffPeriod,
|
||||
TimeWindowContribution,
|
||||
TimeWindowPlanningRule,
|
||||
TraceCounterfactualSelection,
|
||||
TraceCandidateStatus,
|
||||
@@ -117,6 +120,7 @@ interface UntrustedRecord {
|
||||
}
|
||||
|
||||
interface TransportSnapshot {
|
||||
calculationDate?: string;
|
||||
horizon?: { start?: unknown; end?: unknown };
|
||||
balances?: unknown;
|
||||
declaredDays?: unknown;
|
||||
@@ -154,7 +158,7 @@ function cloneUnknown(value: unknown, seen = new WeakMap<object, unknown>()): un
|
||||
|
||||
function snapshotValue(value: unknown): unknown {
|
||||
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 (typeof value !== 'object' || value === null) return value;
|
||||
const result: Record<string, unknown> = {};
|
||||
@@ -189,6 +193,7 @@ function publicSnapshot(request: TransportSnapshot): PlanningSnapshot {
|
||||
: [];
|
||||
const labels = stringMap(request.labels);
|
||||
return deepFreeze({
|
||||
calculationDate: request.calculationDate,
|
||||
horizon: {
|
||||
start: String(snapshotValue(horizon.start) ?? ''),
|
||||
end: String(snapshotValue(horizon.end) ?? ''),
|
||||
@@ -355,6 +360,7 @@ function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState {
|
||||
const horizon = snapshot.horizon ?? {};
|
||||
const balances = Array.isArray(snapshot.balances) ? snapshot.balances : [];
|
||||
return {
|
||||
calculationDate: snapshot.calculationDate,
|
||||
horizon: {
|
||||
start: String(horizon.start ?? ''),
|
||||
end: String(horizon.end ?? ''),
|
||||
@@ -1043,6 +1049,10 @@ export function applyReplayDeltas(
|
||||
ruleIds: [...delta.placement.ruleIds],
|
||||
});
|
||||
const balanceId = delta.balanceId ?? delta.balance?.id;
|
||||
if (delta.kind === 'balance-removed') {
|
||||
state.balances = state.balances.filter((balance) => balance.id !== balanceId);
|
||||
continue;
|
||||
}
|
||||
if (balanceId) {
|
||||
let index = state.balances.findIndex((candidate) => candidate.id === balanceId);
|
||||
const changedBalance = delta.balance?.id === balanceId ? delta.balance : undefined;
|
||||
@@ -1050,7 +1060,7 @@ export function applyReplayDeltas(
|
||||
state.balances.push({ ...changedBalance });
|
||||
index = state.balances.length - 1;
|
||||
}
|
||||
if (index < 0) {
|
||||
if (index < 0 && delta.kind !== 'earned-activated') {
|
||||
const source = finalState?.balances.find((candidate) => candidate.id === balanceId);
|
||||
if (source) {
|
||||
state.balances.push({ ...source });
|
||||
@@ -1082,6 +1092,7 @@ export function applyReplayDeltas(
|
||||
|
||||
function replayedState(trace: PlanningTrace, state: ReplayState): PlanningTraceState {
|
||||
return {
|
||||
calculationDate: trace.initialState.calculationDate,
|
||||
horizon: { ...trace.initialState.horizon },
|
||||
...(trace.initialState.declaredDays
|
||||
? { declaredDays: trace.initialState.declaredDays }
|
||||
@@ -1562,8 +1573,9 @@ export interface PlanningCalculationContext {
|
||||
}
|
||||
|
||||
export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext {
|
||||
const calculationDate = planningDateInParis(new Date());
|
||||
cancelLatestWorkers();
|
||||
const transport = snapshotRequest(request);
|
||||
const transport = { ...snapshotRequest(request), calculationDate };
|
||||
const snapshot = publicSnapshot(transport);
|
||||
const id = `calculation:${++calculationSequence}`;
|
||||
latestCalculationId = id;
|
||||
@@ -1764,8 +1776,16 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
||||
id,
|
||||
snapshot,
|
||||
cancel: () => run.cancel?.(),
|
||||
outcome: run.outcome,
|
||||
explanation: run.explanation,
|
||||
outcome: run.outcome.then((result) => ({
|
||||
...result,
|
||||
calculationDate,
|
||||
...(result.kind === 'PlanningIncomplete' && result.incumbent
|
||||
? { incumbent: { ...result.incumbent, calculationDate } }
|
||||
: {}),
|
||||
})),
|
||||
explanation: run.explanation.then((result) =>
|
||||
result ? { ...result, calculationDate } : undefined,
|
||||
),
|
||||
explanationError: run.explanationError,
|
||||
trace: retainedArtifacts.then((artifacts) => artifacts.trace),
|
||||
timings: run.timings,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from './leave-planner.core';
|
||||
|
||||
type WorkerPlanningRequest = {
|
||||
calculationDate?: string;
|
||||
declaredDays?: unknown;
|
||||
horizon?: { start?: unknown; end?: unknown };
|
||||
balances?: unknown;
|
||||
rules?: unknown;
|
||||
|
||||
@@ -128,6 +128,9 @@ export interface AppTranslations {
|
||||
};
|
||||
};
|
||||
planning: {
|
||||
calculationDate: string;
|
||||
futureEntitlement: string;
|
||||
includedEntitlement: string;
|
||||
comparison: {
|
||||
title: string;
|
||||
candidate: string;
|
||||
|
||||
@@ -127,6 +127,9 @@
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
"calculationDate": "Berechnungsdatum",
|
||||
"futureEntitlement": "Erzeugter künftiger Anspruch",
|
||||
"includedEntitlement": "Anspruch bereits in den gemeldeten Guthaben enthalten",
|
||||
"title": "Ihr optimiertes Jahr",
|
||||
"daysPlaced": "Eingeplante Urlaubstage",
|
||||
"daysGained": "Gewonnene arbeitsfreie Tage",
|
||||
|
||||
@@ -127,6 +127,9 @@
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
"calculationDate": "Calculation date",
|
||||
"futureEntitlement": "Future entitlement generated",
|
||||
"includedEntitlement": "Entitlement already included in reported balances",
|
||||
"title": "Your optimized year",
|
||||
"daysPlaced": "Leave days placed",
|
||||
"daysGained": "Non-working days gained",
|
||||
|
||||
@@ -127,6 +127,9 @@
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
"calculationDate": "Fecha del cálculo",
|
||||
"futureEntitlement": "Derecho futuro generado",
|
||||
"includedEntitlement": "Derecho ya incluido en los saldos declarados",
|
||||
"title": "Tu año optimizado",
|
||||
"daysPlaced": "Días de vacaciones asignados",
|
||||
"daysGained": "Días no laborables ganados",
|
||||
|
||||
@@ -127,6 +127,9 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"daysPlaced": "Jours de congé placés",
|
||||
"daysGained": "Jours non travaillés gagnés",
|
||||
|
||||
Reference in new issue
Block a user