Files
vac-optimizer/src/app/components/rules-panel/calendar-rule-editor.spec.ts
T
nessar 6811142299 feat(planning): make optimization rules composable
Evaluate explicit, ordered optimization configurations through one shared
planner model. Add six editable presets, durable one-time legacy migration,
and consistent completed and cancelled plan provenance.

Include functional regressions and the agreed trace-removal and benchmark
follow-up specs. Performance acceptance remains in #78 under unchanged gates.

Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
2026-10-10 16:07:34 +02:00

242 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<RulesPanel>;
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<HTMLElement>('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<HTMLElement>('mat-option')).find(
(candidate) => candidate.textContent?.trim() === value,
);
if (!option) throw new Error(`Missing translated option: ${value}`);
option.click();
document.querySelector<HTMLElement>('.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<HTMLButtonElement>('.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);
});
});