1495 lines
52 KiB
TypeScript
1495 lines
52 KiB
TypeScript
import { provideZonelessChangeDetection } from '@angular/core';
|
||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||
import { vi } from 'vitest';
|
||
import { CalendarYear } from './calendar-year';
|
||
import {
|
||
CalendarService,
|
||
DayType,
|
||
SelectedDate,
|
||
SelectedDates,
|
||
SelectableDayType,
|
||
} from '../../services/calendar-service';
|
||
import {
|
||
CounterfactualAnalysis,
|
||
CounterfactualSelection,
|
||
HistoricalEligibilityReason,
|
||
LeavePlacement,
|
||
PlanOutcome,
|
||
PlanningCalculationContext,
|
||
PlanningConflictOutcome,
|
||
PlanningExplanation,
|
||
PlanningIncompleteOutcome,
|
||
InvalidInputOutcome,
|
||
PlanningOutcome,
|
||
PlanningObjectiveVector,
|
||
PlanningTrace,
|
||
} from '../../services/leave-planner.service';
|
||
import { UserInputService } from '../../services/user-input-service';
|
||
import { TranslationService } from '../../services/translation-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,
|
||
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 selectedDatesForPlan(value: PlanOutcome, includeHoliday = false): SelectedDates {
|
||
const selected = new SelectedDates();
|
||
if (includeHoliday) {
|
||
const holiday = new Date('2024-05-08T00:00:00');
|
||
selected.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`);
|
||
selected.push(new SelectedDate(DayType.CP, new DateRange(date, date)));
|
||
}
|
||
return selected;
|
||
}
|
||
|
||
function placementForSelection(selection: CounterfactualSelection): LeavePlacement {
|
||
if (typeof selection === 'string')
|
||
return (
|
||
plan.placements.find(
|
||
(candidate) => candidate.date === selection || candidate.balanceId === selection,
|
||
) ?? plan.placements[0]
|
||
);
|
||
return 'decisionId' in selection ? selection.placement : selection;
|
||
}
|
||
|
||
function calculationFor(
|
||
outcome: PlanningOutcome,
|
||
stableExplanation?: PlanningExplanation,
|
||
trace?: PlanningTrace,
|
||
tracePromise: Promise<PlanningTrace | undefined> = Promise.resolve(trace),
|
||
): 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' },
|
||
historicalStatus: 'ineligible',
|
||
},
|
||
{
|
||
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' },
|
||
historicalStatus: 'ineligible',
|
||
},
|
||
],
|
||
};
|
||
return {
|
||
id: 'test',
|
||
snapshot: {
|
||
horizon: plan.horizon,
|
||
balances: [],
|
||
rules: [],
|
||
identities: {},
|
||
labels: {},
|
||
},
|
||
outcome: Promise.resolve(outcome),
|
||
explanation: Promise.resolve(stableExplanation),
|
||
explanationError: Promise.resolve(undefined),
|
||
trace: tracePromise,
|
||
analyzeCounterfactual: vi.fn(async (selection: CounterfactualSelection) => ({
|
||
...counterfactual,
|
||
placement: placementForSelection(selection),
|
||
})),
|
||
historicalEligibility: vi.fn(async () => [] as readonly HistoricalEligibilityReason[]),
|
||
};
|
||
}
|
||
|
||
function calendarFor(
|
||
outcome: PlanningOutcome,
|
||
stableExplanation?: PlanningExplanation,
|
||
trace?: PlanningTrace,
|
||
tracePromise?: Promise<PlanningTrace | undefined>,
|
||
) {
|
||
const calculation = calculationFor(
|
||
outcome,
|
||
stableExplanation,
|
||
trace,
|
||
tracePromise ?? Promise.resolve(trace),
|
||
);
|
||
return {
|
||
calculation,
|
||
calendar: {
|
||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
||
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
|
||
createPlanningCalculation: vi.fn(() => calculation),
|
||
selectedDatesForPlan: vi.fn(selectedDatesForPlan),
|
||
},
|
||
};
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
function jointTrace(): PlanningTrace {
|
||
const placementComparison =
|
||
explanation.kind === 'PlanExplanation' ? explanation.placements[0].comparison : undefined;
|
||
return {
|
||
status: 'ready',
|
||
initialState: {
|
||
horizon: plan.horizon,
|
||
balances: [
|
||
{
|
||
id: 'cp',
|
||
type: 'cp',
|
||
quantity: 2,
|
||
remaining: 2,
|
||
availableFrom: '2024-01-01',
|
||
expiryDate: '2024-12-31',
|
||
earned: false,
|
||
},
|
||
{
|
||
id: 'earned:spring:2024',
|
||
type: 'rtt',
|
||
quantity: 2,
|
||
remaining: 2,
|
||
availableFrom: '2024-05-04',
|
||
expiryDate: '2025-05-04',
|
||
earned: true,
|
||
sourceRuleId: 'spring',
|
||
},
|
||
],
|
||
placements: [plan.placements[1]],
|
||
earnedActivations: [
|
||
{
|
||
id: 'earned:spring:2024',
|
||
ruleId: 'spring',
|
||
occurrence: '2024',
|
||
dates: ['2024-05-03', '2024-05-06'],
|
||
},
|
||
],
|
||
},
|
||
phases: [
|
||
{
|
||
id: 'phase:obligations',
|
||
name: 'obligations',
|
||
completed: true,
|
||
status: 'completed',
|
||
decisions: [
|
||
{
|
||
id: 'decision:prior',
|
||
action: 'validate',
|
||
dates: ['2024-05-01'],
|
||
candidates: [],
|
||
deltas: [
|
||
{
|
||
kind: 'balance-changed',
|
||
balanceId: 'cp',
|
||
remainingBefore: 2,
|
||
remainingAfter: 1,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'decision:joint',
|
||
action: 'choose',
|
||
dates: ['2024-05-03', '2024-05-06'],
|
||
branch: {
|
||
parentDecisionId: 'decision:prior',
|
||
deltas: [
|
||
{
|
||
kind: 'placement-added',
|
||
date: '2024-05-03',
|
||
balanceId: 'cp',
|
||
remainingBefore: 1,
|
||
remainingAfter: 0,
|
||
placement: plan.placements[0],
|
||
},
|
||
{
|
||
kind: 'balance-changed',
|
||
balanceId: 'earned:spring:2024',
|
||
remainingBefore: 2,
|
||
remainingAfter: 2,
|
||
balance: {
|
||
id: 'earned:spring:2024',
|
||
type: 'rtt',
|
||
quantity: 2,
|
||
remaining: 2,
|
||
availableFrom: '2024-05-07',
|
||
expiryDate: '2025-05-07',
|
||
earned: true,
|
||
sourceRuleId: 'spring',
|
||
},
|
||
},
|
||
],
|
||
},
|
||
candidates: [
|
||
{
|
||
status: 'evaluated-rejected',
|
||
dates: ['2024-05-10', '2024-05-13'],
|
||
reason: {
|
||
code: 'joint-date-conflict',
|
||
message: 'This branch cannot satisfy both required dates.',
|
||
},
|
||
technicalComparison: [
|
||
{
|
||
name: 'unplaced-at-risk',
|
||
direction: 'minimize',
|
||
before: 2,
|
||
after: 2,
|
||
},
|
||
{
|
||
name: 'preference:spring',
|
||
direction: 'maximize',
|
||
before: 1,
|
||
after: 0,
|
||
},
|
||
],
|
||
firstDecisiveComparator: 'preference:spring',
|
||
comparison: placementComparison,
|
||
},
|
||
{
|
||
status: 'chosen',
|
||
dates: ['2024-05-03', '2024-05-06'],
|
||
comparison: placementComparison,
|
||
},
|
||
],
|
||
deltas: [
|
||
{
|
||
kind: 'placement-added',
|
||
date: '2024-05-03',
|
||
balanceId: 'cp',
|
||
remainingBefore: 1,
|
||
remainingAfter: 0,
|
||
placement: plan.placements[0],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'decision:later',
|
||
action: 'reject',
|
||
dates: ['2024-05-20'],
|
||
candidates: [],
|
||
deltas: [],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'phase:dates',
|
||
name: 'dates',
|
||
completed: true,
|
||
status: 'completed',
|
||
decisions: [],
|
||
},
|
||
],
|
||
completedStages: ['obligations', 'dates'],
|
||
};
|
||
}
|
||
|
||
describe('CalendarYear planning outcome boundary', () => {
|
||
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);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const userInput = TestBed.inject(UserInputService);
|
||
userInput.remainingVacationDaysSignal.set([
|
||
{
|
||
id: 'stale',
|
||
type: SelectableDayType.CP,
|
||
numberOfDays: 1,
|
||
expiryDate: new Date('2025-01-01'),
|
||
},
|
||
]);
|
||
const fixture: ComponentFixture<CalendarYear> = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
|
||
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
|
||
});
|
||
|
||
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 userInput = TestBed.inject(UserInputService);
|
||
userInput.remainingVacationDaysSignal.set([]);
|
||
const fixture = TestBed.createComponent(CalendarYear);
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
|
||
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();
|
||
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.selectedDatesForPlan.mockImplementation((value) =>
|
||
selectedDatesForPlan(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')!;
|
||
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
|
||
expect(planDetails.open).toBe(true);
|
||
expect(traceDetails.open).toBe(false);
|
||
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('keeps explanation selection quiet and analyzes alternatives only 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(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();
|
||
details.open = true;
|
||
details.dispatchEvent(new Event('toggle'));
|
||
fixture.detectChanges();
|
||
root.querySelector<HTMLButtonElement>('button[data-date="2024-05-06"]')!.click();
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
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);
|
||
});
|
||
|
||
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 trace loading and completion while keeping the completed plan available', async () => {
|
||
const traceDelivery = deferred<PlanningTrace | undefined>();
|
||
const { calendar } = calendarFor(plan, explanation, undefined, traceDelivery.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 liveMessage = root.querySelector<HTMLElement>('.calendar-year > p[aria-live]')!;
|
||
const traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
|
||
activateWithKeyboard(traceDetails.querySelector('summary')!, ' ');
|
||
fixture.detectChanges();
|
||
expect(traceDetails.querySelector('[role="status"]')?.textContent).toContain(
|
||
'decision trace is still loading',
|
||
);
|
||
expect(liveMessage.getAttribute('aria-atomic')).toBe('true');
|
||
expect(root.querySelector('.plan-summary')).not.toBeNull();
|
||
|
||
traceDelivery.resolve(jointTrace());
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(liveMessage.textContent).toContain('decision trace has loaded');
|
||
await vi.waitFor(() => expect(traceDetails.querySelector('[role="status"]')).toBeNull());
|
||
root.querySelector<HTMLButtonElement>(
|
||
'.trace-decisions nav button[aria-label="Next decision"]',
|
||
)!.click();
|
||
fixture.detectChanges();
|
||
expect(root.querySelector('.candidate-list details')).not.toBeNull();
|
||
});
|
||
|
||
it('announces trace delivery failure without discarding the completed plan', async () => {
|
||
const traceDelivery = deferred<PlanningTrace | undefined>();
|
||
const { calendar } = calendarFor(plan, explanation, undefined, traceDelivery.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 traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
|
||
activateWithKeyboard(traceDetails.querySelector('summary')!, ' ');
|
||
fixture.detectChanges();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
traceDelivery.reject(new Error('trace transfer failed'));
|
||
await vi.waitFor(() => {
|
||
fixture.detectChanges();
|
||
expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain(
|
||
'No decision trace is available',
|
||
);
|
||
});
|
||
expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain(
|
||
'No decision trace is available',
|
||
);
|
||
expect(traceDetails.querySelector('[role="alert"]')?.textContent).toContain(
|
||
'trace transfer failed',
|
||
);
|
||
expect(root.querySelector('.plan-summary')).not.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 and replays an atomic trace decision', async () => {
|
||
const conflictExplanation: PlanningExplanation = {
|
||
kind: 'ConflictExplanation',
|
||
conflictingRules: [{ id: 'rule', label: 'Required spring leave' }],
|
||
bottleneck: { code: 'typed-capacity', message: 'No CP entitlement is available.' },
|
||
};
|
||
let setup = calendarFor(conflict, conflictExplanation);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: setup.calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
let 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.',
|
||
);
|
||
|
||
TestBed.resetTestingModule();
|
||
const trace = jointTrace();
|
||
setup = calendarFor(plan, explanation, trace);
|
||
vi.mocked(setup.calculation.historicalEligibility).mockResolvedValue([
|
||
{
|
||
date: '2024-01-02',
|
||
reason: {
|
||
code: 'unavailable',
|
||
message: 'This balance is not available until January 4.',
|
||
},
|
||
},
|
||
{
|
||
date: '2024-01-08',
|
||
reason: { code: 'expired', message: 'This balance expired on January 7.' },
|
||
},
|
||
]);
|
||
const eligibilityRetry = deferred<readonly HistoricalEligibilityReason[]>();
|
||
vi.mocked(setup.calculation.historicalEligibility)
|
||
.mockRejectedValueOnce(new Error('Historical eligibility unavailable.'))
|
||
.mockImplementationOnce(() => eligibilityRetry.promise);
|
||
setup.calendar.selectedDatesForPlan.mockImplementation((value) =>
|
||
selectedDatesForPlan(value, true),
|
||
);
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: setup.calendar },
|
||
],
|
||
}).compileComponents();
|
||
TestBed.inject(TranslationService).setLanguage('en');
|
||
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 traceDetails = root.querySelector<HTMLDetailsElement>('.trace-explorer')!;
|
||
activateWithKeyboard(traceDetails.querySelector('summary')!, ' ');
|
||
fixture.detectChanges();
|
||
await fixture.whenStable();
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
fixture.detectChanges();
|
||
expect(traceDetails.open).toBe(true);
|
||
const nextDecision = root.querySelector<HTMLButtonElement>(
|
||
'.trace-decisions nav button[aria-label="Next decision"]',
|
||
)!;
|
||
activateWithKeyboard(nextDecision);
|
||
await vi.waitFor(() => {
|
||
expect(fixture.componentInstance['historicalEligibilityError']()).toBe(
|
||
'Historical eligibility unavailable.',
|
||
);
|
||
});
|
||
fixture.detectChanges();
|
||
expect(fixture.componentInstance['traceReplay']()).toBe(true);
|
||
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
|
||
'Historical eligibility unavailable.',
|
||
);
|
||
expect(root.querySelector('.sr-only[role="status"]')?.textContent).toContain(
|
||
'Original date eligibility could not be checked.',
|
||
);
|
||
const retryEligibility = Array.from(root.querySelectorAll('button')).find((button) =>
|
||
button.textContent?.includes('Retry date eligibility check'),
|
||
)!;
|
||
activateWithKeyboard(retryEligibility);
|
||
fixture.detectChanges();
|
||
expect(root.querySelector('[role="status"]')?.textContent).toContain(
|
||
'Checking which dates this leave could use',
|
||
);
|
||
eligibilityRetry.resolve([
|
||
{
|
||
date: '2024-01-02',
|
||
reason: {
|
||
code: 'unavailable',
|
||
message: 'This balance is not available until January 4.',
|
||
},
|
||
},
|
||
{
|
||
date: '2024-01-08',
|
||
reason: { code: 'expired', message: 'This balance expired on January 7.' },
|
||
},
|
||
]);
|
||
await vi.waitFor(() => {
|
||
expect([...fixture.componentInstance['historicalIneligibility']().keys()]).toEqual([
|
||
'2024-01-02',
|
||
'2024-01-08',
|
||
]);
|
||
});
|
||
fixture.detectChanges();
|
||
expect(setup.calculation.historicalEligibility).toHaveBeenCalledWith({
|
||
decisionId: 'decision:joint',
|
||
placement: plan.placements[0],
|
||
});
|
||
expect(fixture.componentInstance['traceReplay']()).toBe(true);
|
||
expect(fixture.componentInstance['historicalEligibilityError']()).toBeNull();
|
||
expect([...fixture.componentInstance['historicalIneligibility']().keys()]).toEqual([
|
||
'2024-01-02',
|
||
'2024-01-08',
|
||
]);
|
||
const historicalAvailability = fixture.componentInstance['dateStatuses']();
|
||
expect(historicalAvailability['2024-01-02']).toMatchObject({
|
||
kind: 'ineligible',
|
||
reason: 'This leave balance cannot be used on this date.',
|
||
});
|
||
expect(historicalAvailability['2024-01-08']).toMatchObject({
|
||
kind: 'ineligible',
|
||
reason: 'This date is after the leave balance expires.',
|
||
});
|
||
let earnedBalance = Array.from(root.querySelectorAll('.trace-state-balance')).find((item) =>
|
||
item.textContent?.includes('earned:spring:2024'),
|
||
)!;
|
||
expect(earnedBalance.textContent).toContain('Available from 4 May 2024');
|
||
expect(earnedBalance.textContent).toContain('Expires on 4 May 2025');
|
||
const jointAfterButton =
|
||
root.querySelectorAll<HTMLButtonElement>('.trace-toolbar button')[1]!;
|
||
activateWithKeyboard(jointAfterButton);
|
||
fixture.detectChanges();
|
||
earnedBalance = Array.from(root.querySelectorAll('.trace-state-balance')).find((item) =>
|
||
item.textContent?.includes('earned:spring:2024'),
|
||
)!;
|
||
expect(earnedBalance.textContent).toContain('Available from 7 May 2024');
|
||
expect(earnedBalance.textContent).toContain('Expires on 7 May 2025');
|
||
expect(
|
||
root
|
||
.querySelector('.trace-decisions output[aria-current="step"]')
|
||
?.getAttribute('aria-label'),
|
||
).toContain('2024-05-03, 2024-05-06');
|
||
const candidates = Array.from(
|
||
root.querySelectorAll<HTMLDetailsElement>('.candidate-list > details'),
|
||
);
|
||
expect(candidates).toHaveLength(2);
|
||
expect(candidates[0].querySelector('summary')?.textContent).toContain(
|
||
'2024-05-10, 2024-05-13',
|
||
);
|
||
expect(candidates[1].querySelector('summary')?.textContent).toContain(
|
||
'2024-05-03, 2024-05-06',
|
||
);
|
||
expect(candidates.every((candidate) => !candidate.open)).toBe(true);
|
||
const technicalComparison = candidates[0].querySelector('.technical-comparison')!;
|
||
expect(technicalComparison.closest('details')).toBe(candidates[0]);
|
||
expect(candidates[0].open).toBe(false);
|
||
activateWithKeyboard(candidates[0].querySelector('summary')!, 'Enter');
|
||
fixture.detectChanges();
|
||
expect(candidates[0].open).toBe(true);
|
||
expect(candidates[0].textContent).toContain(
|
||
'This branch cannot satisfy both required dates.',
|
||
);
|
||
expect(technicalComparison.textContent).toContain('Actual stage comparison');
|
||
const traceImpact = candidates[0].querySelector('.criteria-detail')!;
|
||
expect(traceImpact.textContent).toContain('Full-plan effect of this branch');
|
||
expect(traceImpact.querySelectorAll('.decisive-row')).toHaveLength(0);
|
||
expect(traceImpact.textContent).not.toContain('First difference');
|
||
const decisiveComparator = candidates[0].querySelector(
|
||
'.technical-comparison tbody tr.decisive-row',
|
||
)!;
|
||
expect(decisiveComparator.textContent).toContain('preference:spring');
|
||
expect(decisiveComparator.textContent).toContain('1');
|
||
expect(decisiveComparator.textContent).toContain('0');
|
||
expect(
|
||
candidates[0].textContent!.indexOf('This branch cannot satisfy both required dates.'),
|
||
).toBeLessThan(candidates[0].textContent!.indexOf('preference:spring'));
|
||
expect(document.activeElement).toBe(candidates[0].querySelector('summary'));
|
||
|
||
const beforeButton = root.querySelector('.trace-toolbar button') as HTMLButtonElement;
|
||
const afterButton = root.querySelectorAll('.trace-toolbar button')[1] as HTMLButtonElement;
|
||
activateWithKeyboard(beforeButton, ' ');
|
||
fixture.detectChanges();
|
||
expect(beforeButton.getAttribute('aria-pressed')).toBe('true');
|
||
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
|
||
'1 earned activations',
|
||
);
|
||
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
|
||
'cp: 1 remaining',
|
||
);
|
||
activateWithKeyboard(afterButton);
|
||
fixture.detectChanges();
|
||
expect(afterButton.getAttribute('aria-pressed')).toBe('true');
|
||
expect(root.querySelector('.trace-state-summary')?.textContent).toContain('2 placements');
|
||
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
|
||
'1 earned activations',
|
||
);
|
||
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
|
||
'cp: 0 remaining',
|
||
);
|
||
const weekend = root.querySelector<HTMLButtonElement>('button[data-date="2024-05-04"]')!;
|
||
expect(weekend.getAttribute('aria-label')).toContain('Ineligible');
|
||
expect(weekend.querySelector('.status-marker')?.textContent).toBe('×');
|
||
const legend = root.querySelector('.calendar-legend')?.textContent ?? '';
|
||
expect(legend).toContain('Evaluated and rejected');
|
||
expect(legend).toContain('Not examined');
|
||
expect(root.querySelector('.counterfactual-result')).toBeNull();
|
||
});
|
||
|
||
it('turns planner rejection into an incomplete error and clears loading', async () => {
|
||
const calendar = {
|
||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
||
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
|
||
createPlanningCalculation: vi.fn(() => ({
|
||
...calculationFor(plan),
|
||
outcome: Promise.reject(new Error('planner exploded')),
|
||
})),
|
||
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
|
||
};
|
||
await TestBed.configureTestingModule({
|
||
imports: [CalendarYear],
|
||
providers: [
|
||
provideZonelessChangeDetection(),
|
||
{ provide: CalendarService, useValue: calendar },
|
||
],
|
||
}).compileComponents();
|
||
const userInput = TestBed.inject(UserInputService);
|
||
userInput.remainingVacationDaysSignal.set([
|
||
{
|
||
id: 'stale',
|
||
type: SelectableDayType.CP,
|
||
numberOfDays: 1,
|
||
expiryDate: new Date('2025-01-01'),
|
||
},
|
||
]);
|
||
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(userInput.remainingVacationDaysSignal()).toEqual([]);
|
||
});
|
||
});
|