Files
vac-optimizer/src/app/components/rules-panel/work-sequence-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

173 lines
6.9 KiB
TypeScript

import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatExpansionPanel } from '@angular/material/expansion';
import { vi } from 'vitest';
import { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { calculatePlanningInternal } from '../../services/leave-planner.core';
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
import { RulesPanel } from './rules-panel';
describe('work-sequence rule editor and reload', () => {
let fixture: ComponentFixture<RulesPanel>;
let component: RulesPanel;
async function choose(label: string, text: string) {
const fields: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('mat-form-field'),
);
const select = fields
.find((field) => field.querySelector('mat-label')?.textContent?.trim() === label)
?.querySelector<HTMLElement>('mat-select');
if (!select) throw new Error(`Missing select ${label}`);
select.click();
await fixture.whenStable();
const option = Array.from(document.querySelectorAll<HTMLElement>('mat-option')).find(
(candidate) => candidate.textContent?.trim() === text,
);
if (!option) throw new Error(`Missing option ${text}`);
option.click();
await fixture.whenStable();
document.querySelector<HTMLElement>('.cdk-overlay-backdrop')?.click();
await fixture.whenStable();
}
async function setInput(selector: string, value: string) {
const input: HTMLInputElement = fixture.nativeElement.querySelector(selector);
input.value = value;
input.dispatchEvent(new Event('input'));
await fixture.whenStable();
}
beforeEach(async () => {
localStorage.clear();
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
await TestBed.configureTestingModule({
imports: [RulesPanel],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('fr');
fixture = TestBed.createComponent(RulesPanel);
component = fixture.componentInstance;
fixture.detectChanges();
fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open();
});
afterEach(() => {
localStorage.clear();
vi.useRealTimers();
});
it('creates threshold five, edits it to three, and restores its effect, order and activation after reload', async () => {
component.startAddNew(RuleType.OPTIMIZATION);
await fixture.whenStable();
await setInput('input:not([type="number"])', 'Mes reprises');
await choose('Éléments du planning', 'Séquences de travail');
await choose('Mesure', 'Déficit des séquences de travail');
await choose('Conditions (toutes doivent être vérifiées)', 'Internes');
await choose('Conditions (toutes doivent être vérifiées)', 'Non vides');
const threshold: HTMLInputElement =
fixture.nativeElement.querySelector('input[type="number"]');
expect(threshold.value).toBe('5');
expect(threshold.min).toBe('1');
expect(threshold.step).toBe('1');
expect(fixture.nativeElement.textContent).toContain('Seuil de préférence en jours ouvrés');
component.saveEdit();
await fixture.whenStable();
const rule = component.rules[0];
expect(rule).toMatchObject({ configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit });
expect(component.getRuleSummary(rule)).toContain(
'Déficit des séquences de travail (jours ouvrés)',
);
const request = {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
};
expect(calculatePlanningInternal({ ...request, rules: component.rules })).toMatchObject({
kind: 'Plan',
placements: [{ date: '2024-01-09' }, { date: '2024-01-10' }, { date: '2024-01-11' }],
});
component.startEdit(rule, new Event('click'));
await fixture.whenStable();
await setInput('input[type="number"]', '3');
component.saveEdit();
await fixture.whenStable();
expect(component.getRuleSummary(component.rules[0])).toContain(
'seuil de préférence : 3 jours ouvrés',
);
expect(calculatePlanningInternal({ ...request, rules: component.rules })).toMatchObject({
kind: 'Plan',
placements: [],
});
component.startAddNew(RuleType.OPTIMIZATION);
await fixture.whenStable();
await setInput('input:not([type="number"])', 'Ma séquence maximale');
await choose('Éléments du planning', 'Séquences de travail');
await choose('Agrégation', 'Maximum');
component.saveEdit();
await fixture.whenStable();
const maximum = component.rules[1];
expect(maximum).toMatchObject({ configuration: OPTIMIZATION_PRESETS.longestWorkSequence });
component.toggleRule(maximum.id);
await fixture.whenStable();
const moveUp: HTMLButtonElement = fixture.nativeElement
.querySelectorAll('.rule-actions')[1]
.querySelector('button');
moveUp.focus();
moveUp.click();
await fixture.whenStable();
const saved = component.rules;
await new Promise((resolve) => setTimeout(resolve, 350));
TestBed.resetTestingModule();
const restored = TestBed.inject(RuleService).rules;
expect(restored).toEqual(saved);
expect(restored.map(({ id }) => id)).toEqual([maximum.id, rule.id]);
expect(restored[0].enabled).toBe(false);
expect(restored[1]).toMatchObject({
name: 'Mes reprises',
enabled: true,
configuration: { thresholdDays: 3 },
});
expect(calculatePlanningInternal({ ...request, rules: restored })).toMatchObject({
kind: 'Plan',
placements: [],
});
});
it('keeps the saved threshold and shows an accessible error for fractional input', async () => {
component.startAddNew(RuleType.OPTIMIZATION);
component.changeOptimizationMeasure('work-sequence-deficit');
await fixture.whenStable();
await setInput('input[type="number"]', '1.5');
component.saveEdit();
await fixture.whenStable();
expect(component.rules).toEqual([]);
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain(
'seuil entier positif',
);
expect(fixture.nativeElement.querySelector('input[type="number"]').value).toBe('1.5');
});
it('distinguishes aggregation units from the working-day threshold', () => {
const translations = TestBed.inject(TranslationService);
expect(
translations.optimizationSummary(OPTIMIZATION_PRESETS.longestWorkSequence),
).toContain('Longueur des séquences de travail (jours ouvrés)');
expect(
translations.optimizationSummary({
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
aggregation: 'count',
}),
).toContain('Déficit des séquences de travail (Nombre)');
expect(
translations.optimizationSummary({
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
aggregation: 'count',
}),
).toContain('seuil de préférence : 5 jours ouvrés');
});
});