UI improvments
This commit is contained in:
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 () => {
|
||||
|
||||
Reference in new issue
Block a user