feat(planning): explain and trace planning decisions
This commit is contained in:
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({
|
||||
|
||||
Reference in new issue
Block a user