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
173 lines
6.9 KiB
TypeScript
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');
|
|
});
|
|
});
|