import { provideZonelessChangeDetection } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { MatExpansionPanel } from '@angular/material/expansion'; import { RulesPanel } from './rules-panel'; import { RuleService, RuleType, type OptimizationRule } from '../../services/rule-service'; import { TranslationService } from '../../services/translation-service'; import { OPTIMIZATION_PRESETS, type OptimizationConfiguration, } from '../../services/planning-objectives'; import { calculatePlanningInternal } from '../../services/leave-planner.core'; describe('calendar rule editing and persistence', () => { let fixture: ComponentFixture; let service: RuleService; let translations: TranslationService; async function openEditor() { await TestBed.configureTestingModule({ imports: [RulesPanel], providers: [provideZonelessChangeDetection()], }).compileComponents(); service = TestBed.inject(RuleService); translations = TestBed.inject(TranslationService); translations.setLanguage('fr'); fixture = TestBed.createComponent(RulesPanel); fixture.detectChanges(); fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open(); await fixture.whenStable(); fixture.detectChanges(); } beforeEach(async () => { localStorage.clear(); localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); await openEditor(); for (const rule of service.rules) service.deleteRule(rule.id); fixture.detectChanges(); }); afterEach(() => localStorage.clear()); async function select(label: string, value: string) { const fields: HTMLElement[] = Array.from( fixture.nativeElement.querySelectorAll('mat-form-field'), ); const field = fields.find( (candidate) => candidate.querySelector('mat-label')?.textContent?.trim() === label, ); const control = field?.querySelector('mat-select'); if (!control) throw new Error(`Missing labelled control: ${label}`); const labels = (control.getAttribute('aria-labelledby') ?? '') .split(/\s+/) .map((id) => document.getElementById(id)?.textContent?.trim()); expect(labels).toContain(label); control.click(); await fixture.whenStable(); const option = Array.from(document.querySelectorAll('mat-option')).find( (candidate) => candidate.textContent?.trim() === value, ); if (!option) throw new Error(`Missing translated option: ${value}`); option.click(); document.querySelector('.cdk-overlay-backdrop')?.click(); await fixture.whenStable(); fixture.detectChanges(); } async function add(name: string, configuration: OptimizationConfiguration) { const tr = translations.tr().rules; const buttons: HTMLButtonElement[] = Array.from( fixture.nativeElement.querySelectorAll('.add-buttons button'), ); buttons.find((button) => button.textContent?.includes(tr.optimization.newRule))!.click(); await fixture.whenStable(); const nameInput: HTMLInputElement = fixture.nativeElement.querySelector('.rule-editor input'); nameInput.value = name; nameInput.dispatchEvent(new Event('input', { bubbles: true })); await select( tr.optimization.elementsLabel, tr.optimization.elements[configuration.elements], ); for (const condition of configuration.conditions) await select(tr.optimization.conditionsLabel, tr.optimization.conditions[condition]); await select( tr.optimization.aggregationLabel, tr.optimization.aggregations[configuration.aggregation], ); await select( tr.optimization.directionLabel, tr.optimization.directions[configuration.direction], ); const actions: HTMLButtonElement[] = Array.from( fixture.nativeElement.querySelectorAll('.editor-actions button'), ); actions.find((button) => button.textContent?.trim() === tr.add)!.click(); await fixture.whenStable(); fixture.detectChanges(); } function calculate() { let objectiveValues: readonly { id: string; value: number | string }[] = []; const plan = calculatePlanningInternal( { calculationDate: '2024-04-01', horizon: { start: '2024-04-29', end: '2024-05-18' }, balances: [ { id: 'cp', type: 'cp', quantity: 3, availableFrom: '2024-04-29', expiryDate: '2025-01-01', }, ], rules: service.rules, }, undefined, ({ explanation }) => { if (explanation?.kind === 'PlanExplanation') objectiveValues = explanation.placements[0]?.objectiveVector.objectives ?? []; }, ); if (plan.kind !== 'Plan') throw new Error('Expected a plan from saved editor configuration'); return { plan, objectiveValues }; } it('creates both recipes, reverses their order, edits a compatible aggregation and restores it on reload', async () => { await add('Mes semaines fériées', OPTIMIZATION_PRESETS.holidayWeekValue); await add('Mes week-ends', OPTIMIZATION_PRESETS.nonWorkingDayGain); expect(calculate().plan.metrics).toMatchObject({ holidayWeekValue: 5, nonWorkingDayGain: 6, }); const items: HTMLElement[] = Array.from( fixture.nativeElement.querySelectorAll('.rule-item'), ); items[1].querySelector('.rule-actions button')!.click(); await fixture.whenStable(); fixture.detectChanges(); expect(calculate().plan.metrics).toMatchObject({ holidayWeekValue: 0, nonWorkingDayGain: 9, }); const holiday = service.rules.find( (rule): rule is OptimizationRule => rule.type === RuleType.OPTIMIZATION && rule.configuration.elements === 'public-holiday-weeks', )!; fixture.componentInstance.startEdit(holiday, new Event('click')); await fixture.whenStable(); await select( translations.tr().rules.optimization.aggregationLabel, translations.tr().rules.optimization.aggregations.count, ); fixture.componentInstance.saveEdit(); await fixture.whenStable(); await expect .poll( () => JSON.parse(localStorage.getItem('vacationRules')!).rules[1]?.configuration ?.aggregation, ) .toBe('count'); const saved = service.rules; TestBed.resetTestingModule(); await openEditor(); expect(service.rules).toEqual(saved); expect(calculate().plan.metrics).toMatchObject({ holidayWeekValue: 0, nonWorkingDayGain: 9, }); fixture.componentInstance.moveRule(1, -1, new Event('click')); const edited = calculate(); expect(edited.plan.metrics.holidayWeekValue).toBe(5); expect( edited.objectiveValues.find( (objective) => objective.id === `optimization:${holiday.id}`, )?.value, ).toBe(1); }); it('creates economy, edits its name, moves it with the focusable control and restores disable/delete choices', async () => { await add('Mes semaines fériées', OPTIMIZATION_PRESETS.holidayWeekValue); await add('Économiser les jours d’appoint', OPTIMIZATION_PRESETS.supportingPlacements); const economy = service.rules[1]; expect(economy).toMatchObject({ force: 'preference', configuration: OPTIMIZATION_PRESETS.supportingPlacements, }); expect(calculate().plan.metrics.supportingPlacements).toBe(3); const move: HTMLButtonElement = fixture.nativeElement .querySelectorAll('.rule-item')[1] .querySelector('.rule-actions button'); move.focus(); expect(document.activeElement).toBe(move); move.click(); await fixture.whenStable(); expect(calculate().plan.placements).toEqual([]); fixture.componentInstance.startEdit(service.rules[0], new Event('click')); await fixture.whenStable(); const input: HTMLInputElement = fixture.nativeElement.querySelector('.rule-editor input'); input.value = 'Mes jours conservés'; input.dispatchEvent(new Event('input', { bubbles: true })); fixture.componentInstance.saveEdit(); await fixture.whenStable(); const toggle: HTMLButtonElement = fixture.nativeElement .querySelector('.rule-item') .querySelector('button[role="switch"]'); toggle.click(); await fixture.whenStable(); expect(calculate().plan.metrics.supportingPlacements).toBe(3); await expect .poll(() => JSON.parse(localStorage.getItem('vacationRules')!).rules[0]?.enabled) .toBe(false); const saved = service.rules; TestBed.resetTestingModule(); await openEditor(); expect(service.rules).toEqual(saved); expect(service.rules[0]).toMatchObject({ id: economy.id, name: 'Mes jours conservés' }); expect(calculate().plan.metrics.supportingPlacements).toBe(3); fixture.componentInstance.toggleRule(economy.id); await fixture.whenStable(); expect(calculate().plan.placements).toEqual([]); const remove: HTMLButtonElement = fixture.nativeElement .querySelector('.rule-item') .querySelector(`[aria-label="${translations.tr().rules.deleteRule}"]`); remove.click(); await fixture.whenStable(); expect(calculate().plan.metrics.supportingPlacements).toBe(3); await expect .poll(() => JSON.parse(localStorage.getItem('vacationRules')!).rules.length) .toBe(1); TestBed.resetTestingModule(); await openEditor(); expect(service.rules.map(({ id }) => id)).not.toContain(economy.id); expect(calculate().plan.metrics.supportingPlacements).toBe(3); }); });