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; 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; 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.'); }); });