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
242 lines
8.9 KiB
TypeScript
242 lines
8.9 KiB
TypeScript
import { provideZonelessChangeDetection } from '@angular/core';
|
||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||
import { By } from '@angular/platform-browser';
|
||
import { MatExpansionPanel } from '@angular/material/expansion';
|
||
import { RulesPanel } from './rules-panel';
|
||
import { RuleService, RuleType, type OptimizationRule } from '../../services/rule-service';
|
||
import { TranslationService } from '../../services/translation-service';
|
||
import {
|
||
OPTIMIZATION_PRESETS,
|
||
type OptimizationConfiguration,
|
||
} from '../../services/planning-objectives';
|
||
import { calculatePlanningInternal } from '../../services/leave-planner.core';
|
||
|
||
describe('calendar rule editing and persistence', () => {
|
||
let fixture: ComponentFixture<RulesPanel>;
|
||
let service: RuleService;
|
||
let translations: TranslationService;
|
||
|
||
async function openEditor() {
|
||
await TestBed.configureTestingModule({
|
||
imports: [RulesPanel],
|
||
providers: [provideZonelessChangeDetection()],
|
||
}).compileComponents();
|
||
service = TestBed.inject(RuleService);
|
||
translations = TestBed.inject(TranslationService);
|
||
translations.setLanguage('fr');
|
||
fixture = TestBed.createComponent(RulesPanel);
|
||
fixture.detectChanges();
|
||
fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
localStorage.clear();
|
||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||
await openEditor();
|
||
for (const rule of service.rules) service.deleteRule(rule.id);
|
||
fixture.detectChanges();
|
||
});
|
||
afterEach(() => localStorage.clear());
|
||
|
||
async function select(label: string, value: string) {
|
||
const fields: HTMLElement[] = Array.from(
|
||
fixture.nativeElement.querySelectorAll('mat-form-field'),
|
||
);
|
||
const field = fields.find(
|
||
(candidate) => candidate.querySelector('mat-label')?.textContent?.trim() === label,
|
||
);
|
||
const control = field?.querySelector<HTMLElement>('mat-select');
|
||
if (!control) throw new Error(`Missing labelled control: ${label}`);
|
||
const labels = (control.getAttribute('aria-labelledby') ?? '')
|
||
.split(/\s+/)
|
||
.map((id) => document.getElementById(id)?.textContent?.trim());
|
||
expect(labels).toContain(label);
|
||
control.click();
|
||
await fixture.whenStable();
|
||
const option = Array.from(document.querySelectorAll<HTMLElement>('mat-option')).find(
|
||
(candidate) => candidate.textContent?.trim() === value,
|
||
);
|
||
if (!option) throw new Error(`Missing translated option: ${value}`);
|
||
option.click();
|
||
document.querySelector<HTMLElement>('.cdk-overlay-backdrop')?.click();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
}
|
||
|
||
async function add(name: string, configuration: OptimizationConfiguration) {
|
||
const tr = translations.tr().rules;
|
||
const buttons: HTMLButtonElement[] = Array.from(
|
||
fixture.nativeElement.querySelectorAll('.add-buttons button'),
|
||
);
|
||
buttons.find((button) => button.textContent?.includes(tr.optimization.newRule))!.click();
|
||
await fixture.whenStable();
|
||
const nameInput: HTMLInputElement =
|
||
fixture.nativeElement.querySelector('.rule-editor input');
|
||
nameInput.value = name;
|
||
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||
await select(
|
||
tr.optimization.elementsLabel,
|
||
tr.optimization.elements[configuration.elements],
|
||
);
|
||
for (const condition of configuration.conditions)
|
||
await select(tr.optimization.conditionsLabel, tr.optimization.conditions[condition]);
|
||
await select(
|
||
tr.optimization.aggregationLabel,
|
||
tr.optimization.aggregations[configuration.aggregation],
|
||
);
|
||
await select(
|
||
tr.optimization.directionLabel,
|
||
tr.optimization.directions[configuration.direction],
|
||
);
|
||
const actions: HTMLButtonElement[] = Array.from(
|
||
fixture.nativeElement.querySelectorAll('.editor-actions button'),
|
||
);
|
||
actions.find((button) => button.textContent?.trim() === tr.add)!.click();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
}
|
||
|
||
function calculate() {
|
||
let objectiveValues: readonly { id: string; value: number | string }[] = [];
|
||
const plan = calculatePlanningInternal(
|
||
{
|
||
calculationDate: '2024-04-01',
|
||
horizon: { start: '2024-04-29', end: '2024-05-18' },
|
||
balances: [
|
||
{
|
||
id: 'cp',
|
||
type: 'cp',
|
||
quantity: 3,
|
||
availableFrom: '2024-04-29',
|
||
expiryDate: '2025-01-01',
|
||
},
|
||
],
|
||
rules: service.rules,
|
||
},
|
||
undefined,
|
||
({ explanation }) => {
|
||
if (explanation?.kind === 'PlanExplanation')
|
||
objectiveValues = explanation.placements[0]?.objectiveVector.objectives ?? [];
|
||
},
|
||
);
|
||
if (plan.kind !== 'Plan')
|
||
throw new Error('Expected a plan from saved editor configuration');
|
||
return { plan, objectiveValues };
|
||
}
|
||
|
||
it('creates both recipes, reverses their order, edits a compatible aggregation and restores it on reload', async () => {
|
||
await add('Mes semaines fériées', OPTIMIZATION_PRESETS.holidayWeekValue);
|
||
await add('Mes week-ends', OPTIMIZATION_PRESETS.nonWorkingDayGain);
|
||
expect(calculate().plan.metrics).toMatchObject({
|
||
holidayWeekValue: 5,
|
||
nonWorkingDayGain: 6,
|
||
});
|
||
const items: HTMLElement[] = Array.from(
|
||
fixture.nativeElement.querySelectorAll('.rule-item'),
|
||
);
|
||
items[1].querySelector<HTMLButtonElement>('.rule-actions button')!.click();
|
||
await fixture.whenStable();
|
||
fixture.detectChanges();
|
||
expect(calculate().plan.metrics).toMatchObject({
|
||
holidayWeekValue: 0,
|
||
nonWorkingDayGain: 9,
|
||
});
|
||
const holiday = service.rules.find(
|
||
(rule): rule is OptimizationRule =>
|
||
rule.type === RuleType.OPTIMIZATION &&
|
||
rule.configuration.elements === 'public-holiday-weeks',
|
||
)!;
|
||
fixture.componentInstance.startEdit(holiday, new Event('click'));
|
||
await fixture.whenStable();
|
||
await select(
|
||
translations.tr().rules.optimization.aggregationLabel,
|
||
translations.tr().rules.optimization.aggregations.count,
|
||
);
|
||
fixture.componentInstance.saveEdit();
|
||
await fixture.whenStable();
|
||
await expect
|
||
.poll(
|
||
() =>
|
||
JSON.parse(localStorage.getItem('vacationRules')!).rules[1]?.configuration
|
||
?.aggregation,
|
||
)
|
||
.toBe('count');
|
||
const saved = service.rules;
|
||
TestBed.resetTestingModule();
|
||
await openEditor();
|
||
expect(service.rules).toEqual(saved);
|
||
expect(calculate().plan.metrics).toMatchObject({
|
||
holidayWeekValue: 0,
|
||
nonWorkingDayGain: 9,
|
||
});
|
||
fixture.componentInstance.moveRule(1, -1, new Event('click'));
|
||
const edited = calculate();
|
||
expect(edited.plan.metrics.holidayWeekValue).toBe(5);
|
||
expect(
|
||
edited.objectiveValues.find(
|
||
(objective) => objective.id === `optimization:${holiday.id}`,
|
||
)?.value,
|
||
).toBe(1);
|
||
});
|
||
|
||
it('creates economy, edits its name, moves it with the focusable control and restores disable/delete choices', async () => {
|
||
await add('Mes semaines fériées', OPTIMIZATION_PRESETS.holidayWeekValue);
|
||
await add('Économiser les jours d’appoint', OPTIMIZATION_PRESETS.supportingPlacements);
|
||
const economy = service.rules[1];
|
||
expect(economy).toMatchObject({
|
||
force: 'preference',
|
||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||
});
|
||
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
|
||
const move: HTMLButtonElement = fixture.nativeElement
|
||
.querySelectorAll('.rule-item')[1]
|
||
.querySelector('.rule-actions button');
|
||
move.focus();
|
||
expect(document.activeElement).toBe(move);
|
||
move.click();
|
||
await fixture.whenStable();
|
||
expect(calculate().plan.placements).toEqual([]);
|
||
|
||
fixture.componentInstance.startEdit(service.rules[0], new Event('click'));
|
||
await fixture.whenStable();
|
||
const input: HTMLInputElement = fixture.nativeElement.querySelector('.rule-editor input');
|
||
input.value = 'Mes jours conservés';
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
fixture.componentInstance.saveEdit();
|
||
await fixture.whenStable();
|
||
const toggle: HTMLButtonElement = fixture.nativeElement
|
||
.querySelector('.rule-item')
|
||
.querySelector('button[role="switch"]');
|
||
toggle.click();
|
||
await fixture.whenStable();
|
||
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
|
||
await expect
|
||
.poll(() => JSON.parse(localStorage.getItem('vacationRules')!).rules[0]?.enabled)
|
||
.toBe(false);
|
||
const saved = service.rules;
|
||
TestBed.resetTestingModule();
|
||
await openEditor();
|
||
expect(service.rules).toEqual(saved);
|
||
expect(service.rules[0]).toMatchObject({ id: economy.id, name: 'Mes jours conservés' });
|
||
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
|
||
fixture.componentInstance.toggleRule(economy.id);
|
||
await fixture.whenStable();
|
||
expect(calculate().plan.placements).toEqual([]);
|
||
const remove: HTMLButtonElement = fixture.nativeElement
|
||
.querySelector('.rule-item')
|
||
.querySelector(`[aria-label="${translations.tr().rules.deleteRule}"]`);
|
||
remove.click();
|
||
await fixture.whenStable();
|
||
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
|
||
await expect
|
||
.poll(() => JSON.parse(localStorage.getItem('vacationRules')!).rules.length)
|
||
.toBe(1);
|
||
TestBed.resetTestingModule();
|
||
await openEditor();
|
||
expect(service.rules.map(({ id }) => id)).not.toContain(economy.id);
|
||
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
|
||
});
|
||
});
|