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

215 lines
8.0 KiB
TypeScript

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { RulesPanel } from './rules-panel';
import { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { By } from '@angular/platform-browser';
import { CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { vi } from 'vitest';
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
import { MatExpansionPanel } from '@angular/material/expansion';
describe('RulesPanel', () => {
let component: RulesPanel;
let fixture: ComponentFixture<RulesPanel>;
beforeEach(async () => {
// Clear localStorage before testing
localStorage.clear();
await TestBed.configureTestingModule({
imports: [RulesPanel],
providers: [provideZonelessChangeDetection(), RuleService],
}).compileComponents();
fixture = TestBed.createComponent(RulesPanel);
component = fixture.componentInstance;
// Force language to 'fr' so the default rule names match French assertions
const translationService = TestBed.inject(TranslationService);
translationService.setLanguage('fr');
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display initial rules from service', () => {
expect(component.rules.length).toBe(8);
});
it('should start adding new If-Then rule', () => {
component.startAddNew(RuleType.IF_THEN);
expect(component['isAddingNew']()).toBe(true);
expect(component['newRuleType']()).toBe(RuleType.IF_THEN);
expect(component['editBuffer'].type).toBe(RuleType.IF_THEN);
expect(component['editBuffer'].name).toBe('Nouvelle Règle');
});
it('should start adding new Time Window rule', () => {
component.startAddNew(RuleType.TIME_WINDOW);
expect(component['isAddingNew']()).toBe(true);
expect(component['newRuleType']()).toBe(RuleType.TIME_WINDOW);
expect(component['editBuffer'].type).toBe(RuleType.TIME_WINDOW);
expect(component['editBuffer'].name).toBe('Nouvelle Période');
});
it('should cancel edit', () => {
component.startAddNew(RuleType.IF_THEN);
component.cancelEdit();
expect(component['isAddingNew']()).toBe(false);
expect(component['editingRuleId']()).toBeNull();
expect(Object.keys(component['editBuffer']).length).toBe(0);
});
it('should save a new rule', () => {
const initialCount = component.rules.length;
component.startAddNew(RuleType.IF_THEN);
component['editBuffer'].name = 'Custom Test Rule';
component.saveEdit();
expect(component.rules.length).toBe(initialCount + 1);
expect(component.rules.some((r) => r.name === 'Custom Test Rule')).toBe(true);
});
it('should update an existing rule', () => {
const targetRule = component.rules[0];
// Create a mock event with stopPropagation
const mockEvent = { stopPropagation: () => {} } as Event;
component.startEdit(targetRule, mockEvent);
component['editBuffer'].name = 'Updated Name';
component.saveEdit();
expect(component.rules.find((r) => r.id === targetRule.id)?.name).toBe('Updated Name');
});
it('should delete a rule', () => {
const targetId = component.rules[0].id;
const initialCount = component.rules.length;
const mockEvent = { stopPropagation: () => {} } as Event;
component.deleteRule(targetId, mockEvent);
expect(component.rules.length).toBe(initialCount - 1);
expect(component.rules.some((r) => r.id === targetId)).toBe(false);
});
it('should toggle a rule', () => {
const targetRule = component.rules[0];
const initialState = targetRule.enabled;
component.toggleRule(targetRule.id);
expect(component.rules.find((r) => r.id === targetRule.id)?.enabled).toBe(!initialState);
});
it('should move a rule with the keyboard controls', () => {
const secondRuleId = component.rules[1].id;
component.moveRule(1, -1, { stopPropagation: () => {} } as Event);
expect(component.rules[0].id).toBe(secondRuleId);
});
it('creates, edits and moves an optimization using the keyboard controls with preference-only mode', async () => {
fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open();
component.startAddNew(RuleType.OPTIMIZATION);
fixture.detectChanges();
expect(component['forceOptions'].map((option) => option.value)).toEqual(['preference']);
expect(fixture.nativeElement.textContent).toContain('Éléments du planning');
component['editBuffer'].name = 'Mes pertes';
component.saveEdit();
await fixture.whenStable();
const rule = component.rules.at(-1)!;
expect(rule).toMatchObject({
name: 'Mes pertes',
type: RuleType.OPTIMIZATION,
force: 'preference',
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
});
expect(component.getRuleSummary(rule)).toContain('Quantité non placée');
const announce = vi.spyOn(TestBed.inject(LiveAnnouncer), 'announce').mockResolvedValue();
const buttons = fixture.nativeElement.querySelectorAll('.rule-item');
const moveUp: HTMLButtonElement =
buttons[buttons.length - 1].querySelector('.rule-actions button');
moveUp.focus();
moveUp.click();
await fixture.whenStable();
expect(component.rules[7].id).toBe(rule.id);
expect(document.activeElement).toBe(moveUp);
expect(announce).toHaveBeenCalledWith('Mes pertes : position 8 sur 9.');
component.startEdit(rule, new Event('click'));
component['editBuffer'].name = 'Pertes modifiées';
component.saveEdit();
expect(component.rules[7].name).toBe('Pertes modifiées');
component.toggleRule(rule.id);
expect(component.rules[7].enabled).toBe(false);
component.deleteRule(rule.id, new Event('click'));
expect(component.rules.some((candidate) => candidate.id === rule.id)).toBe(false);
});
it('keeps saved optimization parameters when an edit is cancelled and reports invalid parameters', () => {
component.startAddNew(RuleType.OPTIMIZATION);
component.saveEdit();
const rule = component.rules.at(-1)!;
component.startEdit(rule, new Event('click'));
const configuration = component['optimizationConfiguration']!;
configuration.conditions = [];
component.cancelEdit();
expect(component.rules.at(-1)).toMatchObject({
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
});
component.startEdit(rule, new Event('click'));
component.changeOptimizationMeasure('work-sequence-deficit');
const invalid = component['optimizationConfiguration'];
if (invalid?.measure !== 'work-sequence-deficit') throw new Error('Expected deficit');
invalid.thresholdDays = 0;
component.saveEdit();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain(
'seuil entier positif',
);
expect(component.rules.at(-1)).toMatchObject({
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
});
});
it('keeps cancelled drops unchanged and announces the position after a completed drop', () => {
const initialIds = component.rules.map((rule) => rule.id);
const movedName = component.rules[0].name;
const announce = vi.spyOn(TestBed.inject(LiveAnnouncer), 'announce').mockResolvedValue();
const list = fixture.debugElement
.query(By.directive(CdkDropList))
.injector.get(CdkDropList);
const drop = {
previousIndex: 0,
currentIndex: 1,
isPointerOverContainer: false,
} as CdkDragDrop<unknown>;
list.dropped.emit(drop);
expect(component.rules.map((rule) => rule.id)).toEqual(initialIds);
expect(announce).not.toHaveBeenCalled();
list.dropped.emit({ ...drop, isPointerOverContainer: true });
expect(component.rules.map((rule) => rule.id)).toEqual([
initialIds[1],
initialIds[0],
...initialIds.slice(2),
]);
expect(announce).toHaveBeenCalledWith(`${movedName} : position 2 sur 8.`);
// CDK restores the original DOM before emitting dropped. The new order must
// be rendered before this callback returns, without waiting for another frame.
expect(fixture.nativeElement.querySelectorAll('.rule-name')[1].textContent).toContain(
movedName,
);
expect(fixture.nativeElement.querySelectorAll('.rule-number')[1].textContent).toBe('2.');
});
});