CI / policy (push) Successful in 20s
CI / validate (push) Successful in 36s
CI / build (push) Successful in 23s
CI / policy (pull_request) Successful in 8s
CI / validate (pull_request) Successful in 25s
CI / benchmark (push) Successful in 1m29s
CI / security (push) Successful in 1m10s
CI / build (pull_request) Successful in 23s
CI / test (push) Successful in 2m16s
CI / benchmark (pull_request) Successful in 1m27s
CI / security (pull_request) Successful in 1m0s
CI / images (push) Successful in 59s
CI / required (push) Successful in 1s
CI / test (pull_request) Successful in 2m6s
CI / images (pull_request) Successful in 53s
CI / required (pull_request) Successful in 1s
Rendered waits after #302, in throttled headless Chromium, were well below the shown ranges: two years waited 1.9 s against 3-24 s, three years 4.5 s against 11-68 s, and the loaded year 4.0 s against 4-26 s. The prior keeps its structure with refitted, rounded constants, 400 + 250 x (D/365.25)^0.5 x (P/30)^1.5 x R, which stays within about 1.2 times of all six measured configurations. Five years with 30 days per year now shows 6-37 s instead of 44-265 s. Local refinement is unchanged. The 4x CPU throttle slows only the main thread, not the planning worker, so phones can still wait longer than these desktop samples. Part of #302. 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: 2, 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();
|
||
});
|
||
});
|