Files
vac-optimizer/src/app/components/calendar-year/calendar-year-planning.spec.ts
T

1495 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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([]);
});
});