Replace Vitest with bun test across the whole suite. A preload compiles components through Angular's JIT transform with inlined templates, sets up jsdom, TestBed and zoneless change detection, and a small shim covers the Vitest APIs bun:test lacks. Files run isolated, real-worker specs spawn the running Bun, and line coverage from lcov is converted to the existing HTML and Cobertura reports. The CI benchmark replaces its heap cap with a container memory limit. Vitest, Jest, Nx and their Vite configuration are removed. Refs #291 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3161 lines
115 KiB
TypeScript
3161 lines
115 KiB
TypeScript
import { createOptimizationPresetRules } from '../../services/rule-service';
|
||
import { provideZonelessChangeDetection } from '@angular/core';
|
||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||
import { vi } from '../../../testing/vitest-compat';
|
||
import { CalendarYear } from './calendar-year';
|
||
import { UserInput } from '../user-input/user-input';
|
||
import {
|
||
CalendarService,
|
||
DayType,
|
||
SelectedDate,
|
||
SelectedDates,
|
||
calendarProjectionHorizon,
|
||
SelectableDayType,
|
||
} from '../../services/calendar-service';
|
||
import {
|
||
CounterfactualAnalysis,
|
||
CounterfactualSelection,
|
||
LeavePlacement,
|
||
PlanOutcome,
|
||
PlanExplanation,
|
||
PlanningCalculationContext,
|
||
PlanningConflictOutcome,
|
||
PlanningExplanation,
|
||
PlanningIncompleteOutcome,
|
||
InvalidInputOutcome,
|
||
PlanningOutcome,
|
||
PlanningObjectiveVector,
|
||
} 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 { PlanningDurationService } from '../../services/planning-duration.service';
|
||
import { PlanningCalculationService } from '../../services/planning-calculation.service';
|
||
import { DateRange } from '@angular/material/datepicker';
|
||
|
||
const invalid: InvalidInputOutcome = {
|
||
kind: 'InvalidInput',
|
||
reasons: [],
|
||
};
|
||
|
||
const conflict: PlanningConflictOutcome = {
|
||
kind: 'PlanningConflict',
|
||
reasons: [],
|
||
conflictingRuleIds: ['rule'],
|
||
};
|
||
|
||
const incomplete: PlanningIncompleteOutcome = {
|
||
kind: 'PlanningIncomplete',
|
||
completedStages: [],
|
||
reasons: [],
|
||
};
|
||
|
||
const plan: PlanOutcome = {
|
||
kind: 'Plan',
|
||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||
placements: [
|
||
{
|
||
date: '2024-05-03',
|
||
balanceId: 'cp',
|
||
type: 'cp',
|
||
ruleIds: [],
|
||
supporting: false,
|
||
},
|
||
{
|
||
date: '2024-05-06',
|
||
balanceId: 'cp',
|
||
type: 'cp',
|
||
ruleIds: [],
|
||
supporting: false,
|
||
},
|
||
],
|
||
timeOffPeriods: [],
|
||
preferences: [],
|
||
remainingBalances: [],
|
||
metrics: {
|
||
unplacedAtRisk: 0,
|
||
holidayWeekValue: 0,
|
||
nonWorkingDayGain: 4,
|
||
longestWorkSequence: 6,
|
||
averageWorkSequence: 6,
|
||
averageTimeOffPeriod: null,
|
||
longestTimeOffPeriod: null,
|
||
shortSequenceDeficit: 0,
|
||
supportingPlacements: 0,
|
||
preferenceSatisfaction: [],
|
||
},
|
||
completedStages: ['dates'],
|
||
lastCompletedStage: 'dates',
|
||
};
|
||
|
||
const objectiveVector = {
|
||
objectives: [{ id: 'non-working-day-gain' as const, direction: 'maximize' as const, value: 4 }],
|
||
};
|
||
|
||
const explanation: PlanningExplanation = {
|
||
kind: 'PlanExplanation',
|
||
placements: plan.placements.map((placement, index) => ({
|
||
placement,
|
||
balance: {
|
||
id: 'cp',
|
||
name: 'Paid leave',
|
||
type: 'cp',
|
||
quantity: 2,
|
||
expiryDate: '2024-12-31',
|
||
},
|
||
ruleIds: placement.ruleIds,
|
||
supporting: false,
|
||
remainingQuantity: 1 - index,
|
||
contributions: [{ kind: 'entitlement', code: 'balance', label: 'Paid leave', value: 1 }],
|
||
finalMetrics: plan.metrics,
|
||
objectiveVector,
|
||
comparison: {
|
||
before: { objectives: [{ ...objectiveVector.objectives[0], value: 3 }] },
|
||
after: objectiveVector,
|
||
delta: [
|
||
{
|
||
id: 'non-working-day-gain',
|
||
direction: 'maximize',
|
||
value: 1,
|
||
},
|
||
],
|
||
firstDecisiveObjective: 'non-working-day-gain',
|
||
},
|
||
firstDecisiveObjective: 'non-working-day-gain',
|
||
})),
|
||
};
|
||
|
||
function projectionFor(
|
||
value: Parameters<typeof calendarProjectionHorizon>[0] & {
|
||
horizon: { start: string; end: string };
|
||
},
|
||
includeHoliday = false,
|
||
) {
|
||
const horizon = calendarProjectionHorizon(value)!;
|
||
const months: { monthIndex: number; year: number }[] = [];
|
||
const last = new Date(`${horizon.end}T00:00:00`);
|
||
last.setDate(last.getDate() - 1);
|
||
const cursor = new Date(`${horizon.start}T00:00:00`);
|
||
cursor.setDate(1);
|
||
while (cursor <= last) {
|
||
months.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
|
||
cursor.setMonth(cursor.getMonth() + 1);
|
||
}
|
||
return { months, selections: selectedDatesForPlan(value, includeHoliday) };
|
||
}
|
||
|
||
function selectedDatesForPlan(
|
||
value: { placements: readonly PlanOutcome['placements'][number][] },
|
||
includeHoliday = false,
|
||
): SelectedDates {
|
||
const ranges: SelectedDate[] = [];
|
||
if (includeHoliday) {
|
||
const holiday = new Date('2024-05-08T00:00:00');
|
||
ranges.push(new SelectedDate(DayType.CLOSED_DAY, new DateRange(holiday, holiday)));
|
||
}
|
||
for (const placement of value.placements) {
|
||
const date = new Date(`${placement.date}T00:00:00`);
|
||
ranges.push(new SelectedDate(DayType.CP, new DateRange(date, date)));
|
||
}
|
||
return new SelectedDates(ranges);
|
||
}
|
||
|
||
function placementForSelection(selection: CounterfactualSelection): LeavePlacement {
|
||
if (typeof selection === 'string')
|
||
return (
|
||
plan.placements.find(
|
||
(candidate) => candidate.date === selection || candidate.balanceId === selection,
|
||
) ?? plan.placements[0]
|
||
);
|
||
return selection;
|
||
}
|
||
|
||
function calculationFor(
|
||
outcome: PlanningOutcome,
|
||
stableExplanation?: PlanningExplanation,
|
||
): PlanningCalculationContext {
|
||
const counterfactual: CounterfactualAnalysis = {
|
||
kind: 'Counterfactual',
|
||
calculationId: 'test',
|
||
placement: plan.placements[0],
|
||
hypothetical: true,
|
||
baseline: objectiveVector,
|
||
results: [
|
||
{
|
||
date: '2024-05-03',
|
||
status: 'eligible',
|
||
comparison:
|
||
stableExplanation?.kind === 'PlanExplanation'
|
||
? stableExplanation.placements[0].comparison
|
||
: undefined,
|
||
},
|
||
{
|
||
date: '2024-05-04',
|
||
status: 'ineligible',
|
||
reason: { code: 'non-working', message: 'Weekend' },
|
||
},
|
||
{
|
||
date: '2024-05-07',
|
||
status: 'ineligible',
|
||
reason: { code: 'rule-invalid', message: 'Hypothetical constraint' },
|
||
},
|
||
{
|
||
date: '2024-05-08',
|
||
status: 'ineligible',
|
||
reason: { code: 'non-working', message: 'Public Holiday' },
|
||
},
|
||
],
|
||
};
|
||
return {
|
||
id: 'test',
|
||
cancel: vi.fn(),
|
||
snapshot: {
|
||
horizon: plan.horizon,
|
||
balances: [],
|
||
rules: [],
|
||
identities: {},
|
||
labels: {},
|
||
},
|
||
outcome: Promise.resolve(outcome),
|
||
explanation: Promise.resolve(stableExplanation),
|
||
explanationError: Promise.resolve(undefined),
|
||
analyzeCounterfactual: vi.fn(async (selection: CounterfactualSelection) => ({
|
||
...counterfactual,
|
||
placement: placementForSelection(selection),
|
||
})),
|
||
};
|
||
}
|
||
|
||
function calendarFor(outcome: PlanningOutcome, stableExplanation?: PlanningExplanation) {
|
||
const calculation = calculationFor(outcome, stableExplanation);
|
||
return {
|
||
calculation,
|
||
calendar: {
|
||
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
|
||
createPlanningCalculation: vi.fn(() => calculation),
|
||
project: vi.fn((value: Parameters<typeof projectionFor>[0]) => projectionFor(value)),
|
||
},
|
||
};
|
||
}
|
||
|
||
function deferred<T>() {
|
||
let resolve!: (value: T) => void;
|
||
let reject!: (reason?: unknown) => void;
|
||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||
resolve = resolvePromise;
|
||
reject = rejectPromise;
|
||
});
|
||
return { promise, resolve, reject };
|
||
}
|
||
|
||
function activateWithKeyboard(element: HTMLElement, key: 'Enter' | ' ' = 'Enter'): void {
|
||
element.focus();
|
||
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
||
// jsdom does not implement native keyboard activation for buttons or summaries.
|
||
element.click();
|
||
}
|
||
|
||
const presets = createOptimizationPresetRules();
|
||
|
||
describe('CalendarYear planning outcome boundary', () => {
|
||
beforeEach(() => localStorage.clear());
|
||
afterEach(() => {
|
||
localStorage.clear();
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it.each([
|
||
{
|
||
label: 'a Monday holiday directly beside a weekend',
|
||
start: '2024-05-21',
|
||
end: '2024-05-22',
|
||
placed: ['2024-05-21'],
|
||
expected: 1,
|
||
},
|
||
{
|
||
label: 'a Friday holiday directly beside a weekend',
|
||
start: '2024-10-31',
|
||
end: '2024-11-01',
|
||
placed: ['2024-10-31'],
|
||
expected: 1,
|
||
},
|
||
{
|
||
label: 'a Tuesday holiday bridged by placed Monday leave',
|
||
start: '2023-08-14',
|
||
end: '2023-08-16',
|
||
placed: ['2023-08-14'],
|
||
expected: 1,
|
||
},
|
||
{
|
||
label: 'a Tuesday holiday bridged only by a declared Monday',
|
||
start: '2023-08-14',
|
||
end: '2023-08-16',
|
||
declared: ['2023-08-14'],
|
||
expected: 1,
|
||
},
|
||
{
|
||
label: 'one Wednesday holiday in a completed public-holiday week valued at three',
|
||
start: '2024-04-29',
|
||
end: '2024-05-04',
|
||
declared: ['2024-04-29', '2024-04-30', '2024-05-02', '2024-05-03'],
|
||
expected: 1,
|
||
weighted: 3,
|
||
},
|
||
{
|
||
label: 'a weekday-only break without a weekend',
|
||
start: '2024-04-30',
|
||
end: '2024-05-02',
|
||
declared: ['2024-04-30'],
|
||
expected: 0,
|
||
},
|
||
{
|
||
label: 'a natural long weekend without any placement or declaration',
|
||
start: '2024-11-01',
|
||
end: '2024-11-04',
|
||
expected: 0,
|
||
},
|
||
{
|
||
label: 'a public holiday on Saturday',
|
||
start: '2021-04-30',
|
||
end: '2021-05-01',
|
||
placed: ['2021-04-30'],
|
||
expected: 0,
|
||
},
|
||
{
|
||
label: 'a public holiday on Sunday',
|
||
start: '2022-04-29',
|
||
end: '2022-04-30',
|
||
placed: ['2022-04-29'],
|
||
expected: 0,
|
||
},
|
||
{
|
||
label: 'a declaration beside a weekend without a public holiday',
|
||
start: '2024-06-14',
|
||
end: '2024-06-15',
|
||
declared: ['2024-06-14'],
|
||
expected: 0,
|
||
},
|
||
{
|
||
label: 'a Monday holiday beyond the horizon after Friday leave and its weekend',
|
||
start: '2024-05-17',
|
||
end: '2024-05-18',
|
||
placed: ['2024-05-17'],
|
||
expected: 1,
|
||
},
|
||
{
|
||
label: 'a Friday holiday before the horizon beside declared Monday leave',
|
||
start: '2024-11-04',
|
||
end: '2024-11-05',
|
||
declared: ['2024-11-04'],
|
||
expected: 1,
|
||
},
|
||
{
|
||
label: 'two distinct weekday holidays within one break',
|
||
start: '2024-05-10',
|
||
end: '2024-05-11',
|
||
declared: ['2024-05-10'],
|
||
expected: 2,
|
||
},
|
||
{
|
||
label: 'duplicate holiday date contributions',
|
||
start: '2024-05-10',
|
||
end: '2024-05-11',
|
||
declared: ['2024-05-10'],
|
||
expected: 2,
|
||
duplicate: true,
|
||
},
|
||
])(
|
||
'counts public holidays connected to weekends: $label',
|
||
async ({ start, end, placed = [], declared = [], expected, weighted, duplicate }) => {
|
||
const core = await import('../../services/leave-planner.core');
|
||
const outcome = core.calculatePlanningInternal({
|
||
calculationDate: '2020-01-01',
|
||
horizon: { start, end },
|
||
balances: placed.length
|
||
? [{ id: 'cp', type: 'cp', quantity: placed.length, expiryDate: '2025-12-31' }]
|
||
: [],
|
||
rules: placed.map((date) => ({
|
||
id: date,
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: date,
|
||
endDate: date,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
})),
|
||
declaredDays: declared.map((date) => ({ date, type: 'other' })),
|
||
});
|
||
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan.');
|
||
expect(outcome.placements.map(({ date }) => date)).toEqual(placed);
|
||
if (weighted !== undefined) expect(outcome.metrics.holidayWeekValue).toBe(weighted);
|
||
if (duplicate)
|
||
outcome.timeOffPeriods = outcome.timeOffPeriods.map((period) => ({
|
||
...period,
|
||
nonWorkingDates: [...period.nonWorkingDates, ...period.nonWorkingDates],
|
||
}));
|
||
const { calendar } = calendarFor(outcome);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
fixture.nativeElement.querySelector('.statistics-button').click();
|
||
await fixture.whenStable();
|
||
const holidayLabel = Array.from(document.querySelectorAll('[role="dialog"] dt')).find(
|
||
(label) => label.textContent?.includes('Public holidays connected to a weekend'),
|
||
);
|
||
expect(holidayLabel?.textContent?.replace(/\s+/g, ' ').trim()).toBe(
|
||
'Public holidays connected to a weekend (calendar days)',
|
||
);
|
||
expect(holidayLabel?.nextElementSibling?.textContent?.trim()).toBe(String(expected));
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
{ partial: false, placements: 2 },
|
||
{ partial: true, placements: 2 },
|
||
{ partial: false, placements: 0 },
|
||
{ partial: true, placements: 0 },
|
||
])(
|
||
'opens calculated statistics for the displayed plan (partial: $partial, placements: $placements)',
|
||
async ({ partial, placements }) => {
|
||
vi.setSystemTime(new Date('2024-05-01T12:00:00Z'));
|
||
const validPlan: PlanOutcome = {
|
||
...plan,
|
||
calculationDate: '2024-05-01',
|
||
placements: placements ? plan.placements : [],
|
||
declaredDays: [
|
||
{ date: '2024-05-07', type: 'rtt' },
|
||
{ date: '2024-05-10', type: 'other' },
|
||
],
|
||
timeOffPeriods: [
|
||
{
|
||
start: '2024-05-08',
|
||
end: '2024-05-12',
|
||
duration: 5,
|
||
placementDates: [],
|
||
declaredDates: ['2024-05-10'],
|
||
nonWorkingDates: ['2024-05-08', '2024-05-09', '2024-05-11', '2024-05-12'],
|
||
nonWorkingDayGain: 4,
|
||
},
|
||
],
|
||
ifThenContributions: [
|
||
{
|
||
ruleId: 'original-rule',
|
||
label: 'Original generating rule',
|
||
occurrence: '2024-05-08',
|
||
requested: 1,
|
||
declaredDays: [{ date: '2024-05-07', type: 'rtt' }],
|
||
placementDates: [],
|
||
earnedEntitlement: {
|
||
id: 'generated-entitlement',
|
||
type: 'cp',
|
||
quantity: 3,
|
||
availableFrom: '2024-05-09',
|
||
expiryDate: '2025-05-09',
|
||
added: true,
|
||
},
|
||
},
|
||
],
|
||
metrics: {
|
||
...plan.metrics,
|
||
averageWorkSequence: 10 / 3,
|
||
averageTimeOffPeriod: 2.5,
|
||
longestTimeOffPeriod: 4,
|
||
},
|
||
};
|
||
const outcome: PlanningOutcome = partial
|
||
? {
|
||
...incomplete,
|
||
incumbent: validPlan,
|
||
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
||
}
|
||
: validPlan;
|
||
const { calendar, calculation } = calendarFor(outcome, undefined);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
const input = TestBed.inject(UserInputService);
|
||
const rules = TestBed.inject(RuleService);
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
const before = {
|
||
balances: structuredClone(input.vacationNumberSignal()),
|
||
displayed: structuredClone(
|
||
TestBed.inject(PlanningCalculationService).displayedPlan(),
|
||
),
|
||
declarations: structuredClone(input.declaredDaysSignal()),
|
||
rules: structuredClone(rules.rules),
|
||
};
|
||
const opener = fixture.nativeElement.querySelector(
|
||
'.statistics-button',
|
||
) as HTMLButtonElement;
|
||
expect(opener).not.toBeNull();
|
||
expect(opener.textContent).toContain('Statistics');
|
||
activateWithKeyboard(opener);
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
const dialog = document.querySelector('[role="dialog"]')!;
|
||
expect(dialog.textContent).toContain('Plan statistics');
|
||
const statistics = () =>
|
||
Array.from(dialog.querySelectorAll('dt'), (label) => [
|
||
label.textContent?.replace(/\s+/g, ' ').trim(),
|
||
label.nextElementSibling?.textContent?.trim(),
|
||
]);
|
||
const expectedStatistics = [
|
||
['New leave days', String(placements)],
|
||
['Non-working days gained (calendar days)', '4'],
|
||
['Average work sequence (working days)', '3.333'],
|
||
['Longest work sequence (working days)', '6'],
|
||
['Average time-off period (calendar days)', '2.5'],
|
||
['Longest time-off period (calendar days)', '4'],
|
||
['Public holidays connected to a weekend (calendar days)', '2'],
|
||
['Earned leave before use (days)', '3'],
|
||
['Original generating rule', '3'],
|
||
];
|
||
expect(statistics()).toEqual(expectedStatistics);
|
||
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
|
||
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
|
||
expect(input.vacationNumberSignal()).toEqual(before.balances);
|
||
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toEqual(
|
||
before.displayed,
|
||
);
|
||
expect(input.declaredDaysSignal()).toEqual(before.declarations);
|
||
expect(rules.rules).toEqual(before.rules);
|
||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(statistics()).toEqual(expectedStatistics);
|
||
input.updateBalances(() => []);
|
||
input.toggleDeclaredDay('2024-05-10', 'other');
|
||
rules.deleteRule(rules.rules[0].id);
|
||
TestBed.inject(CalendarSettingsService).setPlanningDates('2021-05-14', '2021-05-14');
|
||
input.declarationEntrySignal.set(true);
|
||
vi.setSystemTime(new Date('2024-05-10T12:00:00Z'));
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
expect(statistics()).toEqual(expectedStatistics);
|
||
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
|
||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||
},
|
||
);
|
||
|
||
it.each([2, 3])('shows 3 gross earned days after consuming %s', async (used) => {
|
||
vi.setSystemTime(new Date('2024-05-01T21:59:59Z'));
|
||
try {
|
||
const core = await import('../../services/leave-planner.core');
|
||
const outcome = core.calculatePlanningInternal({
|
||
horizon: { start: '2024-04-29', end: used === 2 ? '2024-05-07' : '2024-05-09' },
|
||
balances: [],
|
||
declaredDays: ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' })),
|
||
rules: [
|
||
{
|
||
id: 'samedi',
|
||
name: 'Samedi Malin',
|
||
type: 'if_then',
|
||
force: 'preference',
|
||
dayOfWeek: 3,
|
||
placement: 'before',
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
rewardDays: 3,
|
||
rewardType: 'other',
|
||
},
|
||
{
|
||
id: 'use',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-05-02',
|
||
endDate: '2024-05-08',
|
||
placeDays: used,
|
||
placeType: 'other',
|
||
},
|
||
],
|
||
});
|
||
if (outcome.kind !== 'Plan') throw new Error('Expected an earned-leave plan.');
|
||
expect(outcome.placements).toHaveLength(used);
|
||
expect(outcome.remainingBalances).toMatchObject(used === 2 ? [{ quantity: 1 }] : []);
|
||
const { calendar } = calendarFor(outcome);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
vi.setSystemTime(new Date('2024-05-02T12:00:00Z'));
|
||
fixture.nativeElement.querySelector('.statistics-button').click();
|
||
await fixture.whenStable();
|
||
const dialog = document.querySelector('[role="dialog"]')!;
|
||
expect(
|
||
dialog
|
||
.querySelector('.statistics-summary > div:last-child dd')
|
||
?.textContent?.trim(),
|
||
).toBe('3');
|
||
expect(
|
||
Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [
|
||
label.textContent?.trim(),
|
||
label.nextElementSibling?.textContent?.trim(),
|
||
]),
|
||
).toEqual([['Samedi Malin', '3']]);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
describe('gross earned leave from finalized occurrences', () => {
|
||
const producer = {
|
||
id: 'samedi',
|
||
name: 'Samedi Malin',
|
||
type: 'if_then',
|
||
force: 'preference',
|
||
dayOfWeek: 3,
|
||
placement: 'before',
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
rewardDays: 2,
|
||
rewardType: 'other',
|
||
};
|
||
const declaredDays = ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' }));
|
||
const submitted = {
|
||
horizon: { start: '2024-04-29', end: '2024-05-07' },
|
||
balances: [],
|
||
rules: [producer],
|
||
declaredDays,
|
||
};
|
||
it.each([
|
||
{
|
||
scenario: 'availability after the calculation date',
|
||
today: '2024-05-01',
|
||
request: submitted,
|
||
total: 2,
|
||
availableFrom: '2024-05-02',
|
||
rows: [['Samedi Malin', '2']],
|
||
},
|
||
{
|
||
scenario: 'availability on the calculation date',
|
||
today: '2024-05-02',
|
||
request: submitted,
|
||
total: 0,
|
||
availableFrom: '2024-05-02',
|
||
rows: [],
|
||
},
|
||
{
|
||
scenario: 'availability before the calculation date',
|
||
today: '2024-05-03',
|
||
request: submitted,
|
||
total: 0,
|
||
availableFrom: '2024-05-02',
|
||
rows: [],
|
||
},
|
||
{
|
||
scenario: 'future earnings after the horizon without consumption',
|
||
today: '2024-05-01',
|
||
request: { ...submitted, horizon: { start: '2024-04-29', end: '2024-05-02' } },
|
||
total: 2,
|
||
availableFrom: '2024-05-02',
|
||
rows: [['Samedi Malin', '2']],
|
||
},
|
||
{
|
||
scenario: 'a later extension makes a provisional historical right future',
|
||
today: '2024-05-03',
|
||
request: {
|
||
...submitted,
|
||
horizon: { start: '2024-04-29', end: '2024-05-04' },
|
||
declaredDays: [...declaredDays, { date: '2024-05-03', type: 'other' }],
|
||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-12-31' }],
|
||
rules: [
|
||
producer,
|
||
{
|
||
id: 'extend',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-05-02',
|
||
endDate: '2024-05-02',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
},
|
||
total: 2,
|
||
availableFrom: '2024-05-06',
|
||
rows: [['Samedi Malin', '2']],
|
||
},
|
||
{
|
||
scenario: 'repeated shared occurrences attributed to distinct same-name rules',
|
||
today: '2024-05-01',
|
||
request: {
|
||
...submitted,
|
||
horizon: { start: '2024-04-29', end: '2024-05-09' },
|
||
declaredDays: ['2024-04-30', '2024-05-07'].map((date) => ({
|
||
date,
|
||
type: 'cp',
|
||
})),
|
||
rules: [
|
||
{
|
||
...producer,
|
||
id: 'first',
|
||
name: 'Same name',
|
||
placeDays: 1,
|
||
rewardDays: 1,
|
||
},
|
||
{
|
||
...producer,
|
||
id: 'second',
|
||
name: 'Same name',
|
||
placeDays: 1,
|
||
rewardDays: 2,
|
||
},
|
||
{ ...producer, placeDays: 1, rewardDays: 3 },
|
||
{ ...producer, id: 'disabled', enabled: false, rewardDays: 99 },
|
||
{ ...producer, id: 'zero', rewardDays: 0, placeDays: 1 },
|
||
],
|
||
},
|
||
total: 12,
|
||
availableFrom: '2024-05-02',
|
||
rows: [
|
||
['Same name', '2'],
|
||
['Same name', '4'],
|
||
['Samedi Malin', '6'],
|
||
],
|
||
},
|
||
])('renders $scenario', async ({ today, request, total, availableFrom, rows }) => {
|
||
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
|
||
try {
|
||
const core = await import('../../services/leave-planner.core');
|
||
const outcome = core.calculatePlanningInternal(request);
|
||
if (outcome.kind !== 'Plan') throw new Error('Expected a valid generating plan.');
|
||
expect(outcome.ifThenContributions?.[0].earnedEntitlement?.availableFrom).toBe(
|
||
availableFrom,
|
||
);
|
||
const { calendar } = calendarFor(outcome);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
vi.setSystemTime(new Date('2025-01-01T12:00:00Z'));
|
||
fixture.nativeElement.querySelector('.statistics-button').click();
|
||
await fixture.whenStable();
|
||
const dialog = document.querySelector('[role="dialog"]')!;
|
||
expect(
|
||
dialog
|
||
.querySelector('.statistics-summary > div:last-child dd')
|
||
?.textContent?.trim(),
|
||
).toBe(String(total));
|
||
expect(
|
||
Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [
|
||
label.textContent?.trim(),
|
||
label.nextElementSibling?.textContent?.trim(),
|
||
]),
|
||
).toEqual(rows);
|
||
expect(!!dialog.querySelector('#earned-leave-title')).toBe(total > 0);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it.each(['declarations', 'placements', 'both'])(
|
||
'renders an occurrence funded by %s',
|
||
async (source) => {
|
||
vi.setSystemTime(new Date('2024-05-01T12:00:00Z'));
|
||
try {
|
||
const core = await import('../../services/leave-planner.core');
|
||
const fixed = source === 'declarations' ? 2 : source === 'both' ? 1 : 0;
|
||
const outcome = core.calculatePlanningInternal({
|
||
...submitted,
|
||
declaredDays: declaredDays.slice(0, fixed),
|
||
balances:
|
||
fixed === 2
|
||
? []
|
||
: [
|
||
{
|
||
id: 'cp',
|
||
type: 'cp',
|
||
quantity: 2 - fixed,
|
||
expiryDate: '2025-12-31',
|
||
},
|
||
],
|
||
});
|
||
if (outcome.kind !== 'Plan')
|
||
throw new Error('Expected a valid generating plan.');
|
||
expect(outcome.ifThenContributions?.[0].declaredDays).toHaveLength(fixed);
|
||
expect(outcome.ifThenContributions?.[0].placementDates).toHaveLength(2 - fixed);
|
||
const { calendar } = calendarFor(outcome);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
fixture.nativeElement.querySelector('.statistics-button').click();
|
||
await fixture.whenStable();
|
||
expect(
|
||
document
|
||
.querySelector(
|
||
'[role="dialog"] .statistics-summary > div:last-child dd',
|
||
)
|
||
?.textContent?.trim(),
|
||
).toBe('2');
|
||
expect(
|
||
Array.from(
|
||
document.querySelectorAll('[role="dialog"] .earned-leave-breakdown dt'),
|
||
(label) => [
|
||
label.textContent?.trim(),
|
||
label.nextElementSibling?.textContent?.trim(),
|
||
],
|
||
),
|
||
).toEqual([['Samedi Malin', '2']]);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
},
|
||
);
|
||
});
|
||
|
||
it('shows the retained earnings after cancelling with a valid generating plan', async () => {
|
||
vi.setSystemTime(new Date('2024-04-28T12:00:00Z'));
|
||
// Yield between work slices so cancellation can follow a delivered valid checkpoint.
|
||
let elapsed = 0;
|
||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => (elapsed += 30));
|
||
let interrupted = false;
|
||
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
|
||
try {
|
||
const core = await import('../../services/leave-planner.core');
|
||
const outcome = await core.calculatePlanningAsyncInternal(
|
||
{
|
||
horizon: { start: '2024-04-29', end: '2024-05-11' },
|
||
declaredDays: [
|
||
{ date: '2024-04-29', type: 'cp' },
|
||
{ date: '2024-05-03', type: 'cp' },
|
||
{ date: '2024-05-06', type: 'other' },
|
||
],
|
||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-12-31' }],
|
||
rules: [
|
||
{
|
||
id: 'consumer',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-05-10',
|
||
endDate: '2024-05-10',
|
||
placeDays: 1,
|
||
placeType: 'other',
|
||
},
|
||
...['before', 'after'].map((placement, index) => ({
|
||
id: placement,
|
||
name: placement,
|
||
type: 'if_then',
|
||
force: 'preference',
|
||
dayOfWeek: 3,
|
||
placement,
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
rewardDays: index + 1,
|
||
rewardType: 'other',
|
||
})),
|
||
],
|
||
},
|
||
(value) => {
|
||
if (value.ifThenContributions?.some(({ ruleId }) => ruleId === 'after'))
|
||
interrupted = true;
|
||
},
|
||
(value) => (artifacts = value),
|
||
undefined,
|
||
undefined,
|
||
() => interrupted,
|
||
);
|
||
clock.mockRestore();
|
||
if (outcome.kind !== 'PlanningIncomplete' || !outcome.incumbent)
|
||
throw new Error('Expected a real cancellation with a retained valid plan.');
|
||
expect(interrupted).toBe(true);
|
||
expect(outcome.incumbent.ifThenContributions).toMatchObject([
|
||
{ ruleId: 'after', earnedEntitlement: { quantity: 2, added: true } },
|
||
]);
|
||
expect(outcome.incumbent.ifThenContributions).toHaveLength(1);
|
||
expect(outcome.incumbent.placements.map(({ date }) => date)).toEqual([
|
||
'2024-05-02',
|
||
'2024-05-10',
|
||
]);
|
||
const { calendar } = calendarFor(outcome, artifacts.explanation);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
fixture.nativeElement.querySelector('.statistics-button').click();
|
||
await fixture.whenStable();
|
||
const dialog = document.querySelector('[role="dialog"]')!;
|
||
expect(dialog.textContent).toContain('Partial plan');
|
||
expect(
|
||
dialog
|
||
.querySelector('.statistics-summary > div:last-child dd')
|
||
?.textContent?.trim(),
|
||
).toBe('2');
|
||
expect(
|
||
Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [
|
||
label.textContent?.trim(),
|
||
label.nextElementSibling?.textContent?.trim(),
|
||
]),
|
||
).toEqual([['after', '2']]);
|
||
} finally {
|
||
clock.mockRestore();
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it.each([false, true])(
|
||
'distinguishes empty averages from zero work and gain in a valid plan (all workdays off: %s)',
|
||
async (allDaysOff) => {
|
||
const core = await import('../../services/leave-planner.core');
|
||
const validPlan = core.calculatePlanningInternal({
|
||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||
balances: [],
|
||
rules: [],
|
||
declaredDays: allDaysOff
|
||
? ['08', '09', '10', '11', '12'].map((day) => ({
|
||
date: `2024-01-${day}`,
|
||
type: 'other',
|
||
}))
|
||
: [],
|
||
});
|
||
if (validPlan.kind !== 'Plan') throw new Error('Expected a valid plan.');
|
||
const { calendar } = calendarFor(validPlan);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
fixture.nativeElement.querySelector('.statistics-button').click();
|
||
await fixture.whenStable();
|
||
expect(
|
||
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
|
||
value.textContent?.trim(),
|
||
),
|
||
).toEqual(
|
||
allDaysOff
|
||
? ['0', '4', '—', '0', '9', '9', '0', '0']
|
||
: ['0', '0', '5', '5', '—', '—', '0', '0'],
|
||
);
|
||
},
|
||
);
|
||
|
||
it('localizes statistics and supports keyboard dismissal with focus restored to its opener', async () => {
|
||
const { calendar } = calendarFor({
|
||
...plan,
|
||
metrics: {
|
||
...plan.metrics,
|
||
averageWorkSequence: 10 / 3,
|
||
averageTimeOffPeriod: 2.5,
|
||
longestTimeOffPeriod: 4,
|
||
},
|
||
});
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const translation = TestBed.inject(TranslationService);
|
||
translation.setLanguage('en');
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
const opener = fixture.nativeElement.querySelector(
|
||
'.statistics-button',
|
||
) as HTMLButtonElement;
|
||
activateWithKeyboard(opener, ' ');
|
||
await fixture.whenStable();
|
||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
|
||
expect(opener.getAttribute('aria-haspopup')).toBe('dialog');
|
||
expect(dialog.getAttribute('aria-labelledby')).toBe('plan-statistics-title');
|
||
expect(dialog.getAttribute('aria-describedby')).toBe('plan-statistics-help');
|
||
await vi.waitFor(() => expect(document.activeElement).toBe(dialog.querySelector('h2')));
|
||
for (const [language, workUnit, calendarUnit, holidayLabel] of [
|
||
['de', 'Arbeitstage', 'Kalendertage', 'Feiertage mit Wochenendanschluss'],
|
||
['es', 'días laborables', 'días naturales', 'Festivos conectados con un fin de semana'],
|
||
['fr', 'jours ouvrés', 'jours calendaires', 'Jours fériés reliés à un week-end'],
|
||
]) {
|
||
translation.setLanguage(language);
|
||
fixture.detectChanges();
|
||
expect(dialog.textContent).toContain(workUnit);
|
||
expect(dialog.textContent).toContain(calendarUnit);
|
||
expect(dialog.textContent).toContain(holidayLabel);
|
||
expect(
|
||
Array.from(dialog.querySelectorAll('dd'), (value) => value.textContent?.trim()),
|
||
).toEqual(['2', '4', '3,333', '6', '2,5', '4', '0', '0']);
|
||
}
|
||
expect(dialog.textContent).toContain('Statistiques du planning');
|
||
expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés');
|
||
expect(dialog.querySelector('button')?.textContent?.trim()).toBe('Fermer');
|
||
dialog.dispatchEvent(
|
||
new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }),
|
||
);
|
||
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
||
expect(document.activeElement).toBe(opener);
|
||
activateWithKeyboard(opener);
|
||
await fixture.whenStable();
|
||
fixture.destroy();
|
||
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
||
});
|
||
|
||
it.each([
|
||
['a new valid plan', { ...plan, placements: [plan.placements[0]] }, false],
|
||
['invalid input', invalid, false],
|
||
['a planning conflict', conflict, false],
|
||
['an incomplete calculation without a retained plan', incomplete, false],
|
||
['a conflict after focus has moved to another control', conflict, true],
|
||
] satisfies [string, PlanningOutcome, boolean][])(
|
||
'replaces open statistics after %s and ignores an older valid outcome',
|
||
async (_, replacement: PlanningOutcome, focusMoved) => {
|
||
const oldOutcome = deferred<PlanningOutcome>();
|
||
const { calendar } = calendarFor(plan);
|
||
calendar.createPlanningCalculation
|
||
.mockReturnValueOnce(calculationFor(plan))
|
||
.mockReturnValueOnce({
|
||
...calculationFor(plan, undefined),
|
||
outcome: oldOutcome.promise,
|
||
})
|
||
.mockReturnValueOnce(calculationFor(replacement));
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
const root: HTMLElement = fixture.nativeElement;
|
||
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
|
||
await fixture.whenStable();
|
||
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
|
||
TestBed.inject(PlanningCalculationService).start();
|
||
fixture.detectChanges();
|
||
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
|
||
TestBed.inject(PlanningCalculationService).start();
|
||
fixture.detectChanges();
|
||
const otherControl = document.createElement('button');
|
||
otherControl.textContent = 'Another control';
|
||
document.body.appendChild(otherControl);
|
||
if (focusMoved) otherControl.focus();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
||
oldOutcome.resolve(plan);
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(replacement);
|
||
const opener = root.querySelector<HTMLButtonElement>('.statistics-button');
|
||
if (replacement.kind === 'Plan') {
|
||
expect(opener).not.toBeNull();
|
||
opener!.click();
|
||
await fixture.whenStable();
|
||
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('1');
|
||
} else {
|
||
expect(opener).toBeNull();
|
||
expect(document.activeElement).toBe(
|
||
focusMoved ? otherControl : root.querySelector('#planning-results-title'),
|
||
);
|
||
}
|
||
otherControl.remove();
|
||
},
|
||
);
|
||
|
||
it.each([false, true])(
|
||
'shows fixed rule credits separately from balance-consuming placements (partial: %s)',
|
||
async (partial) => {
|
||
const core = await import('../../services/leave-planner.core');
|
||
const declaredDays = partial
|
||
? [{ date: '2024-01-10', type: 'cp' as const }]
|
||
: ['08', '09', '10'].map((day) => ({
|
||
date: `2024-01-${day}`,
|
||
type: 'cp' as const,
|
||
}));
|
||
const submitted = {
|
||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||
balances: partial
|
||
? [
|
||
{
|
||
id: 'cp',
|
||
type: 'cp' as const,
|
||
quantity: 1,
|
||
availableFrom: '2024-01-12',
|
||
expiryDate: '2024-01-12',
|
||
},
|
||
]
|
||
: [],
|
||
declaredDays,
|
||
labels: { 'two-cp': 'Two CP including booked leave' },
|
||
rules: [
|
||
{
|
||
id: 'two-cp',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-01-08',
|
||
endDate: '2024-01-12',
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
};
|
||
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
|
||
const outcome = core.calculatePlanningInternal(
|
||
submitted,
|
||
undefined,
|
||
(value) => (artifacts = value),
|
||
);
|
||
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
|
||
localStorage.setItem('leaveBalances', '[]');
|
||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [provideZonelessChangeDetection()],
|
||
}).compileComponents();
|
||
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue({
|
||
...calculationFor(outcome),
|
||
explanation: Promise.resolve(artifacts.explanation),
|
||
});
|
||
const settings = TestBed.inject(CalendarSettingsService);
|
||
settings.setPlanningDates('2024-01-08', '2024-01-12');
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
const root: HTMLElement = fixture.nativeElement;
|
||
const summary =
|
||
root.querySelector('.declared-rule')?.textContent?.replace(/\s+/g, ' ') ?? '';
|
||
expect(summary).toContain('Two CP including booked leave');
|
||
expect(summary).toContain(
|
||
partial
|
||
? '1 Already booked + 1 leave days used = 2 / 2'
|
||
: '2 Already booked + 0 leave days used = 2 / 2',
|
||
);
|
||
expect(summary).toContain('Paid Leave');
|
||
expect(summary).toContain('no remaining balance debit');
|
||
for (const day of declaredDays) {
|
||
const cell = root.querySelector<HTMLButtonElement>(
|
||
`button[data-date="${day.date}"]`,
|
||
)!;
|
||
expect(cell.classList).toContain('cp');
|
||
expect(cell.getAttribute('aria-label')).toContain('Already booked');
|
||
}
|
||
if (!partial)
|
||
expect(summary).not.toContain(
|
||
fixture.componentInstance['displayDate']('2024-01-10'),
|
||
);
|
||
expect(artifacts.explanation).toMatchObject({
|
||
timeWindowContributions: outcome.timeWindowContributions,
|
||
});
|
||
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-10', 'rtt');
|
||
settings.setPlanningDates('2025-01-01', '2025-01-31');
|
||
fixture.detectChanges();
|
||
expect(root.querySelector('.declared-rule')?.textContent?.replace(/\s+/g, ' ')).toBe(
|
||
summary,
|
||
);
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
['2024-05-31', '2024-06-01', '2024-06-02', [], 3, 2],
|
||
[
|
||
'2024-07-31',
|
||
'2024-08-01',
|
||
'2024-08-01',
|
||
[{ date: '2024-08-01', type: 'cp' as const }],
|
||
2,
|
||
0,
|
||
],
|
||
] as const)(
|
||
'shows complete declared-only periods and context dates from %s',
|
||
async (start, end, contextDate, outsideDeclarations, duration, gain) => {
|
||
const core = await import('../../services/leave-planner.core');
|
||
const submitted = {
|
||
horizon: { start, end },
|
||
balances: [],
|
||
rules: [],
|
||
declaredDays: [{ date: start, type: 'other' as const }, ...outsideDeclarations],
|
||
};
|
||
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
|
||
const outcome = core.calculatePlanningInternal(
|
||
submitted,
|
||
undefined,
|
||
(value) => (artifacts = value),
|
||
);
|
||
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
|
||
localStorage.setItem('leaveBalances', '[]');
|
||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [provideZonelessChangeDetection()],
|
||
}).compileComponents();
|
||
const calculation = {
|
||
...calculationFor(outcome),
|
||
explanation: Promise.resolve(artifacts.explanation),
|
||
};
|
||
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
|
||
calculation,
|
||
);
|
||
const settings = TestBed.inject(CalendarSettingsService);
|
||
settings.setPlanningDates(start, start);
|
||
const input = TestBed.inject(UserInputService);
|
||
input.toggleDeclaredDay(start, 'other');
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
const root: HTMLElement = fixture.nativeElement;
|
||
const summary =
|
||
root.querySelector('.declared-period')?.textContent?.replace(/\s+/g, ' ') ?? '';
|
||
expect(summary).toContain(`${duration} days off in a row`);
|
||
expect(summary).toContain(`${gain} weekend & holiday days`);
|
||
expect(summary).toContain('0 leave days used');
|
||
expect(summary).toContain('no remaining balance debit');
|
||
const declared = root.querySelector<HTMLButtonElement>(`button[data-date="${start}"]`)!;
|
||
expect(declared.classList).toContain('other');
|
||
expect(declared.getAttribute('aria-label')).toContain('Already booked');
|
||
const context = root.querySelector<HTMLButtonElement>(
|
||
`button[data-date="${contextDate}"]`,
|
||
)!;
|
||
expect(context.disabled).toBe(true);
|
||
expect(context.classList).toContain('context-only');
|
||
expect(context.classList).toContain('selected');
|
||
expect(context.getAttribute('aria-label')).toContain(
|
||
'Context date outside the planning horizon',
|
||
);
|
||
if (outsideDeclarations.length) {
|
||
expect(context.classList).toContain('cp');
|
||
expect(context.getAttribute('aria-label')).toContain('Already booked');
|
||
}
|
||
input.toggleDeclaredDay(start, 'cp');
|
||
settings.setPlanningDates('2025-01-01', '2025-01-31');
|
||
fixture.detectChanges();
|
||
expect(root.querySelector('.declared-period')?.textContent?.replace(/\s+/g, ' ')).toBe(
|
||
summary,
|
||
);
|
||
expect(declared.getAttribute('aria-label')).toContain('Other');
|
||
expect(fixture.componentInstance['displayedPlan']()?.metrics.nonWorkingDayGain).toBe(
|
||
gain,
|
||
);
|
||
expect(input.vacationNumberSignal()).toEqual([]);
|
||
},
|
||
);
|
||
|
||
it.each([false, true])(
|
||
'keeps adjacent context declarations attached to displayed anchors (fixed anchor: %s)',
|
||
async (fixedAnchor) => {
|
||
const core = await import('../../services/leave-planner.core');
|
||
const placementDate = fixedAnchor ? '2024-07-30' : '2024-07-31';
|
||
const submitted = {
|
||
horizon: { start: '2024-07-30', end: '2024-08-01' },
|
||
balances: [
|
||
{
|
||
id: 'rtt',
|
||
type: 'rtt' as const,
|
||
quantity: 1,
|
||
availableFrom: placementDate,
|
||
expiryDate: placementDate,
|
||
},
|
||
],
|
||
rules: [presets[0]],
|
||
declaredDays: [
|
||
{ date: '2024-08-01', type: 'cp' as const },
|
||
...(fixedAnchor ? [{ date: '2024-07-31', type: 'other' as const }] : []),
|
||
],
|
||
};
|
||
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
|
||
const outcome = core.calculatePlanningInternal(
|
||
submitted,
|
||
undefined,
|
||
(value) => (artifacts = value),
|
||
);
|
||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
|
||
localStorage.setItem('leaveBalances', '[]');
|
||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [provideZonelessChangeDetection()],
|
||
}).compileComponents();
|
||
const calculation = calculationFor(outcome, artifacts.explanation);
|
||
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
|
||
calculation,
|
||
);
|
||
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-07-30', '2024-07-31');
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
const root: HTMLElement = fixture.nativeElement;
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
fixture.detectChanges();
|
||
const after = root.querySelector<HTMLButtonElement>('button[data-date="2024-08-01"]')!;
|
||
expect(after.classList).toContain('selected');
|
||
expect(after.classList).toContain('cp');
|
||
expect(after.classList).toContain('context-only');
|
||
expect(after.disabled).toBe(true);
|
||
expect(after.getAttribute('aria-label')).toContain('Already booked');
|
||
expect(fixture.componentInstance['displayedPlan']()?.placements).toEqual(
|
||
outcome.placements,
|
||
);
|
||
TestBed.inject(UserInputService).toggleDeclaredDay('2024-08-01', 'rtt');
|
||
fixture.detectChanges();
|
||
expect(after.getAttribute('aria-label')).toContain('CP');
|
||
},
|
||
);
|
||
|
||
it('previews a growing and shrinking range across months, then saves it once on release', async () => {
|
||
localStorage.setItem('app.language', '"fr"');
|
||
localStorage.setItem('leaveBalances', '[]');
|
||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear, UserInput],
|
||
providers: [provideZonelessChangeDetection()],
|
||
}).compileComponents();
|
||
const calculate = vi
|
||
.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation')
|
||
.mockReturnValue(calculationFor({ ...plan, placements: [] }));
|
||
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-04-26', '2024-05-10');
|
||
const input = TestBed.inject(UserInputService);
|
||
input.toggleDeclaredDay('2024-04-26', 'other');
|
||
input.toggleDeclaredDay('2024-04-30', 'cp');
|
||
input.toggleDeclaredDay('2024-05-02', 'rtt');
|
||
const controlsFixture = TestBed.createComponent(UserInput);
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
controlsFixture.detectChanges();
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
const controls: HTMLElement = controlsFixture.nativeElement;
|
||
const root: HTMLElement = fixture.nativeElement;
|
||
controls.querySelector<HTMLButtonElement>('button.declaration-entry-toggle')!.click();
|
||
controlsFixture.detectChanges();
|
||
fixture.detectChanges();
|
||
const day = (date: string) =>
|
||
root.querySelector<HTMLButtonElement>(`[data-date="${date}"]`)!;
|
||
const pointer = (event: string, date: string) => {
|
||
day(date).dispatchEvent(
|
||
new PointerEvent(event, {
|
||
bubbles: true,
|
||
pointerId: 1,
|
||
pointerType: 'mouse',
|
||
isPrimary: true,
|
||
button: 0,
|
||
buttons: event === 'pointerup' ? 0 : 1,
|
||
clientX: 200,
|
||
clientY: 200,
|
||
}),
|
||
);
|
||
fixture.detectChanges();
|
||
};
|
||
const saved = localStorage.getItem('declaredNonWorkingDays');
|
||
pointer('pointerdown', '2024-04-29');
|
||
pointer('pointermove', '2024-05-07');
|
||
expect(day('2024-05-07').classList.contains('declaration-preview')).toBe(true);
|
||
expect(day('2024-05-02').classList.contains('cp')).toBe(true);
|
||
expect(day('2024-05-02').classList.contains('rtt')).toBe(false);
|
||
expect(day('2024-04-30').classList.contains('declaration-erase')).toBe(true);
|
||
expect(day('2024-04-30').classList.contains('cp')).toBe(false);
|
||
expect(day('2024-04-30').getAttribute('aria-pressed')).toBe('false');
|
||
expect(day('2024-05-01').classList.contains('declaration-preview')).toBe(false);
|
||
expect(day('2024-05-04').classList.contains('declaration-preview')).toBe(false);
|
||
expect(localStorage.getItem('declaredNonWorkingDays')).toBe(saved);
|
||
pointer('pointermove', '2024-04-29');
|
||
expect(day('2024-04-30').classList.contains('declaration-preview')).toBe(false);
|
||
expect(day('2024-04-30').classList.contains('cp')).toBe(true);
|
||
pointer('pointermove', '2024-05-03');
|
||
expect(day('2024-04-30').classList.contains('declaration-erase')).toBe(true);
|
||
expect(day('2024-05-07').classList.contains('declaration-preview')).toBe(false);
|
||
pointer('pointerup', '2024-05-03');
|
||
// A browser click follows pointerup; it must not toggle the endpoint again.
|
||
day('2024-05-03').dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }));
|
||
fixture.detectChanges();
|
||
expect(root.querySelector('.declaration-preview')).toBeNull();
|
||
expect(root.querySelector('[role="status"]')?.textContent).toContain(
|
||
'Jours déjà posés enregistrés : 3 (Congé Payé). Déclarations supprimées : 1.',
|
||
);
|
||
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
||
{ date: '2024-04-26', type: 'other' },
|
||
{ date: '2024-04-29', type: 'cp' },
|
||
{ date: '2024-05-02', type: 'cp' },
|
||
{ date: '2024-05-03', type: 'cp' },
|
||
]);
|
||
expect(day('2024-05-02').textContent?.trim()).toBe('2');
|
||
expect(day('2024-05-02').getAttribute('aria-label')).toContain('Déjà posé (CP)');
|
||
|
||
controls.querySelector<HTMLButtonElement>('[data-declaration-erase]')!.click();
|
||
controlsFixture.detectChanges();
|
||
fixture.detectChanges();
|
||
pointer('pointerdown', '2024-05-03');
|
||
pointer('pointermove', '2024-04-30');
|
||
expect(day('2024-05-02').classList.contains('declaration-erase')).toBe(true);
|
||
expect(day('2024-05-02').classList.contains('cp')).toBe(false);
|
||
expect(input.declaredDaysSignal()).toHaveLength(4);
|
||
pointer('pointerup', '2024-04-30');
|
||
expect(root.querySelector('[role="status"]')?.textContent).toContain(
|
||
'Déclarations supprimées : 2.',
|
||
);
|
||
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
||
{ date: '2024-04-26', type: 'other' },
|
||
{ date: '2024-04-29', type: 'cp' },
|
||
]);
|
||
expect(input.vacationNumberSignal()).toEqual([]);
|
||
expect(calculate).toHaveBeenCalledOnce();
|
||
|
||
const beforeCancellation = localStorage.getItem('declaredNonWorkingDays');
|
||
pointer('pointerdown', '2024-04-29');
|
||
pointer('pointermove', '2024-05-03');
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||
pointer('pointerup', '2024-05-03');
|
||
expect(root.querySelector('.declaration-preview')).toBeNull();
|
||
expect(localStorage.getItem('declaredNonWorkingDays')).toBe(beforeCancellation);
|
||
pointer('pointerdown', '2024-04-29');
|
||
pointer('pointermove', '2024-05-03');
|
||
pointer('pointercancel', '2024-05-03');
|
||
pointer('pointerup', '2024-05-03');
|
||
expect(localStorage.getItem('declaredNonWorkingDays')).toBe(beforeCancellation);
|
||
|
||
// The first animation frame may have the same timestamp as the pointer move.
|
||
let scrollFrame: FrameRequestCallback | undefined;
|
||
const requestFrame = vi
|
||
.spyOn(window, 'requestAnimationFrame')
|
||
.mockImplementation((callback) => {
|
||
scrollFrame = callback;
|
||
return 1;
|
||
});
|
||
const scroll = vi.spyOn(window, 'scrollBy').mockImplementation(() => undefined);
|
||
pointer('pointerdown', '2024-04-29');
|
||
day('2024-05-03').dispatchEvent(
|
||
new PointerEvent('pointermove', {
|
||
bubbles: true,
|
||
pointerId: 1,
|
||
pointerType: 'mouse',
|
||
buttons: 1,
|
||
clientX: 200,
|
||
clientY: window.innerHeight - 1,
|
||
}),
|
||
);
|
||
scrollFrame!(0);
|
||
expect(scroll.mock.calls[0][1]).toBeGreaterThan(0);
|
||
pointer('pointercancel', '2024-05-03');
|
||
requestFrame.mockRestore();
|
||
scroll.mockRestore();
|
||
|
||
// Touch scrolling remains native, and a subsequent tap must still work after a drag.
|
||
controls.querySelector<HTMLButtonElement>('[data-declared-type="other"]')!.click();
|
||
controlsFixture.detectChanges();
|
||
fixture.detectChanges();
|
||
for (const event of ['pointerdown', 'pointermove', 'pointerup']) {
|
||
day('2024-05-06').dispatchEvent(
|
||
new PointerEvent(event, {
|
||
bubbles: true,
|
||
pointerId: 2,
|
||
pointerType: 'touch',
|
||
isPrimary: true,
|
||
button: 0,
|
||
}),
|
||
);
|
||
}
|
||
expect(root.querySelector('.declaration-preview')).toBeNull();
|
||
day('2024-05-06').dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }));
|
||
fixture.detectChanges();
|
||
expect(day('2024-05-06').getAttribute('aria-label')).toContain('Déjà posé (Autre)');
|
||
pointer('pointerdown', '2024-04-29');
|
||
pointer('pointermove', '2024-05-03');
|
||
controls.querySelector<HTMLButtonElement>('button.declaration-entry-toggle')!.click();
|
||
controlsFixture.detectChanges();
|
||
fixture.detectChanges();
|
||
pointer('pointerup', '2024-05-03');
|
||
expect(input.declaredDaysSignal()).toEqual([
|
||
{ date: '2024-04-26', type: 'other' },
|
||
{ date: '2024-04-29', type: 'cp' },
|
||
{ date: '2024-05-06', type: 'other' },
|
||
]);
|
||
});
|
||
|
||
it('edits declarations from configuration and restores the preceding result when switched off', async () => {
|
||
localStorage.setItem('app.language', '"fr"');
|
||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||
localStorage.setItem('leaveBalances', '[]');
|
||
localStorage.setItem(
|
||
'declaredNonWorkingDays',
|
||
JSON.stringify([{ date: '2024-01-03', type: 'rtt' }]),
|
||
);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear, UserInput],
|
||
providers: [provideZonelessChangeDetection()],
|
||
}).compileComponents();
|
||
const calendar = TestBed.inject(CalendarService);
|
||
const savedPlan: PlanOutcome = {
|
||
...plan,
|
||
horizon: { start: '2024-01-02', end: '2024-01-08' },
|
||
placements: [],
|
||
declaredDays: [{ date: '2024-01-03', type: 'rtt' }],
|
||
};
|
||
const calculate = vi
|
||
.spyOn(calendar, 'createPlanningCalculation')
|
||
.mockReturnValue(calculationFor(savedPlan));
|
||
const settings = TestBed.inject(CalendarSettingsService);
|
||
settings.setPlanningDates('2024-01-02', '2024-01-07');
|
||
const input = TestBed.inject(UserInputService);
|
||
const controlsFixture = TestBed.createComponent(UserInput);
|
||
controlsFixture.detectChanges();
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
const root: HTMLElement = fixture.nativeElement;
|
||
const controls: HTMLElement = controlsFixture.nativeElement;
|
||
expect(root.querySelector('.declaration-controls')).toBeNull();
|
||
expect(controls.querySelector('.declaration-view-toggle')).toBeNull();
|
||
expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(0);
|
||
const editButton = controls.querySelector<HTMLButtonElement>(
|
||
'button.declaration-entry-toggle',
|
||
)!;
|
||
expect(editButton.getAttribute('aria-pressed')).toBe('false');
|
||
expect(root.querySelector('.calendar-legend li')?.textContent?.trim()).toBe('Déjà posé');
|
||
const day = (date: string) =>
|
||
root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)!;
|
||
expect(calculate.mock.calls[0][0]).toMatchObject({ declaredDays: savedPlan.declaredDays });
|
||
expect(day('2024-01-03').getAttribute('aria-label')).toContain('Déjà posé');
|
||
expect(day('2024-01-03').getAttribute('aria-label')).toContain('RTT');
|
||
|
||
settings.setPlanningDates('2024-01-08', '2024-02-02');
|
||
activateWithKeyboard(editButton, ' ');
|
||
controlsFixture.detectChanges();
|
||
fixture.detectChanges();
|
||
expect(editButton.getAttribute('aria-pressed')).toBe('true');
|
||
expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(3);
|
||
expect(
|
||
controls.querySelector('[data-declared-type="cp"]')?.getAttribute('aria-pressed'),
|
||
).toBe('true');
|
||
expect(controls.querySelector('[data-declared-type="any"]')).toBeNull();
|
||
expect(day('2024-01-03').disabled).toBe(true);
|
||
expect(day('2024-01-08').disabled).toBe(false);
|
||
activateWithKeyboard(day('2024-01-08'), ' ');
|
||
fixture.detectChanges();
|
||
expect(day('2024-01-08').getAttribute('aria-pressed')).toBe('true');
|
||
expect(day('2024-01-08').getAttribute('aria-label')).toContain('Déjà posé');
|
||
expect(day('2024-01-08').getAttribute('aria-label')).toContain('CP');
|
||
activateWithKeyboard(day('2024-01-08'));
|
||
fixture.detectChanges();
|
||
expect(day('2024-01-08').getAttribute('aria-pressed')).toBe('false');
|
||
day('2024-01-08').click();
|
||
activateWithKeyboard(
|
||
controls.querySelector<HTMLButtonElement>('[data-declared-type="other"]')!,
|
||
);
|
||
controlsFixture.detectChanges();
|
||
fixture.detectChanges();
|
||
day('2024-01-08').click();
|
||
fixture.detectChanges();
|
||
expect(day('2024-01-08').getAttribute('aria-label')).toContain('Autre');
|
||
expect(day('2024-01-13').disabled).toBe(true);
|
||
expect(input.declaredDaysSignal()).toEqual([
|
||
{ date: '2024-01-03', type: 'rtt' },
|
||
{ date: '2024-01-08', type: 'other' },
|
||
]);
|
||
expect(input.vacationNumberSignal()).toEqual([]);
|
||
expect(calculate).toHaveBeenCalledOnce();
|
||
activateWithKeyboard(editButton, ' ');
|
||
controlsFixture.detectChanges();
|
||
fixture.detectChanges();
|
||
expect(editButton.getAttribute('aria-pressed')).toBe('false');
|
||
expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(0);
|
||
expect(day('2024-01-03').getAttribute('aria-label')).toContain('RTT');
|
||
expect(day('2024-01-08').disabled).toBe(true);
|
||
activateWithKeyboard(day('2024-01-03'));
|
||
fixture.detectChanges();
|
||
expect(root.querySelector('[role="status"]')?.textContent).toContain('Déjà posé');
|
||
expect(root.querySelector('[role="status"]')?.textContent).toContain('solde');
|
||
});
|
||
|
||
it('keeps the preceding calendar until a submitted snapshot returns and ignores later edits', async () => {
|
||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||
localStorage.setItem('leaveBalances', '[]');
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear, UserInput],
|
||
providers: [provideZonelessChangeDetection()],
|
||
}).compileComponents();
|
||
const calendar = TestBed.inject(CalendarService);
|
||
const initial: PlanOutcome = {
|
||
...plan,
|
||
horizon: { start: '2024-01-02', end: '2024-01-08' },
|
||
placements: [{ ...plan.placements[0], date: '2024-01-04', ruleIds: ['linked'] }],
|
||
declaredDays: [{ date: '2024-01-03', type: 'rtt' }],
|
||
};
|
||
const oldExplanation: PlanExplanation = {
|
||
kind: 'PlanExplanation',
|
||
placements: [
|
||
{
|
||
...explanation.placements[0],
|
||
placement: initial.placements[0],
|
||
ruleIds: ['linked'],
|
||
},
|
||
],
|
||
};
|
||
const lateExplanation = deferred<PlanningExplanation | undefined>();
|
||
const initialCalculation = {
|
||
...calculationFor(initial, oldExplanation),
|
||
explanation: lateExplanation.promise,
|
||
};
|
||
const rule = {
|
||
id: 'linked',
|
||
name: 'Original rule',
|
||
type: 'time_window' as const,
|
||
startDate: '2024-01-02',
|
||
endDate: '2024-01-07',
|
||
placeDays: 1,
|
||
placeType: 'cp' as const,
|
||
};
|
||
initialCalculation.snapshot.rules = [rule];
|
||
const calculate = vi
|
||
.spyOn(calendar, 'createPlanningCalculation')
|
||
.mockReturnValueOnce(initialCalculation);
|
||
const settings = TestBed.inject(CalendarSettingsService);
|
||
settings.setPlanningDates('2024-01-02', '2024-01-07');
|
||
const input = TestBed.inject(UserInputService);
|
||
const controlsFixture = TestBed.createComponent(UserInput);
|
||
controlsFixture.detectChanges();
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
const root: HTMLElement = fixture.nativeElement;
|
||
const controls: HTMLElement = controlsFixture.nativeElement;
|
||
const editButton = controls.querySelector<HTMLButtonElement>(
|
||
'button.declaration-entry-toggle',
|
||
)!;
|
||
input.toggleDeclaredDay('2024-05-10', 'cp');
|
||
settings.setPlanningDates('2024-05-06', '2024-05-12');
|
||
editButton.click();
|
||
controlsFixture.detectChanges();
|
||
fixture.detectChanges();
|
||
for (const date of ['2024-05-08', '2024-05-09', '2024-05-11', '2024-05-12']) {
|
||
const day = root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)!;
|
||
expect(day.disabled).toBe(true);
|
||
day.click();
|
||
}
|
||
expect(input.declaredDaysSignal()).toEqual([{ date: '2024-05-10', type: 'cp' }]);
|
||
const submitted: PlanOutcome = {
|
||
...initial,
|
||
horizon: { start: '2024-05-06', end: '2024-05-13' },
|
||
declaredDays: [{ date: '2024-05-10', type: 'cp' }],
|
||
};
|
||
const pending = deferred<PlanningOutcome>();
|
||
const calculation = calculationFor(submitted);
|
||
calculation.snapshot.rules = [{ ...rule, name: 'Replacement rule' }];
|
||
calculate.mockReturnValueOnce({
|
||
...calculation,
|
||
snapshot: { ...calculation.snapshot, ...submitted },
|
||
outcome: pending.promise,
|
||
});
|
||
controls.querySelector<HTMLButtonElement>('.calculate-button')!.click();
|
||
fixture.detectChanges();
|
||
controlsFixture.detectChanges();
|
||
expect(editButton.getAttribute('aria-pressed')).toBe('false');
|
||
expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(0);
|
||
input.toggleDeclaredDay('2024-05-10', 'other');
|
||
settings.setPlanningDates('2024-06-03', '2024-06-07');
|
||
fixture.detectChanges();
|
||
expect(fixture.componentInstance['planningOutcome']()).toBe(initial);
|
||
expect(
|
||
root.querySelector('button[data-date="2024-01-03"]')?.getAttribute('aria-label'),
|
||
).toContain('RTT');
|
||
expect(root.querySelector('button[data-date="2024-05-10"]')).toBeNull();
|
||
expect(calculate.mock.calls[1][0]).toMatchObject({
|
||
horizon: submitted.horizon,
|
||
declaredDays: submitted.declaredDays,
|
||
});
|
||
lateExplanation.resolve(oldExplanation);
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
const details = root.querySelector<HTMLDetailsElement>('.plan-details')!;
|
||
details.open = true;
|
||
details.dispatchEvent(new Event('toggle'));
|
||
fixture.detectChanges();
|
||
expect(root.querySelector('.leave-story')?.textContent).toContain('Original rule');
|
||
expect(root.querySelector('.leave-story')?.textContent).not.toContain('Replacement rule');
|
||
root.querySelector<HTMLButtonElement>('.compare-dates-button')!.click();
|
||
await fixture.whenStable();
|
||
expect(initialCalculation.analyzeCounterfactual).toHaveBeenCalled();
|
||
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
|
||
pending.resolve(submitted);
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(
|
||
root.querySelector('button[data-date="2024-05-10"]')?.getAttribute('aria-label'),
|
||
).toContain('CP');
|
||
expect(root.querySelector('button[data-date="2024-06-03"]')).toBeNull();
|
||
expect(input.declaredDaysSignal()).toEqual([{ date: '2024-05-10', type: 'other' }]);
|
||
});
|
||
|
||
it('calibrates the wait through calendar rendering independently of explanation delivery', async () => {
|
||
let now = 1_000;
|
||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
|
||
try {
|
||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||
const pending = deferred<PlanningOutcome>();
|
||
const delayedExplanation = deferred<PlanningExplanation | undefined>();
|
||
const { calendar, calculation } = calendarFor(plan, undefined);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
|
||
TestBed.inject(UserInputService).updateBalances(() => [
|
||
{
|
||
id: 'cp',
|
||
type: SelectableDayType.CP,
|
||
numberOfDays: 30,
|
||
availableDate: new Date('2024-01-01T00:00:00'),
|
||
expiryDate: new Date('2024-12-31T00:00:00'),
|
||
},
|
||
]);
|
||
const duration = TestBed.inject(PlanningDurationService);
|
||
const submitted = TestBed.inject(PlanningCalculationService).request()!;
|
||
calendar.createPlanningCalculation.mockReturnValue({
|
||
...calculation,
|
||
outcome: pending.promise,
|
||
explanation: delayedExplanation.promise,
|
||
snapshot: { ...calculation.snapshot, ...submitted },
|
||
});
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
now = 21_000;
|
||
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' });
|
||
expect(fixture.nativeElement.querySelector('.loading-overlay')).not.toBeNull();
|
||
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(0);
|
||
pending.resolve(plan);
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
expect(fixture.nativeElement.querySelector('.loading-overlay')).toBeNull();
|
||
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
|
||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||
expect(fixture.componentInstance['planningExplanation']()).toBeNull();
|
||
now = 321_000;
|
||
delayedExplanation.resolve(explanation);
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
expect(fixture.componentInstance['planningExplanation']()).toBe(explanation);
|
||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
|
||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||
} finally {
|
||
clock.mockRestore();
|
||
}
|
||
});
|
||
|
||
it.each(['cancel before a plan', 'cancel with a plan', 'incomplete', 'error'])(
|
||
'excludes the unfinished duration after %s',
|
||
async (mode) => {
|
||
let now = 1_000;
|
||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
|
||
try {
|
||
const pending = deferred<PlanningOutcome>();
|
||
const outcome: PlanningIncompleteOutcome = {
|
||
...incomplete,
|
||
incumbent: mode === 'cancel with a plan' ? plan : undefined,
|
||
reasons: [
|
||
{
|
||
code: mode.startsWith('cancel') ? 'user-cancelled' : 'deadline',
|
||
message: mode,
|
||
},
|
||
],
|
||
};
|
||
const { calendar, calculation } = calendarFor(outcome);
|
||
calendar.createPlanningCalculation.mockReturnValue({
|
||
...calculation,
|
||
outcome: pending.promise,
|
||
});
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const duration = TestBed.inject(PlanningDurationService);
|
||
const initial = duration.estimate();
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
now = 61_000;
|
||
if (mode.startsWith('cancel'))
|
||
fixture.nativeElement.querySelector('.cancel-calculation').click();
|
||
if (mode === 'error') pending.reject(new Error('Worker failed'));
|
||
else pending.resolve(outcome);
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
expect(duration.estimate()).toEqual(initial);
|
||
expect(fixture.nativeElement.querySelector('.loading-overlay')).toBeNull();
|
||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||
} finally {
|
||
clock.mockRestore();
|
||
}
|
||
},
|
||
);
|
||
|
||
it('ignores a replaced calculation duration and measures the latest submitted horizon', async () => {
|
||
let now = 1_000;
|
||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
|
||
try {
|
||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||
const old = deferred<PlanningOutcome>();
|
||
const latest = deferred<PlanningOutcome>();
|
||
const { calendar, calculation } = calendarFor(plan);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const settings = TestBed.inject(CalendarSettingsService);
|
||
settings.setPlanningDates('2024-01-01', '2024-12-31');
|
||
const duration = TestBed.inject(PlanningDurationService);
|
||
calendar.createPlanningCalculation.mockImplementation(() => ({
|
||
...calculation,
|
||
snapshot: {
|
||
...calculation.snapshot,
|
||
...TestBed.inject(PlanningCalculationService).request()!,
|
||
},
|
||
outcome:
|
||
calendar.createPlanningCalculation.mock.calls.length === 1
|
||
? old.promise
|
||
: latest.promise,
|
||
}));
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
now = 11_000;
|
||
settings.setPlanningDates('2024-01-01', '2026-12-31');
|
||
TestBed.inject(PlanningCalculationService).start();
|
||
now = 12_000; // Rendering/scheduling after the request is part of the wait.
|
||
fixture.detectChanges();
|
||
const initial = duration.estimate();
|
||
now = 31_000;
|
||
old.resolve(plan);
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
expect(duration.estimate()).toEqual(initial);
|
||
expect(fixture.nativeElement.querySelector('.loading-overlay')).not.toBeNull();
|
||
latest.resolve({ ...plan, horizon: { start: '2024-01-01', end: '2027-01-01' } });
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(36);
|
||
expect(calendar.createPlanningCalculation).toHaveBeenCalledTimes(2);
|
||
fixture.destroy();
|
||
const revisitedOutcome = deferred<PlanningOutcome>();
|
||
calendar.createPlanningCalculation.mockReturnValueOnce({
|
||
...calculation,
|
||
snapshot: {
|
||
...calculation.snapshot,
|
||
...TestBed.inject(PlanningCalculationService).request()!,
|
||
},
|
||
outcome: revisitedOutcome.promise,
|
||
});
|
||
now = 631_000; // Returning to the calendar is a fresh initial calculation.
|
||
const revisited = TestBed.createComponent(CalendarYear);
|
||
revisited.detectChanges();
|
||
now = 651_000;
|
||
revisitedOutcome.resolve({
|
||
...plan,
|
||
horizon: { start: '2024-01-01', end: '2027-01-01' },
|
||
});
|
||
await revisited.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
revisited.detectChanges();
|
||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||
} finally {
|
||
clock.mockRestore();
|
||
}
|
||
});
|
||
|
||
it.each([false, true])(
|
||
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
|
||
async (withIncumbent) => {
|
||
const pending = deferred<PlanningOutcome>();
|
||
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);
|
||
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(PlanningCalculationService).start();
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(calendar.createPlanningCalculation).toHaveBeenCalledTimes(2);
|
||
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
|
||
await fixture.whenStable();
|
||
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
|
||
},
|
||
);
|
||
|
||
it('does not calibrate or announce a previous result when the planner is remounted', async () => {
|
||
const { calendar } = calendarFor(plan, explanation);
|
||
calendar.createPlanningCalculation
|
||
.mockReturnValueOnce(calculationFor(plan, explanation))
|
||
.mockReturnValueOnce({
|
||
...calculationFor(plan, explanation),
|
||
outcome: new Promise(() => undefined),
|
||
});
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const record = vi.spyOn(TestBed.inject(PlanningDurationService), 'recordCompletion');
|
||
const settle = async (fixture: ComponentFixture<CalendarYear>) => {
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
};
|
||
const first = TestBed.createComponent(CalendarYear);
|
||
await settle(first);
|
||
expect(record).toHaveBeenCalledTimes(1);
|
||
first.destroy();
|
||
|
||
const second = TestBed.createComponent(CalendarYear);
|
||
await settle(second);
|
||
expect(record).toHaveBeenCalledTimes(1);
|
||
expect(second.componentInstance['liveMessage']()).toBe(
|
||
second.componentInstance['tr']().loading,
|
||
);
|
||
});
|
||
|
||
it('does not announce a previous rejected explanation when the planner is remounted', async () => {
|
||
const { calendar } = calendarFor(plan, explanation);
|
||
const rejected = {
|
||
...calculationFor(plan, explanation),
|
||
explanation: Promise.reject(new Error('explanation exploded')),
|
||
};
|
||
rejected.explanation.catch(() => undefined);
|
||
calendar.createPlanningCalculation.mockReturnValueOnce(rejected).mockReturnValueOnce({
|
||
...calculationFor(plan, explanation),
|
||
outcome: new Promise(() => undefined),
|
||
});
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const settle = async (fixture: ComponentFixture<CalendarYear>) => {
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
};
|
||
const first = TestBed.createComponent(CalendarYear);
|
||
await settle(first);
|
||
expect(first.componentInstance['liveMessage']()).toContain('explanation exploded');
|
||
first.destroy();
|
||
|
||
const second = TestBed.createComponent(CalendarYear);
|
||
await settle(second);
|
||
expect(second.componentInstance['liveMessage']()).toBe(
|
||
second.componentInstance['tr']().loading,
|
||
);
|
||
});
|
||
|
||
it('announces loading and keeps focus when a calculation starts during a cancellation', async () => {
|
||
const first = deferred<PlanningOutcome>();
|
||
const { calendar, calculation } = calendarFor(plan, undefined);
|
||
calendar.createPlanningCalculation
|
||
.mockReturnValueOnce({ ...calculation, outcome: first.promise })
|
||
.mockReturnValueOnce({ ...calculation, outcome: new Promise(() => undefined) });
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
const root = fixture.nativeElement as HTMLElement;
|
||
const live = () => fixture.componentInstance['liveMessage']();
|
||
const tr = fixture.componentInstance['tr']();
|
||
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.cancel-calculation')!);
|
||
fixture.detectChanges();
|
||
expect(live()).toBe(tr.planning.cancelling);
|
||
const outside = document.createElement('button');
|
||
document.body.append(outside);
|
||
outside.focus();
|
||
|
||
TestBed.inject(PlanningCalculationService).start();
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
first.resolve(plan);
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
|
||
expect(document.activeElement).toBe(outside);
|
||
expect(live()).toBe(tr.loading);
|
||
outside.remove();
|
||
});
|
||
|
||
it.each([
|
||
['a delivered explanation error', false],
|
||
['a rejected explanation', true],
|
||
])('announces only %s when it rejected', async (_, rejected) => {
|
||
const reason = { code: 'explanation-error', message: 'Explanation boom' };
|
||
const { calendar } = calendarFor(plan, undefined);
|
||
calendar.createPlanningCalculation.mockReturnValue({
|
||
...calculationFor(plan, undefined),
|
||
explanation: rejected
|
||
? Promise.reject(new Error(reason.message))
|
||
: Promise.resolve(undefined),
|
||
explanationError: Promise.resolve(rejected ? undefined : reason),
|
||
});
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
|
||
const service = TestBed.inject(PlanningCalculationService);
|
||
expect(service.explanationError()).toMatchObject(reason);
|
||
expect(service.explanationRejected()).toBe(rejected);
|
||
expect(fixture.componentInstance['liveMessage']().includes(reason.message)).toBe(rejected);
|
||
});
|
||
|
||
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 and explanation 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 calendar = TestBed.inject(CalendarService);
|
||
const calculate = vi
|
||
.spyOn(calendar, 'createPlanningCalculation')
|
||
.mockReturnValueOnce({
|
||
...calculationFor(plan),
|
||
outcome: oldOutcome.promise,
|
||
explanation: oldExplanation.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(PlanningCalculationService).start();
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
oldOutcome.resolve(plan);
|
||
oldExplanation.resolve(explanation);
|
||
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['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);
|
||
TestBed.inject(PlanningCalculationService).start();
|
||
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],
|
||
['PlanningIncomplete without incumbent', incomplete],
|
||
] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => {
|
||
const { calendar } = calendarFor(outcome);
|
||
calendar.createPlanningCalculation
|
||
.mockReturnValueOnce(calculationFor(plan, explanation))
|
||
.mockReturnValueOnce(calculationFor(outcome));
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const fixture: ComponentFixture<CalendarYear> = TestBed.createComponent(CalendarYear);
|
||
const service = TestBed.inject(PlanningCalculationService);
|
||
const settle = async () => {
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
};
|
||
await settle();
|
||
const root = fixture.nativeElement as HTMLElement;
|
||
expect(service.displayedPlan()).toBeDefined();
|
||
expect(
|
||
Array.from(root.querySelectorAll('.plan-summary dd')).map((e) => e.textContent?.trim()),
|
||
).toEqual(['2', '4', '6']);
|
||
|
||
service.start();
|
||
await settle();
|
||
|
||
expect(service.displayedPlan()).toBeUndefined();
|
||
expect(root.querySelector('.plan-summary')).toBeNull();
|
||
expect(root.querySelectorAll('button.selected')).toHaveLength(0);
|
||
});
|
||
|
||
it('keeps and summarizes a returned result', async () => {
|
||
const { calendar } = calendarFor(plan, explanation);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
expect(
|
||
TestBed.inject(PlanningCalculationService).displayedPlan()?.remainingBalances,
|
||
).toEqual(plan.remainingBalances);
|
||
const summaryValues = Array.from(
|
||
fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf<HTMLElement>,
|
||
).map((element) => element.textContent?.trim());
|
||
expect(summaryValues).toEqual(['2', '4', '6']);
|
||
});
|
||
|
||
it('shows muted, disabled context dates in an adjacent year', async () => {
|
||
if (explanation.kind !== 'PlanExplanation') throw new Error('Expected plan explanation');
|
||
const boundaryPlan: PlanOutcome = {
|
||
...plan,
|
||
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
||
placements: [{ ...plan.placements[0], date: '2024-01-02' }],
|
||
timeOffPeriods: [
|
||
{
|
||
start: '2023-12-30',
|
||
end: '2024-01-02',
|
||
duration: 4,
|
||
placementDates: ['2024-01-02'],
|
||
nonWorkingDates: ['2023-12-30', '2023-12-31', '2024-01-01'],
|
||
nonWorkingDayGain: 3,
|
||
},
|
||
],
|
||
};
|
||
const boundaryExplanation: PlanningExplanation = {
|
||
...explanation,
|
||
placements: [
|
||
{
|
||
...explanation.placements[0],
|
||
placement: boundaryPlan.placements[0],
|
||
timeOffPeriod: boundaryPlan.timeOffPeriods[0],
|
||
},
|
||
],
|
||
};
|
||
const { calendar, calculation } = calendarFor(boundaryPlan, boundaryExplanation);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
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.querySelector('[aria-label="December 2023"]')).not.toBeNull();
|
||
root.querySelector<HTMLButtonElement>('button[data-date="2024-01-02"]')!.click();
|
||
fixture.detectChanges();
|
||
const contextDate = root.querySelector<HTMLButtonElement>(
|
||
'button[data-date="2023-12-30"]',
|
||
)!;
|
||
expect(contextDate.classList).toContain('context-only');
|
||
expect(contextDate.disabled).toBe(true);
|
||
expect(contextDate.classList).toContain('explained-period');
|
||
expect(contextDate.getAttribute('aria-label')).toContain('Explained time-off period');
|
||
expect(root.querySelectorAll('button.explained-period')).toHaveLength(4);
|
||
expect(contextDate.getAttribute('aria-label')).toContain(
|
||
'Context date outside the planning horizon',
|
||
);
|
||
expect(
|
||
root.querySelector<HTMLButtonElement>('button[data-date="2024-01-02"]')?.disabled,
|
||
).toBe(false);
|
||
contextDate.click();
|
||
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('keeps the final plan visible and navigates its accessible explanation without losing focus', async () => {
|
||
const first = explanation.placements[0];
|
||
const explained: PlanningExplanation = {
|
||
...explanation,
|
||
placements: [
|
||
{
|
||
...first,
|
||
placement: { ...first.placement, ruleIds: ['spring'] },
|
||
ruleIds: ['spring'],
|
||
supporting: true,
|
||
timeOffPeriod: {
|
||
start: '2024-05-03',
|
||
end: '2024-05-06',
|
||
duration: 4,
|
||
placementDates: ['2024-05-03', '2024-05-06'],
|
||
nonWorkingDates: ['2024-05-04', '2024-05-05'],
|
||
nonWorkingDayGain: 2,
|
||
},
|
||
remainingBalance: {
|
||
id: 'cp',
|
||
type: 'cp',
|
||
quantity: 1,
|
||
availableFrom: '2024-01-01',
|
||
expiryDate: '2024-12-31',
|
||
classification: 'at-risk',
|
||
reason: { code: 'at-risk', message: 'Expires within the horizon.' },
|
||
earned: false,
|
||
},
|
||
contributions: [
|
||
{ kind: 'entitlement', code: 'balance', label: 'Paid leave' },
|
||
{ kind: 'rule', code: 'rule', ruleId: 'spring', label: 'Spring rule' },
|
||
{
|
||
kind: 'at-risk',
|
||
code: 'at-risk',
|
||
label: 'Protects leave at risk of expiry.',
|
||
},
|
||
{
|
||
kind: 'supporting',
|
||
code: 'supporting',
|
||
label: 'Supports the surrounding time-off plan.',
|
||
},
|
||
{
|
||
kind: 'time-off-period',
|
||
code: 'time-off-period',
|
||
label: 'Contributes to the time-off period.',
|
||
},
|
||
{
|
||
kind: 'metric',
|
||
code: 'non-working-day-gain',
|
||
label: 'Weekend and holiday days',
|
||
value: 1,
|
||
},
|
||
],
|
||
comparison: {
|
||
...first.comparison,
|
||
firstDecisiveObjective: 'non-working-day-gain',
|
||
},
|
||
},
|
||
...explanation.placements.slice(1),
|
||
],
|
||
};
|
||
const { calendar, calculation } = calendarFor(plan, explained);
|
||
calculation.snapshot.rules = [
|
||
{
|
||
id: 'spring',
|
||
name: 'Spring rule',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-05-01',
|
||
endDate: '2024-05-06',
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
},
|
||
];
|
||
calendar.project.mockImplementation((value) => projectionFor(value, true));
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
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;
|
||
const planDetails = root.querySelector<HTMLDetailsElement>('.plan-details')!;
|
||
expect(planDetails.open).toBe(false);
|
||
activateWithKeyboard(planDetails.querySelector<HTMLElement>('summary')!);
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
expect(planDetails.open).toBe(true);
|
||
expect(root.querySelector('.plan-insights')!.firstElementChild).toBe(planDetails);
|
||
expect(root.querySelector('.leave-story')?.textContent).toContain('Spring rule');
|
||
expect(root.querySelector('.leave-story')?.textContent).toContain('Protects leave at risk');
|
||
expect(root.querySelector('.leave-story')?.textContent).toContain('could be saved');
|
||
expect(root.querySelector('.break-equation span:last-child strong')?.textContent).toBe('4');
|
||
expect(root.querySelector('.break-story')?.textContent).toContain('days off in a row');
|
||
expect(root.querySelector('.decisive-summary')?.textContent).toContain(
|
||
'Weekend & holiday days in your breaks',
|
||
);
|
||
expect(root.querySelectorAll('button[data-date="2024-05-03"].selected')).toHaveLength(1);
|
||
expect(root.querySelectorAll('button[data-date="2024-05-06"].selected')).toHaveLength(1);
|
||
expect(
|
||
Array.from(
|
||
root.querySelectorAll<HTMLButtonElement>('button.explained-period'),
|
||
(button) => button.dataset['date'],
|
||
),
|
||
).toEqual(['2024-05-03', '2024-05-04', '2024-05-05', '2024-05-06']);
|
||
expect(root.querySelector('.technical-details')?.textContent).toContain(
|
||
'The planner’s priorities',
|
||
);
|
||
const technicalSummary = root.querySelector<HTMLElement>('.technical-details summary')!;
|
||
expect(technicalSummary.parentElement?.matches('details[open]')).toBe(false);
|
||
activateWithKeyboard(technicalSummary, ' ');
|
||
fixture.detectChanges();
|
||
expect(technicalSummary.parentElement?.matches('details[open]')).toBe(true);
|
||
|
||
const next = root.querySelector<HTMLButtonElement>('button[data-step="next"]')!;
|
||
activateWithKeyboard(next);
|
||
fixture.detectChanges();
|
||
expect(next.getAttribute('aria-disabled')).toBe('true');
|
||
expect(next.disabled).toBe(false);
|
||
expect(document.activeElement).toBe(next);
|
||
expect(
|
||
root.querySelector('output[aria-current="step"]')?.getAttribute('aria-label'),
|
||
).toContain('6 May 2024');
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(document.activeElement).toBe(next);
|
||
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
|
||
expect(root.querySelector('[role="dialog"]')).toBeNull();
|
||
expect(root.querySelectorAll('button.explained-period')).toHaveLength(0);
|
||
|
||
const firstButton = root.querySelector<HTMLButtonElement>('button[data-step="first"]')!;
|
||
activateWithKeyboard(firstButton);
|
||
fixture.detectChanges();
|
||
expect(document.activeElement).toBe(firstButton);
|
||
const previous = root.querySelector<HTMLButtonElement>('button[data-step="previous"]')!;
|
||
activateWithKeyboard(previous);
|
||
fixture.detectChanges();
|
||
expect(previous.getAttribute('aria-disabled')).toBe('true');
|
||
expect(previous.disabled).toBe(false);
|
||
expect(document.activeElement).toBe(previous);
|
||
const lastButton = root.querySelector<HTMLButtonElement>('button[data-step="last"]')!;
|
||
activateWithKeyboard(lastButton);
|
||
fixture.detectChanges();
|
||
expect(lastButton.getAttribute('aria-disabled')).toBe('true');
|
||
expect(document.activeElement).toBe(lastButton);
|
||
const date = root.querySelector<HTMLButtonElement>('button[data-date="2024-05-06"]')!;
|
||
activateWithKeyboard(date, ' ');
|
||
fixture.detectChanges();
|
||
expect(document.activeElement).toBe(date);
|
||
expect(date.getAttribute('aria-current')).toBe('date');
|
||
expect(date.getAttribute('aria-label')).toContain('Paid Leave');
|
||
});
|
||
|
||
it('keeps an incomplete incumbent visible and exposes its interrupted-stage details', async () => {
|
||
const outcome: PlanningIncompleteOutcome = {
|
||
kind: 'PlanningIncomplete',
|
||
incumbent: plan,
|
||
completedStages: ['at-risk'],
|
||
lastCompletedStage: 'at-risk',
|
||
reasons: [{ code: 'deadline', message: 'Planning stopped at the deadline.' }],
|
||
};
|
||
const incumbentExplanation: PlanningExplanation = {
|
||
...explanation,
|
||
incomplete: {
|
||
kind: 'IncompleteExplanation',
|
||
reason: outcome.reasons[0],
|
||
completedStages: ['at-risk'],
|
||
uncompletedStages: ['holiday-week-value'],
|
||
},
|
||
};
|
||
const { calendar } = calendarFor(outcome, incumbentExplanation);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
|
||
const details = fixture.nativeElement.querySelector('.plan-details') as HTMLDetailsElement;
|
||
details.open = true;
|
||
details.dispatchEvent(new Event('toggle'));
|
||
fixture.detectChanges();
|
||
expect(fixture.nativeElement.querySelector('.incomplete-incumbent').textContent).toContain(
|
||
'holiday-week-value',
|
||
);
|
||
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
|
||
});
|
||
|
||
it('opens explanations only on user interaction and analyzes alternatives from Compare dates', async () => {
|
||
const { calendar, calculation } = calendarFor(plan, explanation);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
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;
|
||
const details = root.querySelector<HTMLDetailsElement>('.plan-details')!;
|
||
expect(details.open).toBe(false);
|
||
expect(root.querySelector('button.current')).toBeNull();
|
||
details.querySelector<HTMLElement>('summary')!.click();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
expect(details.open).toBe(true);
|
||
expect(root.querySelector<HTMLButtonElement>('button.current')?.dataset['date']).toBe(
|
||
'2024-05-03',
|
||
);
|
||
details.open = false;
|
||
details.dispatchEvent(new Event('toggle'));
|
||
fixture.detectChanges();
|
||
expect(root.querySelector('button.current')).toBeNull();
|
||
root.querySelector<HTMLButtonElement>('button[data-date="2024-05-06"]')!.click();
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(details.open).toBe(true);
|
||
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
|
||
expect(root.querySelector<HTMLButtonElement>('button.current')?.dataset['date']).toBe(
|
||
'2024-05-06',
|
||
);
|
||
expect(root.querySelectorAll('.status-marker, .hypothetical-marker')).toHaveLength(0);
|
||
|
||
root.querySelector<HTMLButtonElement>('.compare-dates-button')!.click();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(calculation.analyzeCounterfactual).toHaveBeenCalledExactlyOnceWith(
|
||
plan.placements[1],
|
||
);
|
||
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
||
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
|
||
expect(root.querySelectorAll('.status-marker, .hypothetical-marker')).toHaveLength(0);
|
||
expect(root.querySelector('.calendar-legend')?.textContent).not.toContain('Eligible');
|
||
expect(root.querySelector<HTMLButtonElement>('button.current')?.dataset['date']).toBe(
|
||
'2024-05-06',
|
||
);
|
||
const input = document.querySelector<HTMLInputElement>('.compare-date-field input')!;
|
||
input.value = '04/05/2024';
|
||
input.dispatchEvent(new Event('input'));
|
||
fixture.detectChanges();
|
||
expect(document.querySelector('.unavailable-date')?.textContent).toContain(
|
||
'No leave is needed.',
|
||
);
|
||
expect(root.querySelector('button[data-date="2024-05-04"]')?.classList).not.toContain(
|
||
'ineligible',
|
||
);
|
||
expect(root.querySelector<HTMLButtonElement>('button.current')?.dataset['date']).toBe(
|
||
'2024-05-06',
|
||
);
|
||
|
||
document.querySelector<HTMLButtonElement>('.comparison-close')!.click();
|
||
await fixture.whenStable();
|
||
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
||
root.querySelector<HTMLButtonElement>('button[data-date="2024-05-03"]')!.click();
|
||
fixture.detectChanges();
|
||
expect(root.querySelector<HTMLButtonElement>('button.current')?.dataset['date']).toBe(
|
||
'2024-05-03',
|
||
);
|
||
expect(calculation.analyzeCounterfactual).toHaveBeenCalledTimes(1);
|
||
|
||
TestBed.inject(PlanningCalculationService).start();
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
expect(root.querySelector<HTMLDetailsElement>('.plan-details')?.open).toBe(false);
|
||
expect(root.querySelector('button.current')).toBeNull();
|
||
});
|
||
|
||
it('explains a whole break and compares another date against the current plan', async () => {
|
||
const before: PlanningObjectiveVector = {
|
||
objectives: [
|
||
{ id: 'unplaced-at-risk', direction: 'minimize', value: 1 },
|
||
{ id: 'preference:spring', direction: 'maximize', value: 1 },
|
||
{ id: 'non-working-day-gain', direction: 'maximize', value: 0 },
|
||
{ id: 'longest-work-sequence', direction: 'minimize', value: 12 },
|
||
{ id: 'placement-date', direction: 'minimize', value: 'removed' },
|
||
],
|
||
};
|
||
const current: PlanningObjectiveVector = {
|
||
objectives: [
|
||
{ id: 'unplaced-at-risk', direction: 'minimize', value: 0 },
|
||
{ id: 'preference:spring', direction: 'maximize', value: 2 },
|
||
{ id: 'non-working-day-gain', direction: 'maximize', value: 4 },
|
||
{ id: 'longest-work-sequence', direction: 'minimize', value: 6 },
|
||
{ id: 'placement-date', direction: 'minimize', value: '2024-05-03' },
|
||
],
|
||
};
|
||
const alternative: PlanningObjectiveVector = {
|
||
objectives: [
|
||
{ id: 'unplaced-at-risk', direction: 'minimize', value: 0 },
|
||
{ id: 'preference:spring', direction: 'maximize', value: 1 },
|
||
{ id: 'non-working-day-gain', direction: 'maximize', value: 2 },
|
||
{ id: 'longest-work-sequence', direction: 'minimize', value: 8 },
|
||
{ id: 'placement-date', direction: 'minimize', value: '2024-05-07' },
|
||
],
|
||
};
|
||
const readable: PlanningExplanation = {
|
||
...explanation,
|
||
placements: explanation.placements.map((selected) => ({
|
||
...selected,
|
||
timeOffPeriod: {
|
||
start: '2024-05-03',
|
||
end: '2024-05-06',
|
||
duration: 4,
|
||
placementDates: ['2024-05-03', '2024-05-06'],
|
||
nonWorkingDates: ['2024-05-04', '2024-05-05'],
|
||
nonWorkingDayGain: 2,
|
||
},
|
||
objectiveVector: current,
|
||
comparison: {
|
||
before,
|
||
after: current,
|
||
delta: [],
|
||
firstDecisiveObjective: 'unplaced-at-risk',
|
||
},
|
||
})),
|
||
};
|
||
const { calendar, calculation } = calendarFor(plan, readable);
|
||
calculation.snapshot.rules = [
|
||
{
|
||
id: 'spring',
|
||
name: 'Spring break',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-05-01',
|
||
endDate: '2024-05-06',
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
},
|
||
];
|
||
const analysis: CounterfactualAnalysis = {
|
||
kind: 'Counterfactual',
|
||
calculationId: 'test',
|
||
placement: plan.placements[0],
|
||
hypothetical: true,
|
||
baseline: before,
|
||
results: [
|
||
{
|
||
date: '2024-05-03',
|
||
status: 'eligible',
|
||
comparison: { before, after: current, delta: [] },
|
||
},
|
||
{
|
||
date: '2024-05-04',
|
||
status: 'ineligible',
|
||
reason: { code: 'non-working', message: 'Weekend' },
|
||
},
|
||
{
|
||
date: '2024-05-07',
|
||
status: 'eligible',
|
||
comparison: { before, after: alternative, delta: [] },
|
||
},
|
||
{
|
||
date: '2024-05-09',
|
||
status: 'eligible',
|
||
comparison: { before, after: current, delta: [] },
|
||
},
|
||
],
|
||
};
|
||
vi.spyOn(calculation, 'analyzeCounterfactual').mockResolvedValue(analysis);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
const element: HTMLElement = document.body;
|
||
element.querySelector<HTMLButtonElement>('button[data-date="2024-05-03"]')!.click();
|
||
fixture.detectChanges();
|
||
expect(
|
||
Array.from(element.querySelectorAll('.break-equation strong'), (node) =>
|
||
node.textContent?.trim(),
|
||
),
|
||
).toEqual(['2', '2', '4']);
|
||
expect(element.querySelector('.leave-story')?.textContent).toContain(
|
||
'This date uses 1 day from Paid leave.',
|
||
);
|
||
expect(
|
||
Array.from(element.querySelectorAll('.day-impact td'), (node) =>
|
||
node.textContent?.trim(),
|
||
),
|
||
).toEqual(['1', '0', '1', '2', '0', '4', '12', '6']);
|
||
expect(element.querySelector('.day-impact')?.textContent).toContain(
|
||
'Preference: Spring break',
|
||
);
|
||
expect(element.querySelector('.day-impact')?.textContent).toContain(
|
||
'Leave days in this preferred period.',
|
||
);
|
||
expect(element.querySelector('.day-impact')?.textContent).not.toContain('placement-date');
|
||
const details = element.querySelector<HTMLDetailsElement>('.technical-details')!;
|
||
details.open = true;
|
||
expect(details.querySelectorAll('.criteria-table')).toHaveLength(1);
|
||
expect(details.querySelector('.decisive-row')?.textContent).toContain(
|
||
'Leave left at risk of expiry',
|
||
);
|
||
expect(details.querySelector('.decisive-label')?.textContent).toContain('First difference');
|
||
expect(details.querySelector('.balance-ledger')?.textContent).toContain(
|
||
'Unused days in the final plan',
|
||
);
|
||
expect(details.querySelectorAll('.criteria-table tbody tr')).toHaveLength(4);
|
||
expect(details.querySelector('.criteria-detail')?.textContent).not.toContain('2024-05-03');
|
||
expect(details.querySelector('.tie-breaks')?.textContent).toContain('Earlier dates first');
|
||
expect(details.querySelector<HTMLDetailsElement>('.criterion-help')?.open).toBe(false);
|
||
expect(details.querySelector<HTMLDetailsElement>('.audit-data')?.open).toBe(false);
|
||
expect(JSON.parse(details.querySelector('pre')!.textContent!).objectiveVector).toEqual(
|
||
current,
|
||
);
|
||
|
||
element.querySelector<HTMLButtonElement>('.compare-dates-button')!.click();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
const input = element.querySelector<HTMLInputElement>('.compare-date-field input')!;
|
||
expect(input.type).toBe('text');
|
||
expect(input.value).toBe('07/05/2024');
|
||
element
|
||
.querySelector<HTMLButtonElement>('.compare-date-field mat-datepicker-toggle button')!
|
||
.click();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(document.querySelector('mat-calendar')).not.toBeNull();
|
||
const chosenDay = document.querySelector<HTMLButtonElement>(
|
||
'mat-calendar button[aria-label="3 May 2024"]',
|
||
)!;
|
||
chosenDay.click();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(input.value).toBe('03/05/2024');
|
||
expect(fixture.componentInstance['selectedCounterfactualDate']()).toBe('2024-05-03');
|
||
expect(fixture.nativeElement.querySelector('.counterfactual-panel')).toBeNull();
|
||
expect(element.querySelector('[role="dialog"]')).not.toBeNull();
|
||
expect(
|
||
fixture.componentInstance['nearbyComparisonDates']().map((result) => result.date),
|
||
).toEqual(['2024-05-07', '2024-05-09']);
|
||
input.value = '03/05/2024';
|
||
input.dispatchEvent(new Event('input'));
|
||
fixture.detectChanges();
|
||
expect(element.querySelector('.counterfactual-result')?.textContent).toContain(
|
||
'This is the planned date.',
|
||
);
|
||
input.value = '07/05/2024';
|
||
input.dispatchEvent(new Event('input'));
|
||
fixture.detectChanges();
|
||
// Both alternatives share the removed-day baseline, but moving must compare
|
||
// current vs alternative (4 -> 2, 6 -> 8), not removed vs alternative (0 -> 2).
|
||
expect(
|
||
Array.from(
|
||
element.querySelectorAll('.counterfactual-result > .impact-table td'),
|
||
(node) => node.textContent?.trim(),
|
||
),
|
||
).toEqual(['2', '1', '4', '2', '6', '8']);
|
||
const dateDetails = element.querySelector('.date-calculation-details')!;
|
||
expect(
|
||
Array.from(dateDetails.querySelectorAll('.criteria-table td'), (node) =>
|
||
node.textContent?.trim(),
|
||
),
|
||
).toEqual(['0', '0', '2', '1', '4', '2', '6', '8']);
|
||
expect(dateDetails.querySelector('.decisive-row')?.textContent).toContain(
|
||
'Preference: Spring break',
|
||
);
|
||
expect(dateDetails.querySelector('thead')?.textContent).toContain('Current plan');
|
||
|
||
expect(element.querySelectorAll('button.selected')).toHaveLength(2);
|
||
input.value = '';
|
||
input.dispatchEvent(new Event('input'));
|
||
fixture.detectChanges();
|
||
expect(input.value).toBe('');
|
||
expect(element.querySelector('.counterfactual-result')).toBeNull();
|
||
expect(element.querySelector('.comparison-empty')?.textContent).toContain('Choose a date');
|
||
input.value = '01/01/2025';
|
||
input.dispatchEvent(new Event('input'));
|
||
fixture.detectChanges();
|
||
expect(input.value).toBe('01/01/2025');
|
||
expect(input.getAttribute('aria-invalid')).toBe('true');
|
||
expect(element.querySelector('#comparison-date-error')).not.toBeNull();
|
||
element.querySelector<HTMLButtonElement>('.nearby-dates button')!.click();
|
||
fixture.detectChanges();
|
||
expect(input.value).toBe('07/05/2024');
|
||
expect(input.getAttribute('aria-invalid')).toBe('false');
|
||
expect(input.getAttribute('aria-describedby')).toContain('comparison-range');
|
||
input.value = '09/05/2024';
|
||
input.dispatchEvent(new Event('input'));
|
||
fixture.detectChanges();
|
||
expect(element.querySelector('.counterfactual-result')?.textContent).toContain(
|
||
'These totals are unchanged.',
|
||
);
|
||
expect(element.querySelector('.counterfactual-result > .impact-table')).toBeNull();
|
||
input.value = '04/05/2024';
|
||
input.dispatchEvent(new Event('input'));
|
||
fixture.detectChanges();
|
||
expect(element.querySelector('.counterfactual-result')?.textContent).toContain(
|
||
'No leave is needed.',
|
||
);
|
||
await fixture.whenStable();
|
||
});
|
||
|
||
it('dismisses a pending comparison, ignores its late result, and keeps errors inside the dialog', async () => {
|
||
const { calendar, calculation } = calendarFor(plan, explanation);
|
||
let finish!: (result: CounterfactualAnalysis) => void;
|
||
vi.spyOn(calculation, 'analyzeCounterfactual')
|
||
.mockImplementationOnce(
|
||
() =>
|
||
new Promise<CounterfactualAnalysis>((resolve) => {
|
||
finish = resolve;
|
||
}),
|
||
)
|
||
.mockRejectedValueOnce(new Error('worker unavailable'));
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
const element: HTMLElement = fixture.nativeElement;
|
||
element.querySelector<HTMLButtonElement>('button[data-date="2024-05-03"]')!.click();
|
||
fixture.detectChanges();
|
||
const compare = element.querySelector<HTMLButtonElement>('.compare-dates-button')!;
|
||
compare.click();
|
||
fixture.detectChanges();
|
||
expect(document.querySelector('.comparison-loading')).not.toBeNull();
|
||
document.querySelector<HTMLButtonElement>('.comparison-close')!.click();
|
||
await fixture.whenStable();
|
||
finish({
|
||
kind: 'Counterfactual',
|
||
calculationId: 'test',
|
||
placement: plan.placements[0],
|
||
hypothetical: true,
|
||
baseline: objectiveVector,
|
||
results: [{ date: '2024-05-03', status: 'eligible' }],
|
||
});
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
||
expect(fixture.componentInstance['counterfactual']()).toBeNull();
|
||
expect(fixture.componentInstance['counterfactualLoading']()).toBe(false);
|
||
expect(element.querySelectorAll('button.selected')).toHaveLength(2);
|
||
compare.click();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(
|
||
document.querySelector('.counterfactual-panel .counterfactual-inline-error')
|
||
?.textContent,
|
||
).toContain('Date alternatives could not be calculated.');
|
||
expect(document.querySelector('.comparison-done')).not.toBeNull();
|
||
document.querySelector<HTMLButtonElement>('.comparison-close')!.click();
|
||
await fixture.whenStable();
|
||
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
||
});
|
||
|
||
it('announces an explicit comparison failure and retries inside the dialog', async () => {
|
||
const { calendar, calculation } = calendarFor(plan, explanation);
|
||
const firstAttempt = deferred<CounterfactualAnalysis>();
|
||
const retryAttempt = deferred<CounterfactualAnalysis>();
|
||
vi.spyOn(calculation, 'analyzeCounterfactual')
|
||
.mockImplementationOnce(() => firstAttempt.promise)
|
||
.mockImplementationOnce(() => retryAttempt.promise);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
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;
|
||
const plannedDate = root.querySelector<HTMLButtonElement>(
|
||
'button[data-date="2024-05-03"]',
|
||
)!;
|
||
activateWithKeyboard(plannedDate);
|
||
fixture.detectChanges();
|
||
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
|
||
expect(document.activeElement).toBe(plannedDate);
|
||
root.querySelector<HTMLButtonElement>('.compare-dates-button')!.click();
|
||
fixture.detectChanges();
|
||
expect(document.querySelector('.comparison-loading')?.textContent).toContain(
|
||
'Analyzing every date',
|
||
);
|
||
expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain(
|
||
'Analyzing every date',
|
||
);
|
||
firstAttempt.reject(new Error('worker unavailable'));
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(
|
||
document.querySelector(
|
||
'.counterfactual-panel .counterfactual-inline-error[role="alert"]',
|
||
)?.textContent,
|
||
).toContain('worker unavailable');
|
||
expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain(
|
||
'Date alternatives could not be calculated.',
|
||
);
|
||
expect(root.querySelector('.counterfactual-inline-error')).toBeNull();
|
||
|
||
document.querySelector<HTMLButtonElement>('.comparison-done')!.click();
|
||
fixture.detectChanges();
|
||
expect(document.querySelector('.comparison-loading')).not.toBeNull();
|
||
const focusedBeforeResult = document.activeElement;
|
||
retryAttempt.resolve({
|
||
kind: 'Counterfactual',
|
||
calculationId: 'test',
|
||
placement: plan.placements[0],
|
||
hypothetical: true,
|
||
baseline: objectiveVector,
|
||
results: [
|
||
{ date: '2024-05-03', status: 'eligible' },
|
||
{ date: '2024-05-06', status: 'eligible' },
|
||
],
|
||
});
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(document.querySelector('.counterfactual-result')).not.toBeNull();
|
||
expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain(
|
||
'Date alternatives are ready.',
|
||
);
|
||
expect(document.activeElement).toBe(focusedBeforeResult);
|
||
});
|
||
|
||
it('renders conflict causes with their reported obligations', async () => {
|
||
const conflictExplanation: PlanningExplanation = {
|
||
kind: 'ConflictExplanation',
|
||
conflictingRules: [{ id: 'rule', label: 'Required spring leave' }],
|
||
bottleneck: { code: 'typed-capacity', message: 'No CP entitlement is available.' },
|
||
};
|
||
const setup = calendarFor(conflict, conflictExplanation);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: setup.calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain(
|
||
'Required spring leave',
|
||
);
|
||
expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain(
|
||
'No CP entitlement is available.',
|
||
);
|
||
});
|
||
|
||
it('turns planner rejection into an incomplete error and clears loading', async () => {
|
||
const calendar = {
|
||
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
|
||
createPlanningCalculation: vi.fn(() => ({
|
||
...calculationFor(plan),
|
||
outcome: Promise.reject(new Error('planner exploded')),
|
||
})),
|
||
project: vi.fn((value: Parameters<typeof projectionFor>[0]) => projectionFor(value)),
|
||
};
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
|
||
const outcome = fixture.componentInstance['planningOutcome']();
|
||
expect(outcome?.kind).toBe('PlanningIncomplete');
|
||
if (outcome?.kind === 'PlanningIncomplete')
|
||
expect(outcome.reasons[0].code).toBe('planner-error');
|
||
expect(fixture.componentInstance['isLoading']()).toBe(false);
|
||
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined();
|
||
});
|
||
});
|