feat(planner): implement cancellation for planning calculations
- Refactor leave-planner.worker.ts to support cancellation of ongoing calculations. - Introduce `calculatePlanningAsyncInternal` for asynchronous planning calculations. - Add cancellation handling in the worker message event. - Update planning-date.ts with new utility functions for date manipulation. - Extend translation-service.ts and translation files (en.json, de.json, es.json, fr.json) to include new strings related to planning horizon and cancellation. - Create documentation for the cancellation feature in planner-cancellation.md. - Add unit tests for cancellation functionality in leave-planner-cancellation.spec.ts and leave-planner.worker.spec.ts. #57 #58
This commit is contained in:
1 parent
5c35a59805
commit
2edf2004f5
31 files changed
+2275
-304
No files matched your search
@@ -79,7 +79,7 @@ export class CalendarMonth implements OnInit {
|
||||
|
||||
getDateKey(day: Date): string {
|
||||
return [
|
||||
day.getFullYear(),
|
||||
String(day.getFullYear()).padStart(4, '0'),
|
||||
String(day.getMonth() + 1).padStart(2, '0'),
|
||||
String(day.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
PlanningTrace,
|
||||
} from '../../services/leave-planner.service';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { RuleService, RuleType } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { DateRange } from '@angular/material/datepicker';
|
||||
|
||||
@@ -182,6 +184,7 @@ function calculationFor(
|
||||
};
|
||||
return {
|
||||
id: 'test',
|
||||
cancel: vi.fn(),
|
||||
snapshot: {
|
||||
horizon: plan.horizon,
|
||||
balances: [],
|
||||
@@ -217,6 +220,7 @@ function calendarFor(
|
||||
calculation,
|
||||
calendar: {
|
||||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
||||
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
|
||||
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
|
||||
createPlanningCalculation: vi.fn(() => calculation),
|
||||
selectedDatesForPlan: vi.fn(selectedDatesForPlan),
|
||||
@@ -397,6 +401,265 @@ function jointTrace(): PlanningTrace {
|
||||
}
|
||||
|
||||
describe('CalendarYear planning outcome boundary', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it.each([false, true])(
|
||||
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
|
||||
async (withIncumbent) => {
|
||||
const pending = deferred<PlanningOutcome>();
|
||||
const delayedTrace = deferred<PlanningTrace | undefined>();
|
||||
const cancelled: PlanningIncompleteOutcome = {
|
||||
kind: 'PlanningIncomplete',
|
||||
incumbent: withIncumbent ? plan : undefined,
|
||||
completedStages: ['validation'],
|
||||
reasons: [{ code: 'user-cancelled', message: 'The calculation was cancelled.' }],
|
||||
};
|
||||
const { calendar, calculation } = calendarFor(
|
||||
cancelled,
|
||||
undefined,
|
||||
undefined,
|
||||
delayedTrace.promise,
|
||||
);
|
||||
calendar.createPlanningCalculation
|
||||
.mockReturnValueOnce({ ...calculation, outcome: pending.promise })
|
||||
.mockReturnValueOnce(calculationFor(plan, explanation));
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: CalendarService, useValue: calendar },
|
||||
],
|
||||
}).compileComponents();
|
||||
TestBed.inject(TranslationService).language.set('fr');
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const cancel = root.querySelector<HTMLButtonElement>('.cancel-calculation');
|
||||
expect(cancel?.textContent).toContain('Annuler');
|
||||
activateWithKeyboard(cancel!);
|
||||
fixture.detectChanges();
|
||||
expect(calculation.cancel).toHaveBeenCalledOnce();
|
||||
expect(cancel?.disabled).toBe(true);
|
||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||||
pending.resolve(cancelled);
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector('.planning-status')?.textContent).toContain(
|
||||
withIncumbent ? 'Calcul annulé — plan partiel' : 'Calcul annulé',
|
||||
);
|
||||
expect(root.querySelectorAll('button.selected')).toHaveLength(withIncumbent ? 2 : 0);
|
||||
expect(root.querySelector('.loading-overlay')).toBeNull();
|
||||
expect(document.activeElement).toBe(root.querySelector('#planning-results-title'));
|
||||
TestBed.inject(UserInputService).requestComputation();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledTimes(2);
|
||||
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
|
||||
delayedTrace.resolve({ ...jointTrace(), status: 'incomplete' });
|
||||
await fixture.whenStable();
|
||||
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
|
||||
it('displays an accepted historical year below 100 without shifting it to the twentieth century', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
|
||||
calculationFor(conflict),
|
||||
);
|
||||
TestBed.inject(CalendarSettingsService).setPlanningDates('0099-05-14', '0099-05-14');
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelectorAll('app-calendar-month')).toHaveLength(1);
|
||||
expect(
|
||||
root.querySelector<HTMLButtonElement>('button[data-date="0099-05-14"]')?.disabled,
|
||||
).toBe(false);
|
||||
expect(
|
||||
root.querySelector<HTMLButtonElement>('button[data-date="0099-05-15"]')?.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalid balances', invalid],
|
||||
['conflict', conflict],
|
||||
[
|
||||
'cancellation without a plan',
|
||||
{ ...incomplete, reasons: [{ code: 'cancelled', message: 'Cancelled.' }] },
|
||||
],
|
||||
['worker error', new Error('Worker stopped.')],
|
||||
['startup error', new Error('Worker could not start.')],
|
||||
] satisfies [string, PlanningOutcome | Error][])(
|
||||
'uses the requested historical three-year calendar after %s',
|
||||
async (label, outcome) => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
const calendar = TestBed.inject(CalendarService);
|
||||
vi.spyOn(calendar, 'createPlanningCalculation').mockImplementation(() => {
|
||||
if (label === 'startup error') throw outcome;
|
||||
return outcome instanceof Error
|
||||
? { ...calculationFor(incomplete), outcome: Promise.reject(outcome) }
|
||||
: calculationFor(outcome);
|
||||
});
|
||||
TestBed.inject(CalendarSettingsService).setPlanningDates('2021-03-15', '2024-02-14');
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
expect(root.querySelectorAll('app-calendar-month')).toHaveLength(36);
|
||||
for (const date of ['2021-03-15', '2024-02-14'])
|
||||
expect(
|
||||
root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)?.disabled,
|
||||
).toBe(false);
|
||||
for (const date of ['2021-03-14', '2024-02-15'])
|
||||
expect(
|
||||
root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)?.disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
root
|
||||
.querySelector('button[data-date="2023-04-10"]')
|
||||
?.classList.contains('closed-day'),
|
||||
).toBe(true);
|
||||
expect(root.querySelector('.loading-overlay')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('ignores an older outcome, explanation and trace after a new horizon is submitted', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
const oldOutcome = deferred<PlanningOutcome>();
|
||||
const oldExplanation = deferred<PlanningExplanation | undefined>();
|
||||
const oldTrace = deferred<PlanningTrace | undefined>();
|
||||
const calendar = TestBed.inject(CalendarService);
|
||||
const calculate = vi
|
||||
.spyOn(calendar, 'createPlanningCalculation')
|
||||
.mockReturnValueOnce({
|
||||
...calculationFor(plan),
|
||||
outcome: oldOutcome.promise,
|
||||
explanation: oldExplanation.promise,
|
||||
trace: oldTrace.promise,
|
||||
})
|
||||
.mockReturnValueOnce(calculationFor(conflict));
|
||||
const settings = TestBed.inject(CalendarSettingsService);
|
||||
settings.setPlanningDates('2024-01-01', '2024-12-31');
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
settings.setPlanningDates('2021-05-14', '2021-05-14');
|
||||
TestBed.inject(UserInputService).requestComputation();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
oldOutcome.resolve(plan);
|
||||
oldExplanation.resolve(explanation);
|
||||
oldTrace.resolve(jointTrace());
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(calculate).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.componentInstance['planningOutcome']()).toBe(conflict);
|
||||
expect(fixture.componentInstance['planningExplanation']()).toBeNull();
|
||||
expect(fixture.componentInstance['planningTrace']()).toBeNull();
|
||||
expect(fixture.componentInstance['planningHorizon']()).toEqual({
|
||||
start: '2021-05-14',
|
||||
end: '2021-05-15',
|
||||
});
|
||||
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('captures selected dates with balances and rules only on an explicit calculation request', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
const calendar = TestBed.inject(CalendarService);
|
||||
const calculate = vi
|
||||
.spyOn(calendar, 'createPlanningCalculation')
|
||||
.mockReturnValueOnce(calculationFor(plan, explanation))
|
||||
.mockReturnValueOnce(calculationFor(conflict));
|
||||
const settings = TestBed.inject(CalendarSettingsService);
|
||||
settings.setPlanningDates('2024-01-01', '2024-12-31');
|
||||
const input = TestBed.inject(UserInputService);
|
||||
const rules = TestBed.inject(RuleService);
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(calculate).toHaveBeenCalledTimes(1);
|
||||
expect(calculate.mock.calls[0][0].horizon).toEqual(plan.horizon);
|
||||
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
|
||||
settings.setPlanningDates('2021-05-14', '2021-05-14');
|
||||
input.updateBalances(() => [
|
||||
{
|
||||
id: 'historical',
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: 1,
|
||||
availableDate: new Date(2021, 4, 14),
|
||||
expiryDate: new Date(2021, 4, 14),
|
||||
},
|
||||
]);
|
||||
rules.addRule({
|
||||
id: 'historical-rule',
|
||||
name: 'Historical rule',
|
||||
type: RuleType.TIME_WINDOW,
|
||||
force: 'obligation',
|
||||
enabled: true,
|
||||
placeDays: 1,
|
||||
placeType: SelectableDayType.CP,
|
||||
startDate: new Date(2021, 4, 14),
|
||||
endDate: new Date(2021, 4, 14),
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(calculate).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
|
||||
input.requestComputation();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(calculate).toHaveBeenCalledTimes(2);
|
||||
expect(calculate.mock.calls[1][0]).toMatchObject({
|
||||
horizon: { start: '2021-05-14', end: '2021-05-15' },
|
||||
balances: [
|
||||
{
|
||||
id: 'historical',
|
||||
type: 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2021-05-14',
|
||||
expiryDate: '2021-05-14',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(calculate.mock.calls[1][0].rules).toContainEqual(
|
||||
expect.objectContaining({ id: 'historical-rule' }),
|
||||
);
|
||||
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(1);
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
|
||||
'button[data-date="2021-05-14"]',
|
||||
)?.disabled,
|
||||
).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('button[data-date="2021-05-13"]')
|
||||
?.classList.contains('closed-day'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['InvalidInput', invalid],
|
||||
['PlanningConflict', conflict],
|
||||
@@ -1472,6 +1735,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
it('turns planner rejection into an incomplete error and clears loading', async () => {
|
||||
const calendar = {
|
||||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
||||
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
|
||||
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
|
||||
createPlanningCalculation: vi.fn(() => ({
|
||||
...calculationFor(plan),
|
||||
|
||||
@@ -14,14 +14,25 @@
|
||||
></mat-spinner>
|
||||
<div class="spinner-text">{{ loadingProgress() }}%</div>
|
||||
</div>
|
||||
<p role="status">{{ tr().loading }}</p>
|
||||
<p role="status">{{ isCancelling() ? tr().planning.cancelling : tr().loading }}</p>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="cancel-calculation"
|
||||
[disabled]="isCancelling()"
|
||||
(click)="cancelCalculation()"
|
||||
>
|
||||
{{ tr().planning.cancel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<header class="results-header">
|
||||
<div class="results-heading">
|
||||
<h2 id="planning-results-title" tabindex="-1">{{ tr().planning.title }}</h2>
|
||||
<h2 #resultsTitle id="planning-results-title" tabindex="-1">
|
||||
{{ tr().planning.title }}
|
||||
</h2>
|
||||
@if (planningOutcome()?.kind === 'InvalidInput') {
|
||||
<p class="planning-status error" role="alert">
|
||||
<mat-icon aria-hidden="true">error</mat-icon>
|
||||
@@ -42,7 +53,7 @@
|
||||
<mat-icon aria-hidden="true">{{
|
||||
hasPlannerError() ? 'error' : 'warning'
|
||||
}}</mat-icon>
|
||||
{{ hasPlannerError() ? tr().planning.error : tr().planning.incomplete }}
|
||||
{{ outcomeLabel(planningOutcome()!) }}
|
||||
</p>
|
||||
} @else if (planningOutcome()?.kind === 'Plan') {
|
||||
<p class="planning-status success" role="status">
|
||||
|
||||
@@ -526,6 +526,9 @@ output {
|
||||
justify-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.cancel-calculation {
|
||||
min-height: 44px;
|
||||
}
|
||||
.spinner-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DateAdapter } from '@angular/material/core';
|
||||
import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import dayjs from 'dayjs';
|
||||
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { RuleService } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import {
|
||||
applyReplayDeltas,
|
||||
CounterfactualAnalysis,
|
||||
@@ -64,6 +66,7 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
|
||||
MatDatepickerModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButton,
|
||||
MatCard,
|
||||
CalendarMonth,
|
||||
MatCardContent,
|
||||
@@ -78,6 +81,7 @@ export class CalendarYear {
|
||||
private readonly userInput = inject(UserInputService);
|
||||
private readonly calendarService = inject(CalendarService);
|
||||
private readonly ruleService = inject(RuleService);
|
||||
private readonly calendarSettings = inject(CalendarSettingsService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
@@ -86,7 +90,9 @@ export class CalendarYear {
|
||||
private comparisonDialog: MatDialogRef<unknown> | null = null;
|
||||
protected readonly translationService = inject(TranslationService);
|
||||
|
||||
protected readonly isLoading = signal(true);
|
||||
protected readonly isLoading = signal(false);
|
||||
protected readonly isCancelling = signal(false);
|
||||
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
|
||||
protected readonly loadingProgress = signal(0);
|
||||
protected readonly planningOutcome = signal<PlanningOutcome | null>(null);
|
||||
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
|
||||
@@ -201,7 +207,7 @@ export class CalendarYear {
|
||||
// Material returns local midnight; converting to UTC can change the calendar day.
|
||||
const date = selected
|
||||
? [
|
||||
selected.getFullYear(),
|
||||
String(selected.getFullYear()).padStart(4, '0'),
|
||||
String(selected.getMonth() + 1).padStart(2, '0'),
|
||||
String(selected.getDate()).padStart(2, '0'),
|
||||
].join('-')
|
||||
@@ -283,7 +289,7 @@ export class CalendarYear {
|
||||
if (selected.type !== DayType.CLOSED_DAY || !selected.range.start) continue;
|
||||
const day = selected.range.start;
|
||||
closedDayDates.add(
|
||||
`${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
|
||||
`${String(day.getFullYear()).padStart(4, '0')}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
|
||||
);
|
||||
}
|
||||
if (this.traceReplay() && trace) {
|
||||
@@ -428,7 +434,8 @@ export class CalendarYear {
|
||||
}
|
||||
}
|
||||
const result: { monthIndex: number; year: number }[] = [];
|
||||
const cursor = new Date(first.getFullYear(), first.getMonth(), 1);
|
||||
const cursor = new Date(first);
|
||||
cursor.setDate(1);
|
||||
while (cursor <= last) {
|
||||
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
|
||||
cursor.setMonth(cursor.getMonth() + 1);
|
||||
@@ -441,74 +448,87 @@ export class CalendarYear {
|
||||
this.destroyRef.onDestroy(() => this.clearCounterfactual());
|
||||
effect(() => {
|
||||
this.userInput.computeTriggerSignal();
|
||||
this.resetCalculationState();
|
||||
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
|
||||
const currentRules = untracked(() => this.ruleService.rules);
|
||||
const horizon = this.calendarService.getDefaultPlanningHorizon();
|
||||
this.planningHorizon.set(horizon);
|
||||
const version = ++this.calculationVersion;
|
||||
this.isLoading.set(true);
|
||||
this.loadingProgress.set(0);
|
||||
this.liveMessage.set(this.tr().loading);
|
||||
queueMicrotask(() => {
|
||||
let calculation: PlanningCalculationContext;
|
||||
try {
|
||||
calculation = this.calendarService.createPlanningCalculation({
|
||||
horizon,
|
||||
balances: vacationData.map((balance) => ({
|
||||
id: balance.id,
|
||||
type: balance.type,
|
||||
quantity: balance.numberOfDays,
|
||||
label: this.translationService.getDayTypeLabel(balance.type),
|
||||
availableFrom: balance.availableDate
|
||||
? dayjs(balance.availableDate).format('YYYY-MM-DD')
|
||||
: undefined,
|
||||
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
|
||||
})),
|
||||
rules: currentRules,
|
||||
labels: Object.fromEntries(
|
||||
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
|
||||
),
|
||||
progress: (stage) => {
|
||||
if (version === this.calculationVersion)
|
||||
this.loadingProgress.set(this.stageProgress(stage));
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.failCalculation(error, version);
|
||||
return;
|
||||
}
|
||||
this.activeCalculation = calculation;
|
||||
this.loadArtifacts(calculation, version);
|
||||
void calculation.outcome
|
||||
.then((outcome) => {
|
||||
if (version === this.calculationVersion) this.applyOutcome(outcome);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.applyOutcome({
|
||||
kind: 'PlanningIncomplete',
|
||||
completedStages: [],
|
||||
reasons: [
|
||||
{
|
||||
code: 'planner-error',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'The planner failed unexpectedly.',
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.loadingProgress.set(100);
|
||||
this.isLoading.set(false);
|
||||
});
|
||||
});
|
||||
untracked(() => this.startCalculation());
|
||||
});
|
||||
}
|
||||
|
||||
private startCalculation(): void {
|
||||
const horizon = this.calendarSettings.planningHorizon();
|
||||
if (!horizon) return;
|
||||
this.planningHorizon.set(horizon);
|
||||
this.resetCalculationState(horizon);
|
||||
const vacationData = this.userInput.vacationNumberSignal();
|
||||
const currentRules = this.ruleService.rules;
|
||||
const version = ++this.calculationVersion;
|
||||
this.isLoading.set(true);
|
||||
this.isCancelling.set(false);
|
||||
this.loadingProgress.set(0);
|
||||
this.liveMessage.set(this.tr().loading);
|
||||
let calculation: PlanningCalculationContext;
|
||||
try {
|
||||
calculation = this.calendarService.createPlanningCalculation({
|
||||
horizon,
|
||||
balances: vacationData.map((balance) => ({
|
||||
id: balance.id,
|
||||
type: balance.type,
|
||||
quantity: balance.numberOfDays,
|
||||
label: this.translationService.getDayTypeLabel(balance.type),
|
||||
availableFrom: balance.availableDate
|
||||
? dayjs(balance.availableDate).format('YYYY-MM-DD')
|
||||
: undefined,
|
||||
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
|
||||
})),
|
||||
rules: currentRules,
|
||||
labels: Object.fromEntries(
|
||||
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
|
||||
),
|
||||
progress: (stage) => {
|
||||
if (version === this.calculationVersion)
|
||||
this.loadingProgress.set(this.stageProgress(stage));
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.failCalculation(error, version);
|
||||
return;
|
||||
}
|
||||
this.activeCalculation = calculation;
|
||||
this.loadArtifacts(calculation, version);
|
||||
void calculation.outcome
|
||||
.then((outcome) => {
|
||||
if (version === this.calculationVersion) this.applyOutcome(outcome);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.applyOutcome({
|
||||
kind: 'PlanningIncomplete',
|
||||
completedStages: [],
|
||||
reasons: [
|
||||
{
|
||||
code: 'planner-error',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'The planner failed unexpectedly.',
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.loadingProgress.set(100);
|
||||
this.isLoading.set(false);
|
||||
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
|
||||
this.isCancelling.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
protected cancelCalculation(): void {
|
||||
if (!this.isLoading() || this.isCancelling() || !this.activeCalculation) return;
|
||||
this.isCancelling.set(true);
|
||||
this.liveMessage.set(this.tr().planning.cancelling);
|
||||
this.activeCalculation.cancel();
|
||||
}
|
||||
|
||||
private failCalculation(error: unknown, version: number): void {
|
||||
if (version !== this.calculationVersion) return;
|
||||
const reason = {
|
||||
@@ -523,7 +543,7 @@ export class CalendarYear {
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
|
||||
private resetCalculationState(): void {
|
||||
private resetCalculationState(horizon: { start: string; end: string }): void {
|
||||
this.activeCalculation = null;
|
||||
this.planningOutcome.set(null);
|
||||
this.planningExplanation.set(null);
|
||||
@@ -542,7 +562,7 @@ export class CalendarYear {
|
||||
this.traceReplay.set(false);
|
||||
this.clearHistoricalEligibility();
|
||||
this.selectedTraceDate.set(null);
|
||||
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
|
||||
this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon));
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
}
|
||||
|
||||
@@ -626,7 +646,9 @@ export class CalendarYear {
|
||||
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
|
||||
return;
|
||||
}
|
||||
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
|
||||
this.selectedDates.set(
|
||||
this.calendarService.getSelectedDatesForHorizon(this.planningHorizon()!),
|
||||
);
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
}
|
||||
|
||||
@@ -1114,7 +1136,10 @@ export class CalendarYear {
|
||||
protected hasPlannerError(): boolean {
|
||||
const outcome = this.planningOutcome();
|
||||
return (
|
||||
outcome?.kind === 'PlanningIncomplete' && outcome.reasons[0]?.code === 'planner-error'
|
||||
outcome?.kind === 'PlanningIncomplete' &&
|
||||
outcome.reasons.some((reason) =>
|
||||
['planner-error', 'worker-error', 'worker-unavailable'].includes(reason.code),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1245,10 +1270,14 @@ export class CalendarYear {
|
||||
return value.replaceAll('-', ' ').replaceAll(':', ': ');
|
||||
}
|
||||
|
||||
private outcomeLabel(outcome: PlanningOutcome): string {
|
||||
protected outcomeLabel(outcome: PlanningOutcome): string {
|
||||
if (outcome.kind === 'Plan') return this.tr().planning.complete;
|
||||
if (outcome.kind === 'InvalidInput') return this.tr().planning.invalidInput;
|
||||
if (outcome.kind === 'PlanningConflict') return this.tr().planning.conflict;
|
||||
if (outcome.reasons.some((reason) => reason.code === 'user-cancelled'))
|
||||
return outcome.incumbent
|
||||
? this.tr().planning.cancelledPartial
|
||||
: this.tr().planning.cancelled;
|
||||
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,11 +30,18 @@
|
||||
class="calculate-button"
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!!calendarSettings.planningHorizonError()"
|
||||
[attr.aria-describedby]="
|
||||
calendarSettings.planningHorizonError() ? 'invalid-planning-horizon' : null
|
||||
"
|
||||
(click)="triggerComputation()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
|
||||
{{ tr().userInput.calculate }}
|
||||
</button>
|
||||
@if (calendarSettings.planningHorizonError()) {
|
||||
<p id="invalid-planning-horizon" role="alert">{{ tr().settings.invalidHorizon }}</p>
|
||||
}
|
||||
|
||||
@if (hasRemainingDays()) {
|
||||
<mat-card class="warning-card">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { vi } from 'vitest';
|
||||
import { UserInput } from './user-input';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { UserSettings } from '../user-settings/user-settings';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
|
||||
describe('UserInput', () => {
|
||||
let component: UserInput;
|
||||
@@ -82,6 +83,26 @@ describe('UserInput', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('prevents calculation while planning dates are invalid and re-enables it after correction', () => {
|
||||
const settings = TestBed.inject(CalendarSettingsService);
|
||||
settings.setPlanningDates('2024-03-01', '2024-02-29');
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const calculate = root.querySelector<HTMLButtonElement>('.calculate-button')!;
|
||||
expect(calculate.disabled).toBe(true);
|
||||
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'planning dates in Settings',
|
||||
);
|
||||
calculate.click();
|
||||
component.triggerComputation();
|
||||
expect(userInputService.computeTriggerSignal()).toBe(0);
|
||||
settings.setPlanningDates('2024-02-29', '2024-02-29');
|
||||
fixture.detectChanges();
|
||||
expect(calculate.disabled).toBe(false);
|
||||
calculate.click();
|
||||
expect(userInputService.computeTriggerSignal()).toBe(1);
|
||||
});
|
||||
|
||||
it('should initialize with vacation days from service', () => {
|
||||
const vacationDays = userInputService.vacationNumberSignal();
|
||||
expect(vacationDays).toBeDefined();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DayInput } from '../userInput/day-input/day-input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { VacDay } from '../../services/calendar-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-input',
|
||||
@@ -26,6 +27,7 @@ import { TranslationService } from '../../services/translation-service';
|
||||
})
|
||||
export class UserInput {
|
||||
private userInputService: UserInputService = inject(UserInputService);
|
||||
protected readonly calendarSettings = inject(CalendarSettingsService);
|
||||
protected readonly translationService = inject(TranslationService);
|
||||
|
||||
get tr() {
|
||||
@@ -99,6 +101,7 @@ export class UserInput {
|
||||
}
|
||||
|
||||
public triggerComputation() {
|
||||
if (this.calendarSettings.planningHorizonError()) return;
|
||||
this.userInputService.requestComputation();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,53 @@
|
||||
<mat-dialog-content>
|
||||
<p>{{ tr().settings.description }}</p>
|
||||
<div class="settings-controls">
|
||||
<fieldset class="planning-dates">
|
||||
<legend>{{ tr().settings.planningHorizon }}</legend>
|
||||
<p id="planning-dates-help">{{ tr().settings.planningDatesHelp }}</p>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ tr().settings.planningStart }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="date"
|
||||
name="planningStart"
|
||||
required
|
||||
#planningStart
|
||||
[value]="calendarSettings.planningDates().start"
|
||||
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
|
||||
aria-describedby="planning-dates-help planning-dates-error"
|
||||
(input)="
|
||||
calendarSettings.setPlanningDates(
|
||||
planningStart.value,
|
||||
lastPlanningDate.value
|
||||
)
|
||||
"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ tr().settings.lastPlanningDate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="date"
|
||||
name="lastPlanningDate"
|
||||
required
|
||||
#lastPlanningDate
|
||||
[value]="calendarSettings.planningDates().lastPlanningDate"
|
||||
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
|
||||
aria-describedby="planning-dates-help planning-dates-error"
|
||||
(input)="
|
||||
calendarSettings.setPlanningDates(
|
||||
planningStart.value,
|
||||
lastPlanningDate.value
|
||||
)
|
||||
"
|
||||
/>
|
||||
</mat-form-field>
|
||||
@if (calendarSettings.planningHorizonError(); as error) {
|
||||
<p id="planning-dates-error" class="validation-error" role="alert">
|
||||
{{ tr().settings[error] }}
|
||||
</p>
|
||||
}
|
||||
</fieldset>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ tr().settings.selectTheme }}</mat-label>
|
||||
<mat-select
|
||||
|
||||
@@ -20,3 +20,26 @@ mat-dialog-content > p {
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.planning-dates {
|
||||
min-width: 0;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
legend {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0 1rem;
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
|
||||
.validation-error {
|
||||
color: var(--ui-error-text);
|
||||
background: var(--ui-error-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,71 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { UserSettings } from './user-settings';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
|
||||
describe('UserSettings', () => {
|
||||
let component: UserSettings;
|
||||
let fixture: ComponentFixture<UserSettings>;
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [UserSettings],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
|
||||
TestBed.inject(TranslationService).setLanguage('en');
|
||||
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-29', '2027-02-28');
|
||||
fixture = TestBed.createComponent(UserSettings);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it.each(['', '2024-02-30', 'not-a-date'])(
|
||||
'reports an invalid date entry without saving it: %s',
|
||||
(value) => {
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
|
||||
start.value = value;
|
||||
start.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'two valid calendar dates',
|
||||
);
|
||||
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
|
||||
start: '2024-02-29',
|
||||
lastPlanningDate: '2027-02-28',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('edits inclusive dates with an inline validation indication while preserving the saved horizon', () => {
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
|
||||
const last = root.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
|
||||
expect(start.value).toBe('2024-02-29');
|
||||
expect(last.value).toBe('2027-02-28');
|
||||
expect(root.textContent).toContain('Last planning date (inclusive)');
|
||||
last.value = '2024-02-28';
|
||||
last.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector('[role="alert"]')?.textContent).toContain('on or after');
|
||||
expect(last.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toBeNull();
|
||||
last.value = '2024-02-29';
|
||||
last.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toEqual({
|
||||
start: '2024-02-29',
|
||||
end: '2024-03-01',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { ThemeService } from '../../services/theme-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
@@ -24,6 +25,7 @@ import { TranslationService } from '../../services/translation-service';
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatSlideToggle,
|
||||
MatInputModule,
|
||||
],
|
||||
templateUrl: './user-settings.html',
|
||||
styleUrl: './user-settings.scss',
|
||||
@@ -32,7 +34,8 @@ import { TranslationService } from '../../services/translation-service';
|
||||
export class UserSettings {
|
||||
protected readonly themeService = inject(ThemeService);
|
||||
protected readonly translationService = inject(TranslationService);
|
||||
protected readonly showWeekNumbers = inject(CalendarSettingsService).get('showWeekNumbers');
|
||||
protected readonly calendarSettings = inject(CalendarSettingsService);
|
||||
protected readonly showWeekNumbers = this.calendarSettings.get('showWeekNumbers');
|
||||
protected readonly tr = this.translationService.tr;
|
||||
protected readonly themePreferences = ['system', 'light', 'dark'] as const;
|
||||
}
|
||||
@@ -31,6 +31,62 @@ describe('CalendarService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['2026-03-28', '2026-03-30', '2026-03-31'],
|
||||
['2026-10-24', '2026-10-26', '2026-10-27'],
|
||||
])(
|
||||
'groups and counts calendar dates across the clock change starting %s',
|
||||
(periodStart, date, end) => {
|
||||
const previousTimeZone = process.env['TZ'];
|
||||
process.env['TZ'] = 'Europe/Paris';
|
||||
try {
|
||||
const selected = service.selectedDatesForPlan({
|
||||
horizon: { start: date, end },
|
||||
placements: [
|
||||
{ date, balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
|
||||
],
|
||||
});
|
||||
const period = selected.datesSelected.find(
|
||||
(item) =>
|
||||
item.type === null &&
|
||||
item.range.start?.getTime() ===
|
||||
new Date(`${periodStart}T00:00:00`).getTime() &&
|
||||
item.range.end?.getTime() === new Date(`${date}T00:00:00`).getTime(),
|
||||
);
|
||||
expect(period).toBeDefined();
|
||||
expect(period?.getLengthInDays()).toBe(3);
|
||||
} finally {
|
||||
if (previousTimeZone === undefined) delete process.env['TZ'];
|
||||
else process.env['TZ'] = previousTimeZone;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('projects adjacent non-working dates into a preceding month without moving leave outside the horizon', () => {
|
||||
const horizon = { start: '2024-04-02', end: '2024-04-03' };
|
||||
const selected = service.selectedDatesForPlan({
|
||||
horizon,
|
||||
placements: [
|
||||
{ date: '2024-04-02', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
|
||||
],
|
||||
timeOffPeriods: [
|
||||
{
|
||||
start: '2024-03-30',
|
||||
end: '2024-04-02',
|
||||
duration: 4,
|
||||
placementDates: ['2024-04-02'],
|
||||
nonWorkingDates: ['2024-03-30', '2024-03-31', '2024-04-01'],
|
||||
nonWorkingDayGain: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(selected.getClasses(new Date('2024-03-30T00:00:00'))).toContain(DayType.WEEKEND);
|
||||
expect(selected.getClasses(new Date('2024-04-01T00:00:00'))).toContain(DayType.CLOSED_DAY);
|
||||
expect(selected.getClasses(new Date('2024-04-02T00:00:00'))).toContain(DayType.CP);
|
||||
expect(selected.getClasses(new Date('2024-04-03T00:00:00'))).not.toContain(DayType.CP);
|
||||
expect(horizon).toEqual({ start: '2024-04-02', end: '2024-04-03' });
|
||||
});
|
||||
|
||||
describe('getHolidays', () => {
|
||||
it('should return an array of holidays', () => {
|
||||
const holidays = service.getHolidays();
|
||||
@@ -115,6 +171,25 @@ describe('CalendarService', () => {
|
||||
DayType.WEEKEND,
|
||||
);
|
||||
});
|
||||
|
||||
it('projects holidays in every covered year and in both partial boundary months', () => {
|
||||
const selected = service.getSelectedDatesForHorizon({
|
||||
start: '2023-12-26',
|
||||
end: '2027-01-03',
|
||||
});
|
||||
for (const date of [
|
||||
'2023-12-25',
|
||||
'2024-04-01',
|
||||
'2025-04-21',
|
||||
'2026-04-06',
|
||||
'2027-01-01',
|
||||
]) {
|
||||
expect(selected.getClasses(new Date(`${date}T00:00:00`))).toContain(
|
||||
DayType.CLOSED_DAY,
|
||||
);
|
||||
}
|
||||
expect(selected.getClasses(new Date('2027-01-02T00:00:00'))).toContain(DayType.WEEKEND);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setWeekendSelection', () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
PlanningReason,
|
||||
RemainingClassification,
|
||||
} from './leave-planner.service';
|
||||
import { addPlanningYears, planningDateInParis } from './planning-date';
|
||||
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
|
||||
|
||||
dayjs.extend(weekOfYear);
|
||||
dayjs.extend(isoWeek);
|
||||
@@ -39,10 +39,21 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
selectedDatesForPlan(
|
||||
plan: Pick<PlanOutcome, 'placements'> & { horizon?: { start: string; end: string } },
|
||||
plan: Pick<PlanOutcome, 'placements'> & {
|
||||
horizon?: { start: string; end: string };
|
||||
timeOffPeriods?: PlanOutcome['timeOffPeriods'];
|
||||
},
|
||||
): SelectedDates {
|
||||
const selected = plan.horizon
|
||||
? this.getSelectedDatesForHorizon(plan.horizon)
|
||||
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
|
||||
? this.getSelectedDatesForHorizon(projectionHorizon)
|
||||
: this.getSelectedDatesForYear();
|
||||
for (const placement of plan.placements) {
|
||||
selected.push(
|
||||
@@ -58,9 +69,7 @@ export class CalendarService {
|
||||
return selected;
|
||||
}
|
||||
|
||||
// Cache for holiday timestamps
|
||||
private holidayTimestampsCache: Set<number> | null = null;
|
||||
private holidayCacheYear: number | null = null;
|
||||
private readonly holidaysByYear = new Map<number, number[]>();
|
||||
|
||||
// Get selected dates from current date to the next year
|
||||
getSelectedDatesFromNow(): SelectedDates {
|
||||
@@ -78,9 +87,10 @@ export class CalendarService {
|
||||
const start = new Date(`${horizon.start}T00:00:00`);
|
||||
const last = new Date(`${horizon.end}T00:00:00`);
|
||||
last.setDate(last.getDate() - 1);
|
||||
const holidays = this.getHolidays(start.getFullYear());
|
||||
const holidays = this.getHolidays(start.getFullYear(), last.getFullYear());
|
||||
const monthInHorizon: Year = [];
|
||||
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
const cursor = new Date(start);
|
||||
cursor.setDate(1);
|
||||
while (cursor <= last) {
|
||||
monthInHorizon.push(
|
||||
CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()),
|
||||
@@ -128,59 +138,47 @@ export class CalendarService {
|
||||
|
||||
setWeekendSelection(date: Date, selectedDates: SelectedDates) {
|
||||
if (date.getDay() === 6) {
|
||||
const end = new Date(date);
|
||||
end.setDate(end.getDate() + 1);
|
||||
selectedDates._datesSelected.push(
|
||||
new SelectedDate(
|
||||
DayType.WEEKEND,
|
||||
new DateRange<Date>(
|
||||
date,
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1),
|
||||
),
|
||||
),
|
||||
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(date, end)),
|
||||
);
|
||||
}
|
||||
if (date.getDate() === 1 && date.getDay() === 0) {
|
||||
const start = new Date(date);
|
||||
start.setDate(start.getDate() - 1);
|
||||
selectedDates._datesSelected.push(
|
||||
new SelectedDate(
|
||||
DayType.WEEKEND,
|
||||
new DateRange<Date>(
|
||||
new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1),
|
||||
date,
|
||||
),
|
||||
),
|
||||
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(start, date)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getHolidays(startYear = this.dateCache.getCurrentYear()): Date[] {
|
||||
// Cache holidays to avoid recalculation
|
||||
if (this.holidayCacheYear === startYear && this.holidayTimestampsCache) {
|
||||
// Return timestamps directly for efficient lookup
|
||||
return Array.from(this.holidayTimestampsCache).map((ts) => new Date(ts));
|
||||
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
|
||||
const dates: Date[] = [];
|
||||
for (let year = startYear; year <= lastYear; year++) {
|
||||
let timestamps = this.holidaysByYear.get(year);
|
||||
if (!timestamps) {
|
||||
const calendar = new Holidays('FR', allTypesOptions);
|
||||
timestamps = calendar
|
||||
.getHolidays(year, lang)
|
||||
.filter((holiday) => holiday.type === 'public')
|
||||
.map((holiday) => {
|
||||
const dateOnly = String(holiday.date).match(/^\d{4}-\d{2}-\d{2}/)?.[0];
|
||||
return (
|
||||
dateOnly ? new Date(`${dateOnly}T00:00:00`) : new Date(holiday.date)
|
||||
).getTime();
|
||||
});
|
||||
this.holidaysByYear.set(year, timestamps);
|
||||
}
|
||||
dates.push(...timestamps.map((timestamp) => new Date(timestamp)));
|
||||
}
|
||||
|
||||
// Logic to return holidays for the given year
|
||||
const hd = new Holidays('FR', allTypesOptions);
|
||||
const holidays = hd
|
||||
.getHolidays(startYear, lang)
|
||||
.filter((holiday) => holiday.type === 'public');
|
||||
const holidaysPlusOne = hd
|
||||
.getHolidays(startYear + 1, lang)
|
||||
.filter((holiday) => holiday.type === 'public');
|
||||
holidays.push(...holidaysPlusOne);
|
||||
const holidaysDate: Date[] = holidays.map((holiday) => {
|
||||
const dateOnly = String(holiday.date).match(/^\d{4}-\d{2}-\d{2}/)?.[0];
|
||||
return dateOnly ? new Date(`${dateOnly}T00:00:00`) : new Date(holiday.date);
|
||||
});
|
||||
|
||||
// Update cache
|
||||
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
|
||||
this.holidayCacheYear = startYear;
|
||||
|
||||
return holidaysDate;
|
||||
return dates;
|
||||
}
|
||||
|
||||
static daysInWeek(weekIndex: number, monthIndex: number, year: number): Week {
|
||||
const firstDayOfMonth = new Date(year, monthIndex, 1);
|
||||
const firstDayOfMonth = new Date(0);
|
||||
firstDayOfMonth.setFullYear(year, monthIndex, 1);
|
||||
firstDayOfMonth.setHours(0, 0, 0, 0);
|
||||
const firstDayOfWeek = new Date(firstDayOfMonth);
|
||||
let offset = firstDayOfMonth.getDay() - 1; // Adjust to make Monday the first day of the week
|
||||
if (offset < 0) {
|
||||
@@ -357,8 +355,7 @@ export class SelectedDate implements SelectedDateInterface {
|
||||
|
||||
getLengthInDays(): number {
|
||||
if (this.range.start && this.range.end) {
|
||||
const timeDiff = this.range.end.getTime() - this.range.start.getTime();
|
||||
return Math.floor(timeDiff / (1000 * 3600 * 24)) + 1;
|
||||
return dayjs(this.range.end).diff(dayjs(this.range.start), 'day') + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -409,8 +406,6 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
return;
|
||||
}
|
||||
|
||||
const oneDayInMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Sort dates by start date
|
||||
const sorted = [...this._datesSelected].sort((a, b) => {
|
||||
const aStart = a.range.start?.getTime() ?? 0;
|
||||
@@ -429,7 +424,9 @@ export class SelectedDates implements SelectedDateInterface {
|
||||
const currEnd = current.range.end?.getTime() ?? 0;
|
||||
|
||||
// Check if current date is adjacent or overlapping with the maximum end of the current group
|
||||
if (currStart - currentGroupMaxEnd <= oneDayInMs) {
|
||||
const nextDay = new Date(currentGroupMaxEnd);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
if (currStart <= nextDay.getTime()) {
|
||||
currentGroup.push(current);
|
||||
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
|
||||
} else {
|
||||
|
||||
@@ -1,21 +1,114 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { CalendarSettingsService } from './calendar-settings-service';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('CalendarSettingsService', () => {
|
||||
let service: CalendarSettingsService;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-10-04T22:30:00Z'));
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
});
|
||||
service = TestBed.inject(CalendarSettingsService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('defaults to the Paris date and displays the day before the anniversary as the last planning date', () => {
|
||||
expect(service.planningDates()).toEqual({
|
||||
start: '2026-10-05',
|
||||
lastPlanningDate: '2027-10-04',
|
||||
});
|
||||
expect(service.planningHorizon()).toEqual({ start: '2026-10-05', end: '2027-10-05' });
|
||||
expect(localStorage.getItem('calendarSettings.planningDates')).toBeNull();
|
||||
});
|
||||
|
||||
it('saves and restores fixed historical dates before applying a later default', () => {
|
||||
service.setPlanningDates('2024-02-29', '2026-02-28');
|
||||
vi.setSystemTime(new Date('2029-08-01T12:00:00Z'));
|
||||
const restored = TestBed.runInInjectionContext(() => new CalendarSettingsService());
|
||||
expect(restored.planningDates()).toEqual({
|
||||
start: '2024-02-29',
|
||||
lastPlanningDate: '2026-02-28',
|
||||
});
|
||||
expect(restored.planningHorizon()).toEqual({ start: '2024-02-29', end: '2026-03-01' });
|
||||
});
|
||||
|
||||
it('uses the existing February 29 anniversary convention for the unsaved default', () => {
|
||||
vi.setSystemTime(new Date('2024-02-29T12:00:00Z'));
|
||||
const leapDay = TestBed.runInInjectionContext(() => new CalendarSettingsService());
|
||||
expect(leapDay.planningDates()).toEqual({
|
||||
start: '2024-02-29',
|
||||
lastPlanningDate: '2025-02-27',
|
||||
});
|
||||
expect(leapDay.planningHorizon()).toEqual({ start: '2024-02-29', end: '2025-02-28' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['2024-02-29', '2024-03-01'],
|
||||
['2026-03-29', '2026-03-30'],
|
||||
['2026-10-25', '2026-10-26'],
|
||||
['2026-12-31', '2027-01-01'],
|
||||
])(
|
||||
'converts a single inclusive planning date %s across leap days, clock changes and years',
|
||||
(date, end) => {
|
||||
const previousTimeZone = process.env['TZ'];
|
||||
process.env['TZ'] = 'Pacific/Auckland';
|
||||
try {
|
||||
service.setPlanningDates(date, date);
|
||||
expect(service.planningHorizon()).toEqual({ start: date, end });
|
||||
} finally {
|
||||
if (previousTimeZone === undefined) delete process.env['TZ'];
|
||||
else process.env['TZ'] = previousTimeZone;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['2024-02-30', '2024-03-01', 'invalidDate'],
|
||||
['2024-02-29', '2024-13-01', 'invalidDate'],
|
||||
['29/02/2024', '2024-03-01', 'invalidDate'],
|
||||
['', '2024-03-01', 'invalidDate'],
|
||||
['2024-03-02', '2024-03-01', 'horizonOrder'],
|
||||
])(
|
||||
'keeps invalid input visible without replacing the last valid saved dates (%s, %s)',
|
||||
(start, last, error) => {
|
||||
service.setPlanningDates('2024-02-29', '2027-02-28');
|
||||
service.setPlanningDates(start, last);
|
||||
expect(service.planningDates()).toEqual({ start, lastPlanningDate: last });
|
||||
expect(service.planningHorizon()).toBeNull();
|
||||
expect(service.planningHorizonError()).toBe(error);
|
||||
const restored = TestBed.runInInjectionContext(() => new CalendarSettingsService());
|
||||
expect(restored.planningHorizon()).toEqual({ start: '2024-02-29', end: '2027-03-01' });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'{',
|
||||
'null',
|
||||
'{}',
|
||||
'{"start":"2024-02-30","lastPlanningDate":"2024-03-01"}',
|
||||
'{"start":"2024-03-02","lastPlanningDate":"2024-03-01"}',
|
||||
])('uses an unsaved default when saved dates are unreadable: %s', (saved) => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
localStorage.setItem('calendarSettings.planningDates', saved);
|
||||
const restored = TestBed.runInInjectionContext(() => new CalendarSettingsService());
|
||||
expect(restored.planningHorizon()).toEqual({ start: '2026-10-05', end: '2027-10-05' });
|
||||
expect(localStorage.getItem('calendarSettings.planningDates')).toBe(saved);
|
||||
});
|
||||
|
||||
it('should initialize with default values', () => {
|
||||
expect(service.get('showWeekNumbers')()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { Injectable, WritableSignal, inject, Injector } from '@angular/core';
|
||||
import { Injectable, WritableSignal, inject, Injector, signal, computed } from '@angular/core';
|
||||
import { storedSignal } from '../shared/utils/stored-signal';
|
||||
import {
|
||||
addPlanningDays,
|
||||
addPlanningYears,
|
||||
isPlanningDate,
|
||||
planningDateInParis,
|
||||
} from './planning-date';
|
||||
|
||||
const PLANNING_DATES_KEY = 'calendarSettings.planningDates';
|
||||
|
||||
function planningDatesError(
|
||||
start: unknown,
|
||||
lastPlanningDate: unknown,
|
||||
): 'invalidDate' | 'horizonOrder' | null {
|
||||
if (!isPlanningDate(start) || !isPlanningDate(lastPlanningDate)) return 'invalidDate';
|
||||
if (lastPlanningDate < start) return 'horizonOrder';
|
||||
return isPlanningDate(addPlanningDays(lastPlanningDate, 1)) ? null : 'invalidDate';
|
||||
}
|
||||
|
||||
interface SettingDefinition {
|
||||
defaultValue: boolean;
|
||||
@@ -11,6 +28,62 @@ interface SettingDefinition {
|
||||
})
|
||||
export class CalendarSettingsService {
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly selectedPlanningDates = signal(this.restorePlanningDates());
|
||||
readonly planningDates = this.selectedPlanningDates.asReadonly();
|
||||
readonly planningHorizonError = computed(() => {
|
||||
const { start, lastPlanningDate } = this.planningDates();
|
||||
return planningDatesError(start, lastPlanningDate);
|
||||
});
|
||||
readonly planningHorizon = computed(() =>
|
||||
this.planningHorizonError()
|
||||
? null
|
||||
: {
|
||||
start: this.planningDates().start,
|
||||
end: addPlanningDays(this.planningDates().lastPlanningDate, 1),
|
||||
},
|
||||
);
|
||||
|
||||
setPlanningDates(start: string, lastPlanningDate: string): void {
|
||||
const dates = { start, lastPlanningDate };
|
||||
this.selectedPlanningDates.set(dates);
|
||||
if (this.planningHorizonError()) return;
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined')
|
||||
localStorage.setItem(PLANNING_DATES_KEY, JSON.stringify(dates));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save planning dates to localStorage.', error);
|
||||
}
|
||||
}
|
||||
|
||||
private restorePlanningDates(): { start: string; lastPlanningDate: string } {
|
||||
try {
|
||||
const stored =
|
||||
typeof localStorage === 'undefined'
|
||||
? null
|
||||
: localStorage.getItem(PLANNING_DATES_KEY);
|
||||
if (stored !== null) {
|
||||
const saved: unknown = JSON.parse(stored);
|
||||
if (
|
||||
typeof saved === 'object' &&
|
||||
saved !== null &&
|
||||
'start' in saved &&
|
||||
'lastPlanningDate' in saved &&
|
||||
isPlanningDate(saved.start) &&
|
||||
isPlanningDate(saved.lastPlanningDate) &&
|
||||
!planningDatesError(saved.start, saved.lastPlanningDate)
|
||||
)
|
||||
return { start: saved.start, lastPlanningDate: saved.lastPlanningDate };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore planning dates from localStorage.', error);
|
||||
}
|
||||
return this.defaultPlanningDates();
|
||||
}
|
||||
|
||||
private defaultPlanningDates(): { start: string; lastPlanningDate: string } {
|
||||
const start = planningDateInParis(new Date());
|
||||
return { start, lastPlanningDate: addPlanningDays(addPlanningYears(start, 1), -1) };
|
||||
}
|
||||
|
||||
private readonly definitions = new Map<string, SettingDefinition>([
|
||||
[
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
createPlanningCalculation,
|
||||
type PlanningIncompleteOutcome,
|
||||
type PlanningTraceUpdate,
|
||||
} from './leave-planner.service';
|
||||
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||
balances: [],
|
||||
rules: [],
|
||||
};
|
||||
|
||||
describe('user cancellation', () => {
|
||||
const workers: CancellableWorker[] = [];
|
||||
const currentWorker = () => workers[workers.length - 1];
|
||||
class CancellableWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
postMessage = vi.fn();
|
||||
terminate = vi.fn();
|
||||
constructor() {
|
||||
workers.push(this);
|
||||
}
|
||||
emit(data: unknown) {
|
||||
this.onmessage?.({ data } as MessageEvent);
|
||||
}
|
||||
}
|
||||
beforeEach(() => {
|
||||
workers.length = 0;
|
||||
vi.stubGlobal('Worker', CancellableWorker);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('supports cancellation through the public non-browser calculation context', async () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
const calculation = createPlanningCalculation(request);
|
||||
calculation.cancel();
|
||||
expect(await calculation.outcome).toMatchObject({
|
||||
kind: 'PlanningIncomplete',
|
||||
reasons: [{ code: 'user-cancelled' }],
|
||||
});
|
||||
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||
});
|
||||
|
||||
it('uses the replacement reason consistently in non-browser outcomes and artifacts', async () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
const replaced = createPlanningCalculation(request);
|
||||
const current = createPlanningCalculation(request);
|
||||
expect(await replaced.outcome).toMatchObject({
|
||||
kind: 'PlanningIncomplete',
|
||||
reasons: [{ code: 'stale-calculation' }],
|
||||
});
|
||||
expect(await replaced.explanation).toMatchObject({
|
||||
kind: 'IncompleteExplanation',
|
||||
reason: { code: 'stale-calculation' },
|
||||
});
|
||||
expect((await replaced.trace)?.error).toMatchObject({ code: 'stale-calculation' });
|
||||
expect(await current.outcome).toMatchObject({ kind: 'Plan' });
|
||||
await current.trace;
|
||||
});
|
||||
|
||||
it('retains a plan at least as good as every valid candidate actually evaluated before cancellation', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
let cancelled = false;
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
const outcome = await core.calculatePlanningAsyncInternal(
|
||||
{
|
||||
horizon: request.horizon,
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }],
|
||||
rules: [
|
||||
{
|
||||
id: 'january',
|
||||
type: 'time_window',
|
||||
force: 'preference',
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'longest-work-sequence') cancelled = true;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
undefined,
|
||||
undefined,
|
||||
() => cancelled,
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'PlanningIncomplete',
|
||||
reasons: [{ code: 'user-cancelled' }],
|
||||
});
|
||||
if (artifacts.explanation?.kind !== 'PlanExplanation' || !artifacts.trace)
|
||||
throw new Error('Expected a retained plan explanation and its original trace.');
|
||||
const retained = artifacts.explanation.placements[0].objectiveVector.objectives;
|
||||
const evaluated = artifacts.trace.phases.flatMap((phase) =>
|
||||
phase.decisions.flatMap((decision) =>
|
||||
decision.candidates.flatMap((candidate) =>
|
||||
candidate.comparison?.after.objectives[0].value === 0
|
||||
? [candidate.comparison.after.objectives]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(evaluated.length).toBeGreaterThan(0);
|
||||
for (const candidate of evaluated) {
|
||||
expect(candidate.map(({ id }) => id)).toEqual(retained.map(({ id }) => id));
|
||||
const firstDifference = retained.findIndex(
|
||||
(objective, index) => objective.value !== candidate[index].value,
|
||||
);
|
||||
if (firstDifference < 0) continue;
|
||||
const chosen = retained[firstDifference];
|
||||
const alternative = candidate[firstDifference];
|
||||
expect(
|
||||
chosen.direction === 'minimize'
|
||||
? chosen.value < alternative.value
|
||||
: chosen.value > alternative.value,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('requests interruption before a valid plan without terminating trace delivery', async () => {
|
||||
const calculation = createPlanningCalculation(request);
|
||||
calculation.cancel();
|
||||
calculation.cancel();
|
||||
expect(currentWorker().postMessage).toHaveBeenLastCalledWith({ type: 'cancel' });
|
||||
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
|
||||
expect(currentWorker().terminate).not.toHaveBeenCalled();
|
||||
|
||||
const cancelled: PlanningIncompleteOutcome = {
|
||||
kind: 'PlanningIncomplete',
|
||||
completedStages: ['validation'],
|
||||
reasons: [{ code: 'user-cancelled', message: 'The calculation was cancelled.' }],
|
||||
};
|
||||
currentWorker().emit({ type: 'result', outcome: cancelled });
|
||||
currentWorker().emit({
|
||||
type: 'result',
|
||||
outcome: { kind: 'PlanningConflict', conflictingRuleIds: [], reasons: [] },
|
||||
});
|
||||
expect(await calculation.outcome).toEqual(cancelled);
|
||||
expect(currentWorker().terminate).not.toHaveBeenCalled();
|
||||
currentWorker().emit({ type: 'artifacts', artifacts: {} });
|
||||
await calculation.trace;
|
||||
expect(currentWorker().terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('drains records queued before and after cancellation while keeping the partial plan on artifact error', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
const updates: PlanningTraceUpdate[] = [];
|
||||
let interrupted = false;
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
const submitted = {
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
|
||||
rules: [],
|
||||
};
|
||||
const cancelled = await core.calculatePlanningAsyncInternal(
|
||||
{
|
||||
...submitted,
|
||||
traceProgress: (_: number, update: PlanningTraceUpdate) =>
|
||||
updates.push(structuredClone(update)),
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'non-working-day-gain') interrupted = true;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
undefined,
|
||||
undefined,
|
||||
() => interrupted,
|
||||
);
|
||||
if (cancelled.kind !== 'PlanningIncomplete' || !cancelled.incumbent || !artifacts.trace)
|
||||
throw new Error('Expected a cancelled fixture with a valid plan and original trace.');
|
||||
const calculation = createPlanningCalculation(submitted);
|
||||
const operations = updates.flatMap(({ operations }) => operations);
|
||||
const split = Math.floor(operations.length / 2);
|
||||
currentWorker().emit({
|
||||
type: 'trace-update',
|
||||
totalMs: 0,
|
||||
acknowledge: true,
|
||||
update: {
|
||||
...updates[0],
|
||||
operations: operations.slice(0, split),
|
||||
},
|
||||
});
|
||||
calculation.cancel();
|
||||
currentWorker().emit({ type: 'result', outcome: cancelled });
|
||||
currentWorker().emit({ type: 'result', outcome: cancelled.incumbent });
|
||||
currentWorker().emit({
|
||||
type: 'explanation',
|
||||
explanationError: { code: 'explanation-error', message: 'Unavailable' },
|
||||
});
|
||||
currentWorker().emit({
|
||||
type: 'trace-update',
|
||||
totalMs: 0,
|
||||
acknowledge: true,
|
||||
update: {
|
||||
operations: operations.slice(split),
|
||||
},
|
||||
});
|
||||
const trace = artifacts.trace;
|
||||
currentWorker().emit({
|
||||
type: 'artifacts-start',
|
||||
artifacts: {},
|
||||
trace: {
|
||||
status: trace.status,
|
||||
initialState: trace.initialState,
|
||||
completedStages: trace.completedStages,
|
||||
nonWorkingDates: trace.nonWorkingDates,
|
||||
error: trace.error,
|
||||
},
|
||||
});
|
||||
currentWorker().emit({ type: 'artifacts-complete' });
|
||||
expect(await calculation.outcome).toEqual(cancelled);
|
||||
expect(await calculation.explanationError).toMatchObject({ code: 'explanation-error' });
|
||||
expect(await calculation.trace).toEqual(trace);
|
||||
});
|
||||
|
||||
it('reports worker failure distinctly after a cancellation request', async () => {
|
||||
const calculation = createPlanningCalculation(request);
|
||||
calculation.cancel();
|
||||
currentWorker().onerror?.();
|
||||
expect(await calculation.outcome).toMatchObject({
|
||||
kind: 'PlanningIncomplete',
|
||||
reasons: [{ code: 'worker-error' }],
|
||||
});
|
||||
expect((await calculation.trace)?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('keeps a cancellation accepted before a queued completion and adapts its explanation without replay', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
const plan = core.calculatePlanningInternal(
|
||||
request,
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(plan.kind).toBe('Plan');
|
||||
const calculation = createPlanningCalculation(request);
|
||||
calculation.cancel();
|
||||
currentWorker().emit({ type: 'result', outcome: plan });
|
||||
currentWorker().emit({ type: 'artifacts', artifacts });
|
||||
expect(await calculation.outcome).toMatchObject({
|
||||
kind: 'PlanningIncomplete',
|
||||
incumbent: plan,
|
||||
reasons: [{ code: 'user-cancelled' }],
|
||||
});
|
||||
expect(await calculation.explanation).toMatchObject({
|
||||
kind: 'PlanExplanation',
|
||||
incomplete: { reason: { code: 'user-cancelled' } },
|
||||
});
|
||||
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports replacement distinctly and ignores late replies from the cancelled calculation', async () => {
|
||||
const cancelled = createPlanningCalculation(request);
|
||||
cancelled.cancel();
|
||||
const previousWorker = currentWorker();
|
||||
const lateReply = previousWorker.onmessage!;
|
||||
const newer = createPlanningCalculation(request);
|
||||
expect(previousWorker.terminate).toHaveBeenCalledOnce();
|
||||
lateReply({
|
||||
data: {
|
||||
type: 'result',
|
||||
outcome: { kind: 'PlanningConflict', reasons: [], conflictingRuleIds: [] },
|
||||
},
|
||||
} as MessageEvent);
|
||||
expect(await cancelled.outcome).toMatchObject({
|
||||
kind: 'PlanningIncomplete',
|
||||
reasons: [{ code: 'stale-calculation' }],
|
||||
});
|
||||
const outcome: PlanningIncompleteOutcome = {
|
||||
kind: 'PlanningIncomplete',
|
||||
completedStages: [],
|
||||
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
||||
};
|
||||
currentWorker().emit({ type: 'result', outcome });
|
||||
currentWorker().emit({ type: 'artifacts', artifacts: {} });
|
||||
expect(await newer.outcome).toEqual(outcome);
|
||||
await newer.trace;
|
||||
const sent = currentWorker().postMessage.mock.calls.length;
|
||||
newer.cancel();
|
||||
expect(currentWorker().postMessage).toHaveBeenCalledTimes(sent);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -46,6 +46,68 @@ async function runInNonBrowser(request: Parameters<typeof planLeave>[0]) {
|
||||
}
|
||||
|
||||
describe('planLeave', () => {
|
||||
it.each([
|
||||
['one leap day', '2024-02-29', '2024-03-01', '2024-02-29', '2024-02-29', '2024-02-29'],
|
||||
['historical day', '2021-05-14', '2021-05-15', '2021-05-14', '2021-05-13', '2021-05-16'],
|
||||
['two years', '2024-01-01', '2026-01-01', '2025-12-24', '2025-12-24', '2025-12-25'],
|
||||
['three years', '2024-01-01', '2027-01-01', '2026-12-24', '2026-12-24', '2026-12-27'],
|
||||
])(
|
||||
'plans a light %s horizon with dated provenance and French holidays',
|
||||
async (_, start, end, placementDate, periodStart, periodEnd) => {
|
||||
const result = isPlan(
|
||||
await runInNonBrowser({
|
||||
horizon: { start, end },
|
||||
balances: [
|
||||
{
|
||||
id: 'expired',
|
||||
type: 'rtt',
|
||||
quantity: 2,
|
||||
availableFrom: '2020-01-01',
|
||||
expiryDate: '2020-12-31',
|
||||
},
|
||||
{
|
||||
id: 'dated-cp',
|
||||
type: 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: placementDate,
|
||||
expiryDate: placementDate,
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'chosen-date',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: placementDate,
|
||||
endDate: placementDate,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.horizon).toEqual({ start, end });
|
||||
expect(result.placements).toEqual([
|
||||
{
|
||||
date: placementDate,
|
||||
balanceId: 'dated-cp',
|
||||
type: 'cp',
|
||||
ruleIds: ['chosen-date'],
|
||||
supporting: false,
|
||||
},
|
||||
]);
|
||||
expect(result.timeOffPeriods).toHaveLength(1);
|
||||
expect(result.timeOffPeriods[0]).toMatchObject({ start: periodStart, end: periodEnd });
|
||||
expect(result.remainingBalances.find((item) => item.id === 'expired')).toMatchObject({
|
||||
quantity: 2,
|
||||
type: 'rtt',
|
||||
availableFrom: '2020-01-01',
|
||||
expiryDate: '2020-12-31',
|
||||
classification: 'unavoidable-expiry',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects malformed horizons before calculation', async () => {
|
||||
const result = await planLeave({
|
||||
horizon: { start: '2025-01-01', end: '2024-01-01' },
|
||||
@@ -57,6 +119,18 @@ describe('planLeave', () => {
|
||||
if (result.kind === 'InvalidInput') expect(result.reasons[0].code).toBe('horizon-order');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['2024-02-30', '2025-01-01', 'horizon-start'],
|
||||
['2024-13-01', '2025-01-01', 'horizon-start'],
|
||||
['2024-01-01', '2024-00-01', 'horizon-end'],
|
||||
['not-a-date', '2025-01-01', 'horizon-start'],
|
||||
])('reports an invalid horizon date without throwing: %s to %s', async (start, end, code) => {
|
||||
const outcome = await planLeave({ horizon: { start, end }, balances: [], rules: [] });
|
||||
expect(outcome.kind).toBe('InvalidInput');
|
||||
if (outcome.kind === 'InvalidInput')
|
||||
expect(outcome.reasons.map((item) => item.code)).toContain(code);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'invalid balance quantity',
|
||||
@@ -620,6 +694,7 @@ describe('planLeave', () => {
|
||||
expect(result.timeOffPeriods[0].duration).toBe(9);
|
||||
});
|
||||
|
||||
// Async calculation yields to the test timer; allow the planner's ten-second budget.
|
||||
it('uses the 11 November and 14 July weeks in the representative default plan', async () => {
|
||||
const result = isPlan(
|
||||
await runInNonBrowser({
|
||||
@@ -677,7 +752,7 @@ describe('planLeave', () => {
|
||||
'2027-07-16',
|
||||
]),
|
||||
);
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it('moves a satisfied time-window preference to complete a holiday week', async () => {
|
||||
const result = isPlan(
|
||||
@@ -1411,6 +1486,56 @@ describe('planLeave transport', () => {
|
||||
});
|
||||
|
||||
describe('planning calculation context', () => {
|
||||
it('keeps a multi-year horizon attached to its immutable outcome, explanation and original trace', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2027-01-01' },
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2026-12-24',
|
||||
expiryDate: '2026-12-24',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'final-year',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2026-12-24',
|
||||
endDate: '2026-12-24',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
};
|
||||
const calculation = createPlanningCalculation(request);
|
||||
request.horizon.end = '2025-01-01';
|
||||
request.balances[0].expiryDate = '2024-12-31';
|
||||
expect(calculation.snapshot.horizon).toEqual({
|
||||
start: '2024-01-01',
|
||||
end: '2027-01-01',
|
||||
});
|
||||
expect(isPlan(await calculation.outcome).horizon).toEqual(calculation.snapshot.horizon);
|
||||
const explanation = await calculation.explanation;
|
||||
expect(explanation?.kind).toBe('PlanExplanation');
|
||||
if (explanation?.kind === 'PlanExplanation')
|
||||
expect(explanation.placements[0]).toMatchObject({
|
||||
placement: { date: '2026-12-24', balanceId: 'cp', ruleIds: ['final-year'] },
|
||||
balance: { availableFrom: '2026-12-24', expiryDate: '2026-12-24' },
|
||||
});
|
||||
expect((await calculation.trace)?.initialState.horizon).toEqual(
|
||||
calculation.snapshot.horizon,
|
||||
);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('replays full balance metadata when an existing balance changes', () => {
|
||||
const state = {
|
||||
balances: [
|
||||
|
||||
@@ -434,6 +434,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
||||
const timings = new Promise<PlanningCalculationTimings>((resolve) => {
|
||||
resolveTimings = resolve;
|
||||
});
|
||||
let supersede: (() => void) | undefined;
|
||||
let cancel: (() => void) | undefined;
|
||||
try {
|
||||
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
||||
@@ -441,6 +442,8 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
||||
});
|
||||
|
||||
let outcomeSettled = false;
|
||||
let cancellationRequested = false;
|
||||
let cancelledRace: PlanningIncompleteOutcome | undefined;
|
||||
let explanationSettled = false;
|
||||
let artifactsSettled = false;
|
||||
let checkpoint: PlanOutcome | undefined;
|
||||
@@ -483,17 +486,44 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
||||
};
|
||||
const finishOutcome = (result: PlanningOutcome) => {
|
||||
if (!outcomeSettled) {
|
||||
if (
|
||||
cancellationRequested &&
|
||||
(result.kind === 'Plan' || result.kind === 'PlanningConflict')
|
||||
) {
|
||||
cancelledRace = interruptedOutcome(
|
||||
result.kind === 'Plan' ? result : checkpoint,
|
||||
deadline,
|
||||
'user-cancelled',
|
||||
'The calculation was cancelled by the user.',
|
||||
);
|
||||
result = cancelledRace;
|
||||
}
|
||||
outcomeSettled = true;
|
||||
clearTimeout(livenessWatchdog);
|
||||
resolveOutcome(result);
|
||||
}
|
||||
};
|
||||
const cancelledExplanation = (
|
||||
result: PlanningExplanation | undefined,
|
||||
): PlanningExplanation | undefined => {
|
||||
if (!cancelledRace) return result;
|
||||
const incomplete = {
|
||||
kind: 'IncompleteExplanation' as const,
|
||||
reason: cancelledRace.reasons[0],
|
||||
completedStages: cancelledRace.completedStages,
|
||||
uncompletedStages: [],
|
||||
};
|
||||
return cancelledRace.incumbent && result?.kind === 'PlanExplanation'
|
||||
? { ...result, incomplete }
|
||||
: incomplete;
|
||||
};
|
||||
const finishExplanation = (
|
||||
result: PlanningExplanation | undefined,
|
||||
error: PlanningReason | undefined,
|
||||
finalState?: PlanningTraceState,
|
||||
) => {
|
||||
if (!explanationSettled) {
|
||||
if (!error) result = cancelledExplanation(result);
|
||||
explanationSettled = true;
|
||||
receivedExplanation = result;
|
||||
receivedExplanationError = error;
|
||||
@@ -511,6 +541,16 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
||||
explanationError: result.explanationError ?? receivedExplanationError,
|
||||
finalState: result.finalState ?? receivedFinalState,
|
||||
};
|
||||
if (cancelledRace) {
|
||||
if (!complete.explanationError)
|
||||
complete.explanation = cancelledExplanation(complete.explanation);
|
||||
if (complete.trace && complete.trace.status !== 'failed')
|
||||
complete.trace = {
|
||||
...complete.trace,
|
||||
status: 'incomplete',
|
||||
error: cancelledRace.reasons[0],
|
||||
};
|
||||
}
|
||||
if (finishExplanationPromise)
|
||||
finishExplanation(
|
||||
complete.explanation,
|
||||
@@ -529,7 +569,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
||||
traceTransferMs,
|
||||
});
|
||||
terminate();
|
||||
if (cancel) latestWorkerCancels.delete(cancel);
|
||||
if (supersede) latestWorkerCancels.delete(supersede);
|
||||
}
|
||||
};
|
||||
const partialArtifacts = (
|
||||
@@ -601,7 +641,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
||||
deltas.push(operation.delta);
|
||||
}
|
||||
};
|
||||
cancel = () => {
|
||||
supersede = () => {
|
||||
if (artifactsSettled) return;
|
||||
const hadOutcome = outcomeSettled;
|
||||
const reason = {
|
||||
@@ -628,7 +668,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
||||
},
|
||||
);
|
||||
};
|
||||
if (cancel) latestWorkerCancels.add(cancel);
|
||||
if (supersede) latestWorkerCancels.add(supersede);
|
||||
const finalizeFailure = (reason: { code: string; message: string }) => {
|
||||
if (!explanationSettled && checkpoint && checkpointState) {
|
||||
receivedFinalState ??= checkpointState;
|
||||
@@ -757,6 +797,15 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
||||
};
|
||||
scheduleLivenessWatchdog();
|
||||
|
||||
cancel = () => {
|
||||
if (outcomeSettled || cancellationRequested) return;
|
||||
cancellationRequested = true;
|
||||
try {
|
||||
worker.postMessage({ type: 'cancel' });
|
||||
} catch {
|
||||
fail('worker-error', 'The cancellation request could not reach the worker.');
|
||||
}
|
||||
};
|
||||
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
||||
const message = event.data;
|
||||
if (!message) return;
|
||||
@@ -1489,6 +1538,7 @@ function explainCheckpointInWorker(
|
||||
}
|
||||
|
||||
export interface PlanningCalculationContext {
|
||||
readonly cancel: () => void;
|
||||
readonly id: string;
|
||||
readonly snapshot: PlanningSnapshot;
|
||||
readonly outcome: Promise<PlanningOutcome>;
|
||||
@@ -1579,6 +1629,31 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
||||
};
|
||||
}
|
||||
} else {
|
||||
let cancellationRequested = false;
|
||||
let outcomeSettled = false;
|
||||
let superseded = false;
|
||||
const replacementReason: PlanningReason = {
|
||||
code: 'stale-calculation',
|
||||
message: 'Planning stopped because a newer calculation started.',
|
||||
};
|
||||
const explanationForRun = (value: PlanningExplanation | undefined) => {
|
||||
if (!superseded) return value;
|
||||
if (value?.kind === 'IncompleteExplanation')
|
||||
return { ...value, reason: replacementReason };
|
||||
if (value?.kind === 'PlanExplanation' && value.incomplete)
|
||||
return {
|
||||
...value,
|
||||
incomplete: { ...value.incomplete, reason: replacementReason },
|
||||
};
|
||||
return value;
|
||||
};
|
||||
const supersede = () => {
|
||||
if (!outcomeSettled) {
|
||||
superseded = true;
|
||||
cancellationRequested = true;
|
||||
}
|
||||
};
|
||||
latestWorkerCancels.add(supersede);
|
||||
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
||||
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
||||
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
|
||||
@@ -1600,15 +1675,26 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
||||
try {
|
||||
const core = await import('./leave-planner.core');
|
||||
let completedArtifacts: PlanningArtifacts = {};
|
||||
const result = core.calculatePlanningInternal(
|
||||
const result = await core.calculatePlanningAsyncInternal(
|
||||
transport,
|
||||
undefined,
|
||||
(value) => (completedArtifacts = value),
|
||||
undefined,
|
||||
(value) => {
|
||||
outcomeSettled = true;
|
||||
resolve(
|
||||
superseded && value.kind === 'PlanningIncomplete'
|
||||
? {
|
||||
...value,
|
||||
reasons: [replacementReason],
|
||||
}
|
||||
: value,
|
||||
);
|
||||
},
|
||||
(value, error) => {
|
||||
resolveExplanation(value);
|
||||
resolveExplanation(explanationForRun(value));
|
||||
resolveExplanationError(error);
|
||||
},
|
||||
() => cancellationRequested,
|
||||
);
|
||||
if (result.kind === 'InvalidInput') {
|
||||
resolveExplanation(undefined);
|
||||
@@ -1616,7 +1702,10 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
||||
}
|
||||
resolve(result);
|
||||
queueMicrotask(() => {
|
||||
resolveArtifacts(completedArtifacts);
|
||||
resolveArtifacts({
|
||||
...completedArtifacts,
|
||||
explanation: explanationForRun(completedArtifacts.explanation),
|
||||
});
|
||||
resolveTimings(completedArtifacts.timings ?? {});
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1636,10 +1725,22 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
||||
explanationError: reason,
|
||||
});
|
||||
resolveTimings({});
|
||||
} finally {
|
||||
outcomeSettled = true;
|
||||
latestWorkerCancels.delete(supersede);
|
||||
}
|
||||
});
|
||||
});
|
||||
run = { outcome, explanation, explanationError, artifacts, timings };
|
||||
run = {
|
||||
outcome,
|
||||
explanation,
|
||||
explanationError,
|
||||
artifacts,
|
||||
timings,
|
||||
cancel: () => {
|
||||
if (!outcomeSettled) cancellationRequested = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const retainedArtifacts = run.artifacts.then((artifacts) => ({
|
||||
@@ -1657,6 +1758,7 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
||||
const context: PlanningCalculationContext = {
|
||||
id,
|
||||
snapshot,
|
||||
cancel: () => run.cancel?.(),
|
||||
outcome: run.outcome,
|
||||
explanation: run.explanation,
|
||||
explanationError: run.explanationError,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
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';
|
||||
|
||||
describe('real planning worker cancellation', () => {
|
||||
let directory: string;
|
||||
let bundle: string;
|
||||
let afterCheckpoint: (() => void) | undefined;
|
||||
const threads = new Set<NodeWorker>();
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'vac-optimizer-worker-'));
|
||||
bundle = join(directory, 'worker.mjs');
|
||||
const entry = join(directory, 'entry.ts');
|
||||
writeFileSync(
|
||||
entry,
|
||||
`import { parentPort } from 'node:worker_threads';
|
||||
globalThis.self = { postMessage: (message) => parentPort.postMessage(message) };
|
||||
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
|
||||
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
`,
|
||||
);
|
||||
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
afterCheckpoint = undefined;
|
||||
class RealWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
private thread = new NodeWorker(bundle);
|
||||
constructor() {
|
||||
threads.add(this.thread);
|
||||
this.thread.on('message', (data) => {
|
||||
this.onmessage?.({ data } as MessageEvent);
|
||||
if (data.type === 'checkpoint') afterCheckpoint?.();
|
||||
});
|
||||
this.thread.on('error', () => this.onerror?.());
|
||||
}
|
||||
postMessage(message: unknown) {
|
||||
this.thread.postMessage(message);
|
||||
}
|
||||
terminate() {
|
||||
void this.thread.terminate();
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Worker', RealWorker);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...threads].map((thread) => thread.terminate()));
|
||||
threads.clear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
afterAll(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
it('stops during calendar preparation without a plan or an obligation conflict', async () => {
|
||||
const stages: string[] = [];
|
||||
let preparationUpdates = 0;
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||
balances: [],
|
||||
rules: [],
|
||||
progress: (stage) => {
|
||||
stages.push(stage);
|
||||
if (stage === 'preparation' && ++preparationUpdates === 2) calculation.cancel();
|
||||
},
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'PlanningIncomplete',
|
||||
reasons: [{ code: 'user-cancelled' }],
|
||||
});
|
||||
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||
expect(outcome.incumbent).toBeUndefined();
|
||||
expect(preparationUpdates).toBeGreaterThanOrEqual(2);
|
||||
expect(stages).not.toContain('obligations');
|
||||
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||
expect(await calculation.explanationError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('cancels before a displayed valid plan without reporting a queued conflict', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||
balances: [],
|
||||
rules: [
|
||||
{
|
||||
id: 'required',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-12-31',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
progress: (stage) => {
|
||||
if (stage === 'obligations') calculation.cancel();
|
||||
},
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'PlanningIncomplete',
|
||||
reasons: [{ code: 'user-cancelled' }],
|
||||
});
|
||||
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||
expect(outcome.incumbent).toBeUndefined();
|
||||
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||
});
|
||||
|
||||
it('stops an active obligation search and retains a jointly valid incumbent and its history', async () => {
|
||||
let cancelledAt = 0;
|
||||
afterCheckpoint = () => {
|
||||
afterCheckpoint = undefined;
|
||||
setTimeout(() => {
|
||||
cancelledAt = performance.now();
|
||||
calculation.cancel();
|
||||
}, 10);
|
||||
};
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 6, expiryDate: '2024-12-31' }],
|
||||
rules: ['first', 'second'].map((id) => ({
|
||||
id,
|
||||
type: 'time_window' as const,
|
||||
force: 'obligation' as const,
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-12-31',
|
||||
placeDays: 3,
|
||||
placeType: 'cp' as const,
|
||||
})),
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('PlanningIncomplete');
|
||||
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||
expect(outcome.reasons[0].code).toBe('user-cancelled');
|
||||
expect(performance.now() - cancelledAt).toBeLessThan(2000);
|
||||
expect(outcome.completedStages).not.toContain('obligations');
|
||||
expect(outcome.incumbent).toBeDefined();
|
||||
const incumbent = outcome.incumbent!;
|
||||
for (const id of ['first', 'second'])
|
||||
expect(
|
||||
incumbent.placements.filter((placement) => placement.ruleIds.includes(id)),
|
||||
).toHaveLength(3);
|
||||
expect(
|
||||
incumbent.remainingBalances.reduce((sum, balance) => sum + balance.quantity, 0) +
|
||||
incumbent.placements.length,
|
||||
).toBe(6);
|
||||
const explanation = await calculation.explanation;
|
||||
expect(explanation?.kind).toBe('PlanExplanation');
|
||||
if (explanation?.kind === 'PlanExplanation') {
|
||||
expect(explanation.placements.map(({ placement }) => placement)).toEqual(
|
||||
incumbent.placements,
|
||||
);
|
||||
expect(explanation.incomplete?.reason.code).toBe('user-cancelled');
|
||||
}
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.status).toBe('incomplete');
|
||||
expect(
|
||||
trace?.phases.find((phase) => phase.name === 'obligations')?.decisions.length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(trace?.phases.find((phase) => phase.name === 'dates')?.status).toBe('not-run');
|
||||
});
|
||||
|
||||
it('preserves balances and provenance after a valid checkpoint and drains every produced trace packet', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
|
||||
rules: [
|
||||
{
|
||||
id: 'fixed',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-02',
|
||||
endDate: '2024-01-02',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
progress: (stage) => {
|
||||
if (stage === 'non-working-day-gain') calculation.cancel();
|
||||
},
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('PlanningIncomplete');
|
||||
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||
expect(outcome.reasons[0].code).toBe('user-cancelled');
|
||||
expect(outcome.completedStages).toContain('holiday-week-value');
|
||||
expect(outcome.completedStages).not.toContain('non-working-day-gain');
|
||||
const incumbent = outcome.incumbent!;
|
||||
expect(incumbent.placements).toHaveLength(3);
|
||||
expect(incumbent.placements).toContainEqual({
|
||||
date: '2024-01-02',
|
||||
type: 'cp',
|
||||
balanceId: 'cp',
|
||||
ruleIds: ['fixed'],
|
||||
supporting: false,
|
||||
});
|
||||
expect(incumbent.remainingBalances).toEqual([]);
|
||||
const explanation = await calculation.explanation;
|
||||
expect(explanation?.kind).toBe('PlanExplanation');
|
||||
if (explanation?.kind === 'PlanExplanation')
|
||||
expect(explanation.placements.map(({ placement }) => placement)).toEqual(
|
||||
incumbent.placements,
|
||||
);
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.status).toBe('incomplete');
|
||||
expect(trace?.error?.code).toBe('user-cancelled');
|
||||
expect(trace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status).toBe(
|
||||
'interrupted',
|
||||
);
|
||||
const frozenOutcome = structuredClone(outcome);
|
||||
calculation.cancel();
|
||||
expect(await calculation.outcome).toEqual(frozenOutcome);
|
||||
});
|
||||
|
||||
it('completes the same deterministic plan as the synchronous adapter when not cancelled', async () => {
|
||||
const submitted = {
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
|
||||
rules: [],
|
||||
};
|
||||
const { calculatePlanningInternal } = await import('./leave-planner.core');
|
||||
const expected = calculatePlanningInternal(submitted);
|
||||
const calculation = createPlanningCalculation(submitted);
|
||||
expect(await calculation.outcome).toEqual(expected);
|
||||
expect((await calculation.trace)?.status).toBe('ready');
|
||||
expect(await calculation.explanationError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from './leave-planner.service';
|
||||
import {
|
||||
analyzeCounterfactualInternal,
|
||||
calculatePlanningInternal,
|
||||
calculatePlanningAsyncInternal,
|
||||
explainIncompleteCheckpoint,
|
||||
historicalCounterfactualReasons,
|
||||
type CounterfactualBaselineOptions,
|
||||
@@ -38,6 +38,7 @@ type WorkerRequest =
|
||||
request: WorkerPlanningRequest;
|
||||
}
|
||||
| { type: 'trace-ack'; count: number }
|
||||
| { type: 'cancel' }
|
||||
| {
|
||||
type: 'counterfactual';
|
||||
request: PlanningSnapshot;
|
||||
@@ -120,6 +121,7 @@ interface TraceDelivery {
|
||||
}
|
||||
|
||||
let activeTraceDelivery: TraceDelivery | undefined;
|
||||
let activeCalculation: { cancelled: boolean } | undefined;
|
||||
|
||||
function traceStatusIndex(trace: PlanningTrace): {
|
||||
statuses: Map<string, TraceCandidateStatus>;
|
||||
@@ -272,8 +274,12 @@ function pumpTraceUpdates(delivery: TraceDelivery): void {
|
||||
finishTraceDelivery(delivery);
|
||||
}
|
||||
|
||||
worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
worker.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
||||
try {
|
||||
if (event.data?.type === 'cancel') {
|
||||
if (activeCalculation) activeCalculation.cancelled = true;
|
||||
return;
|
||||
}
|
||||
if (event.data?.type === 'trace-ack') {
|
||||
const delivery = activeTraceDelivery;
|
||||
if (!delivery || !Number.isInteger(event.data.count) || event.data.count <= 0) return;
|
||||
@@ -282,6 +288,8 @@ worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
return;
|
||||
}
|
||||
if (event.data?.type === 'plan') {
|
||||
const calculation = { cancelled: false };
|
||||
activeCalculation = calculation;
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
let outcomePosted = false;
|
||||
let outcome: PlanningOutcome;
|
||||
@@ -294,7 +302,7 @@ worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
};
|
||||
activeTraceDelivery = traceDelivery;
|
||||
try {
|
||||
outcome = calculatePlanningInternal(
|
||||
outcome = await calculatePlanningAsyncInternal(
|
||||
{
|
||||
...event.data.request,
|
||||
progress: (stage: string) =>
|
||||
@@ -331,6 +339,7 @@ worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
explanationError,
|
||||
finalState,
|
||||
} satisfies WorkerResponse),
|
||||
() => calculation.cancelled,
|
||||
);
|
||||
} catch (error) {
|
||||
if (activeTraceDelivery === traceDelivery) activeTraceDelivery = undefined;
|
||||
@@ -341,6 +350,8 @@ worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
} satisfies WorkerResponse);
|
||||
return;
|
||||
} finally {
|
||||
if (activeCalculation === calculation) activeCalculation = undefined;
|
||||
}
|
||||
if (!outcomePosted)
|
||||
worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse);
|
||||
|
||||
@@ -6,7 +6,7 @@ export function planningDateInParis(value: Date): string {
|
||||
day: '2-digit',
|
||||
}).formatToParts(value);
|
||||
const get = (type: string) => parts.find((part) => part.type === type)?.value ?? '';
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
return `${get('year').padStart(4, '0')}-${get('month')}-${get('day')}`;
|
||||
}
|
||||
|
||||
export function addPlanningYears(key: string, years: number): string {
|
||||
@@ -16,3 +16,15 @@ export function addPlanningYears(key: string, years: number): string {
|
||||
if (date.getUTCMonth() !== month) date.setUTCDate(0);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function addPlanningDays(key: string, days: number): string {
|
||||
const date = new Date(`${key}T00:00:00Z`);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function isPlanningDate(value: unknown): value is string {
|
||||
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
@@ -57,6 +57,13 @@ export interface AppTranslations {
|
||||
dark: string;
|
||||
};
|
||||
systemAppearance: string;
|
||||
planningHorizon: string;
|
||||
planningStart: string;
|
||||
lastPlanningDate: string;
|
||||
planningDatesHelp: string;
|
||||
invalidDate: string;
|
||||
horizonOrder: string;
|
||||
invalidHorizon: string;
|
||||
};
|
||||
rules: {
|
||||
title: string;
|
||||
@@ -191,6 +198,10 @@ export interface AppTranslations {
|
||||
invalidInput: string;
|
||||
conflict: string;
|
||||
incomplete: string;
|
||||
cancel: string;
|
||||
cancelling: string;
|
||||
cancelled: string;
|
||||
cancelledPartial: string;
|
||||
error: string;
|
||||
complete: string;
|
||||
whyPlacement: string;
|
||||
|
||||
Reference in new issue
Block a user