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
215 lines
8.0 KiB
TypeScript
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.');
|
|
});
|
|
});
|