fix(planning): complete explanation and trace behavior (#46)

This commit is contained in:
nessar committed 2026-10-02 15:05:30 +02:00
1 parent 57a39c81ce
commit c9dbe82b79
10 files changed
+925 -140

No files matched your search

@@ -112,8 +112,12 @@ const explanation: PlanningExplanation = {
})),
};
function selectedDatesForPlan(value: PlanOutcome): SelectedDates {
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)));
@@ -145,6 +149,18 @@ function calculationFor(
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',
},
],
};
@@ -276,8 +292,32 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
});
it('keeps the simple result visible and reveals placement details on demand', async () => {
it('shows the first plan explanation by default and navigates placements', async () => {
const { calendar, calculation } = calendarFor(plan, explanation);
calendar.selectedDatesForPlan.mockImplementation((value) =>
selectedDatesForPlan(value, true),
);
const counterfactualWithoutHistoricalStatus: CounterfactualAnalysis = {
kind: 'Counterfactual',
calculationId: 'test',
placement: plan.placements[0],
hypothetical: true,
baseline: objectiveVector,
results: [
{ date: '2024-05-03', status: 'eligible' },
{
date: '2024-05-04',
status: 'ineligible',
},
{
date: '2024-05-08',
status: 'ineligible',
},
],
};
vi.spyOn(calculation, 'analyzeCounterfactual').mockResolvedValue(
counterfactualWithoutHistoricalStatus,
);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -292,17 +332,37 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
const details = fixture.nativeElement.querySelector('.plan-details') as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
expect(fixture.nativeElement.querySelector('button.current')).toBeNull();
expect(fixture.nativeElement.querySelectorAll('.legend-symbol')).toHaveLength(0);
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
details.open = true;
details.dispatchEvent(new Event('toggle'));
fixture.detectChanges();
expect(details.open).toBe(true);
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(3);
expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
'2024-05-03',
);
expect(calculation.analyzeCounterfactual).toHaveBeenCalledWith(plan.placements[0]);
expect(fixture.nativeElement.textContent).toContain('Paid leave');
expect(fixture.componentInstance['dateStatuses']()['2024-05-04']).toMatchObject({
kind: 'ineligible',
label: 'Ineligible',
reason: 'Weekend',
});
expect(fixture.componentInstance['dateStatuses']()['2024-05-08']).toMatchObject({
kind: 'ineligible',
label: 'Ineligible',
reason: 'Public Holiday',
});
const weekend = fixture.nativeElement.querySelector(
'button[data-date="2024-05-04"]',
) as HTMLButtonElement;
expect(weekend.getAttribute('aria-label')).toContain('Ineligible');
expect(weekend.getAttribute('aria-label')).toContain('Weekend');
expect(weekend.querySelector('.status-marker')?.textContent).toBe('×');
expect(weekend.querySelector('.hypothetical-marker')?.textContent).toBe('~×');
const holidayButton = fixture.nativeElement.querySelector(
'button[data-date="2024-05-08"]',
) as HTMLButtonElement;
expect(holidayButton.getAttribute('aria-label')).toContain('Ineligible');
expect(holidayButton.getAttribute('aria-label')).toContain('Public Holiday');
expect(holidayButton.querySelector('.status-marker')?.textContent).toBe('×');
expect(holidayButton.querySelector('.hypothetical-marker')?.textContent).toBe('~×');
const next = fixture.nativeElement.querySelector(
'button[data-step="next"]',
@@ -404,6 +464,9 @@ describe('CalendarYear planning outcome boundary', () => {
completedStages: ['at-risk'],
};
setup = calendarFor(plan, explanation, trace);
setup.calendar.selectedDatesForPlan.mockImplementation((value) =>
selectedDatesForPlan(value, true),
);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -433,6 +496,33 @@ describe('CalendarYear planning outcome boundary', () => {
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');
expect(fixture.componentInstance['dateStatuses']()['2024-05-04']).toMatchObject({
kind: 'ineligible',
reason: 'Weekend',
});
expect(fixture.componentInstance['dateStatuses']()['2024-05-08']).toMatchObject({
kind: 'ineligible',
reason: 'Public Holiday',
});
const weekend = fixture.nativeElement.querySelector(
'button[data-date="2024-05-04"]',
) as HTMLButtonElement;
expect(weekend.getAttribute('aria-label')).toContain('Ineligible');
expect(weekend.getAttribute('aria-label')).toContain('Weekend');
const holiday = fixture.nativeElement.querySelector(
'button[data-date="2024-05-08"]',
) as HTMLButtonElement;
expect(holiday.getAttribute('aria-label')).toContain('Public Holiday');
const legend = fixture.nativeElement.querySelector('.calendar-legend').textContent;
expect(legend).toContain('Evaluated and rejected');
expect(legend).toContain('Not examined');
const unevaluatedDate = fixture.nativeElement.querySelector(
'button[data-date="2024-05-07"]',
) as HTMLButtonElement;
unevaluatedDate.click();
fixture.detectChanges();
expect(unevaluatedDate.classList).toContain('current');
expect(fixture.componentInstance['liveMessage']()).toContain('Not examined');
const afterButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[1];
afterButton.click();
fixture.detectChanges();
@@ -443,7 +533,65 @@ describe('CalendarYear planning outcome boundary', () => {
compareButton.click();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(fixture.componentInstance['dateStatuses']()['2024-05-04'].kind).toBe('ineligible');
expect(fixture.componentInstance['traceReplay']()).toBe(true);
expect(fixture.componentInstance['selectedTraceDecision']()?.id).toBe('decision:1');
expect(fixture.componentInstance['dateStatuses']()['2024-05-04']).toMatchObject({
kind: 'ineligible',
label: 'Ineligible',
reason: 'Weekend',
hypotheticalLabel: 'Ineligible',
hypotheticalReason: 'Weekend',
});
expect(fixture.componentInstance['dateStatuses']()['2024-05-03']).toMatchObject({
kind: 'chosen',
label: 'Chosen',
hypotheticalLabel: 'Eligible',
});
expect(fixture.componentInstance['dateStatuses']()['2024-05-07']).toMatchObject({
kind: 'not-examined',
label: 'Not examined',
hypotheticalLabel: 'Ineligible',
});
expect(fixture.componentInstance['dateStatuses']()['2024-05-08']).toMatchObject({
kind: 'ineligible',
label: 'Ineligible',
reason: 'Public Holiday',
hypotheticalLabel: 'Ineligible',
});
const holidayAlternative = fixture.nativeElement.querySelector(
'button[data-date="2024-05-08"]',
) as HTMLButtonElement;
expect(holidayAlternative.querySelector('.status-marker')?.textContent).toBe('×');
expect(holidayAlternative.querySelector('.hypothetical-marker')?.textContent).toBe('~×');
const notExaminedAlternative = fixture.nativeElement.querySelector(
'button[data-date="2024-05-07"]',
) as HTMLButtonElement;
expect(notExaminedAlternative.querySelector('.status-marker')?.textContent).toBe('?');
expect(notExaminedAlternative.querySelector('.hypothetical-marker')?.textContent).toBe(
'~×',
);
expect(notExaminedAlternative.getAttribute('aria-label')).toContain('Not examined');
expect(notExaminedAlternative.getAttribute('aria-label')).toContain(
'Date alternatives: Ineligible',
);
const counterfactualLegend = fixture.nativeElement.querySelector('.calendar-legend');
expect(counterfactualLegend.textContent).toContain('~✓Eligible');
expect(counterfactualLegend.textContent).toContain('~×Ineligible');
expect(fixture.componentInstance['dateStatuses']()['2024-05-03'].change).toBe('added');
expect(fixture.componentInstance['dateStatuses']()['2024-05-06'].change).toBe('unchanged');
const addedDate = fixture.nativeElement.querySelector(
'button[data-date="2024-05-03"]',
) as HTMLButtonElement;
expect(addedDate.querySelector('.status-marker')?.textContent).toBe('+');
expect(addedDate.querySelector('.hypothetical-marker')?.textContent).toBe('~✓');
expect(addedDate.classList).toContain('change-added');
expect(
(
fixture.nativeElement.querySelector(
'button[data-date="2024-05-06"]',
) as HTMLButtonElement
).classList,
).toContain('change-unchanged');
const alternativeDate = fixture.nativeElement.querySelector(
'button[data-date="2024-05-04"]',
) as HTMLButtonElement;
@@ -451,6 +599,28 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
expect(alternativeDate.classList).toContain('current');
expect(alternativeDate.getAttribute('aria-current')).toBe('date');
expect(alternativeDate.getAttribute('aria-label')).toContain('Ineligible');
expect(alternativeDate.getAttribute('aria-label')).toContain(
'Date alternatives: Ineligible',
);
expect(alternativeDate.getAttribute('aria-label')).toContain('Reason: Weekend');
expect(fixture.componentInstance['liveMessage']()).toContain(
'Date alternatives: Ineligible',
);
const counterfactualResult = fixture.nativeElement.querySelector(
'.counterfactual-result',
) as HTMLElement;
expect(counterfactualResult.textContent).toContain('Ineligible');
expect(counterfactualResult.textContent).toContain('Original search status: Ineligible');
const chosenDate = fixture.nativeElement.querySelector(
'button[data-date="2024-05-03"]',
) as HTMLButtonElement;
chosenDate.click();
fixture.detectChanges();
expect(chosenDate.getAttribute('aria-label')).toContain('Chosen');
expect(chosenDate.getAttribute('aria-label')).toContain('Date alternatives: Eligible');
expect(counterfactualResult.textContent).toContain('Eligible');
expect(counterfactualResult.textContent).toContain('Original search status: Chosen');
});
it('turns planner rejection into an incomplete error and clears loading', async () => {