UI improvments

This commit is contained in:
nessar committed 2026-10-02 15:05:30 +02:00
1 parent c9dbe82b79
commit 0ce33cc9d6
106 files changed
+72934 -1675

No files matched your search

@@ -18,9 +18,11 @@ import {
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 = {
@@ -292,7 +294,7 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
});
it('shows the first plan explanation by default and navigates placements', async () => {
it('keeps the calendar clear until a placement is selected and alternatives are requested', async () => {
const { calendar, calculation } = calendarFor(plan, explanation);
calendar.selectedDatesForPlan.mockImplementation((value) =>
selectedDatesForPlan(value, true),
@@ -332,7 +334,23 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
const details = fixture.nativeElement.querySelector('.plan-details') as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('button.current')).toBeNull();
const placement = fixture.nativeElement.querySelector(
'button[data-date="2024-05-03"]',
) as HTMLButtonElement;
placement.click();
fixture.detectChanges();
expect(details.open).toBe(true);
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('.technical-details').open).toBe(false);
const compare = fixture.nativeElement.querySelector(
'.compare-dates-button',
) as HTMLButtonElement;
compare.click();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(3);
expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
'2024-05-03',
@@ -373,12 +391,314 @@ describe('CalendarYear planning outcome boundary', () => {
'2024-05-06',
);
expect(fixture.componentInstance['counterfactual']()).toBeNull();
details.open = false;
details.dispatchEvent(new Event('toggle'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button.current')).toBeNull();
});
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.',
);
TestBed.inject(TranslationService).setLanguage('fr');
fixture.detectChanges();
expect(element.querySelector('.day-impact')?.textContent).toContain(
'Congés risquant d’expirer',
);
expect(element.querySelector('.counterfactual-result')?.textContent).toContain(
'Aucun congé à poser.',
);
TestBed.inject(TranslationService).setLanguage('en');
fixture.detectChanges();
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 .inline-error')?.textContent,
).toContain('Date alternatives could not be calculated.');
expect(document.querySelector('.comparison-done')).not.toBeNull();
document.querySelector<HTMLButtonElement>('.comparison-done')!.click();
await fixture.whenStable();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
});
it('renders conflict causes and replays an atomic trace decision', async () => {
const conflictExplanation: PlanningExplanation = {
kind: 'ConflictExplanation',
@@ -440,6 +760,7 @@ describe('CalendarYear planning outcome boundary', () => {
status: 'chosen',
date: '2024-05-03',
placement: plan.placements[0],
comparison: explanation.placements[0].comparison,
},
],
deltas: [
@@ -490,6 +811,14 @@ describe('CalendarYear planning outcome boundary', () => {
) as HTMLButtonElement;
firstPhase.click();
fixture.detectChanges();
expect(
Array.from(
fixture.nativeElement.querySelectorAll(
'.candidate-list .criteria-table td',
) as NodeListOf<HTMLElement>,
(node) => node.textContent?.trim(),
),
).toEqual(['3', '4']);
const beforeButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[0];
beforeButton.click();
fixture.detectChanges();
@@ -607,10 +936,10 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.componentInstance['liveMessage']()).toContain(
'Date alternatives: Ineligible',
);
const counterfactualResult = fixture.nativeElement.querySelector(
const counterfactualResult = document.querySelector(
'.counterfactual-result',
) as HTMLElement;
expect(counterfactualResult.textContent).toContain('Ineligible');
expect(counterfactualResult.textContent).toContain('This date can’t be used');
expect(counterfactualResult.textContent).toContain('Original search status: Ineligible');
const chosenDate = fixture.nativeElement.querySelector(
'button[data-date="2024-05-03"]',
@@ -619,8 +948,34 @@ describe('CalendarYear planning outcome boundary', () => {
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('Available date');
expect(counterfactualResult.textContent).toContain('Original search status: Chosen');
expect(counterfactualResult.textContent).toContain('Before this decision');
expect(
Array.from(counterfactualResult.querySelectorAll(':scope > .impact-table td'), (node) =>
node.textContent?.trim(),
),
).toEqual(['3', '4']);
expect(
Array.from(counterfactualResult.querySelectorAll('.criteria-table td'), (node) =>
node.textContent?.trim(),
),
).toEqual(['3', '4']);
// Closing the trace restores the actual plan and clears hypothetical markers.
const traceDetails = fixture.nativeElement.querySelector(
'.trace-explorer',
) as HTMLDetailsElement;
traceDetails.open = true;
traceDetails.dispatchEvent(new Event('toggle'));
beforeButton.click();
fixture.detectChanges();
expect(fixture.componentInstance['traceReplay']()).toBe(true);
traceDetails.open = false;
traceDetails.dispatchEvent(new Event('toggle'));
fixture.detectChanges();
expect(fixture.componentInstance['traceReplay']()).toBe(false);
expect(fixture.componentInstance['counterfactual']()).toBeNull();
expect(fixture.componentInstance['dateStatuses']()).toEqual({});
});
it('turns planner rejection into an incomplete error and clears loading', async () => {