- 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';
|
} 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 }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user