feat(planning): explain and trace planning decisions

This commit is contained in:
nessar committed 2026-08-31 00:02:45 +02:00
1 parent 609b3dece9
commit d7a13b0b1e
24 files changed
+6073 -342

No files matched your search

@@ -2,15 +2,26 @@ import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { CalendarYear } from './calendar-year';
import { CalendarService, SelectedDates, SelectableDayType } from '../../services/calendar-service';
import {
CalendarService,
DayType,
SelectedDate,
SelectedDates,
SelectableDayType,
} from '../../services/calendar-service';
import {
CounterfactualAnalysis,
PlanOutcome,
PlanningCalculationContext,
PlanningConflictOutcome,
PlanningExplanation,
PlanningIncompleteOutcome,
InvalidInputOutcome,
PlanningOutcome,
PlanningTrace,
} from '../../services/leave-planner.service';
import { UserInputService } from '../../services/user-input-service';
import { DateRange } from '@angular/material/datepicker';
const invalid: InvalidInputOutcome = {
kind: 'InvalidInput',
@@ -64,18 +75,120 @@ const plan: PlanOutcome = {
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): SelectedDates {
const selected = new SelectedDates();
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 calculationFor(
outcome: PlanningOutcome,
stableExplanation?: PlanningExplanation,
trace?: PlanningTrace,
): 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' },
},
],
};
return {
id: 'test',
snapshot: {
horizon: plan.horizon,
balances: [],
rules: [],
identities: {},
labels: {},
},
outcome: Promise.resolve(outcome),
explanation: Promise.resolve(stableExplanation),
explanationError: Promise.resolve(undefined),
trace: Promise.resolve(trace),
analyzeCounterfactual: vi.fn(async () => counterfactual),
};
}
function calendarFor(
outcome: PlanningOutcome,
stableExplanation?: PlanningExplanation,
trace?: PlanningTrace,
) {
const calculation = calculationFor(outcome, stableExplanation, trace);
return {
calculation,
calendar: {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
createPlanningCalculation: vi.fn(() => calculation),
selectedDatesForPlan: vi.fn(selectedDatesForPlan),
},
};
}
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 = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
planLeave: vi.fn(async () => outcome),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
const { calendar } = calendarFor(outcome);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -100,12 +213,7 @@ describe('CalendarYear planning outcome boundary', () => {
});
it('keeps and summarizes a returned result', async () => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
planLeave: vi.fn(async () => plan),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
const { calendar } = calendarFor(plan, explanation);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -127,13 +235,215 @@ describe('CalendarYear planning outcome boundary', () => {
expect(summaryValues).toEqual(['2', '4', '6']);
});
it('keeps an incomplete incumbent visible with its interrupted-stage explanation', 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();
expect(fixture.nativeElement.querySelector('.incomplete-incumbent').textContent).toContain(
'holiday-week-value',
);
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
});
it('shows the stable placement explanation by default and keeps the full plan visible', 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();
expect(fixture.nativeElement.querySelector('.placement-explanation')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('Paid leave');
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
expect(calculation.analyzeCounterfactual).toHaveBeenCalledWith(plan.placements[0]);
const next = fixture.nativeElement.querySelector(
'button[data-step="next"]',
) as HTMLButtonElement;
next.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
'2024-05-06',
);
});
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();
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: PlanningTrace = {
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,
},
],
placements: [plan.placements[1]],
earnedActivations: [],
},
phases: [
{
id: 'phase:1',
name: 'at-risk',
completed: true,
status: 'completed',
decisions: [
{
id: 'decision:1',
action: 'add',
dates: ['2024-05-03'],
candidates: [
{
status: 'chosen',
date: '2024-05-03',
placement: plan.placements[0],
},
],
deltas: [
{
kind: 'placement-added',
date: '2024-05-03',
balanceId: 'cp',
placement: plan.placements[0],
},
],
},
],
},
{
id: 'phase:2',
name: 'dates',
completed: true,
status: 'completed',
decisions: [],
},
],
completedStages: ['at-risk'],
};
setup = calendarFor(plan, explanation, trace);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: setup.calendar },
],
}).compileComponents();
fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const lastPhase = fixture.nativeElement.querySelector(
'button[data-phase-step="last"]',
) as HTMLButtonElement;
lastPhase.click();
fixture.detectChanges();
expect(fixture.componentInstance['tracePhaseIndex']()).toBe(1);
const firstPhase = fixture.nativeElement.querySelector(
'button[data-phase-step="first"]',
) as HTMLButtonElement;
firstPhase.click();
fixture.detectChanges();
const beforeButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[0];
beforeButton.click();
fixture.detectChanges();
expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(1);
expect(fixture.componentInstance['dateStatuses']()['2024-05-06'].change).toBe('unchanged');
expect(fixture.componentInstance['dateStatuses']()['2024-05-03'].change).toBe('added');
const afterButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[1];
afterButton.click();
fixture.detectChanges();
expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(2);
const compareButton = fixture.nativeElement.querySelector(
'.candidate-list button',
) as HTMLButtonElement;
compareButton.click();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(fixture.componentInstance['dateStatuses']()['2024-05-04'].kind).toBe('ineligible');
const alternativeDate = fixture.nativeElement.querySelector(
'button[data-date="2024-05-04"]',
) as HTMLButtonElement;
alternativeDate.click();
fixture.detectChanges();
expect(alternativeDate.classList).toContain('current');
expect(alternativeDate.getAttribute('aria-current')).toBe('date');
});
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' })),
planLeave: vi.fn(async () => {
throw new Error('planner exploded');
}),
createPlanningCalculation: vi.fn(() => ({
...calculationFor(plan),
outcome: Promise.reject(new Error('planner exploded')),
})),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
await TestBed.configureTestingModule({