Files
vac-optimizer/src/app/services/rule-migration.spec.ts
T
nessar d9d9befa12
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s
refactor(planning): remove the original decision trace
2026-10-15 20:42:33 +02:00

276 lines
9.8 KiB
TypeScript

import { TestBed } from '@angular/core/testing';
import { RuleService, RuleType } from './rule-service';
import { vi } from 'vitest';
import { CalendarSettingsService } from './calendar-settings-service';
import { PlanningDurationService } from './planning-duration.service';
import { UserInputService } from './user-input-service';
import { SelectableDayType } from './calendar-service';
import { createPlanningCalculation } from './leave-planner.service';
describe('saved rule migration', () => {
beforeEach(() => localStorage.clear());
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
TestBed.resetTestingModule();
localStorage.clear();
});
it('restores every legacy record unchanged between loss prevention and the other five presets', () => {
const legacy = [
{
id: 'personal-window',
name: 'My fixed window',
type: RuleType.TIME_WINDOW,
enabled: true,
force: 'obligation',
startDate: '2024-01-10',
endDate: '2024-01-12',
placeDays: 2,
placeType: 'cp',
},
{
id: 'personal-pattern',
name: 'My disabled pattern',
type: RuleType.IF_THEN,
enabled: false,
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'around',
rewardDays: 1,
rewardType: 'other',
},
];
localStorage.setItem('vacationRules', JSON.stringify(legacy));
const service = TestBed.inject(RuleService);
expect(service.rules).toHaveLength(8);
expect(service.rules.slice(1, 3).map(({ id }) => id)).toEqual([
'personal-window',
'personal-pattern',
]);
const saved = JSON.parse(localStorage.getItem('vacationRules')!);
expect(saved.version).toBe(1);
expect(saved.rules.slice(1, 3)).toEqual(legacy);
expect(service.rules[2].force).toBe('preference');
expect(saved.rules[2]).not.toHaveProperty('force');
});
it('gives fresh users the six active presets around the two existing initial rules', () => {
const service = TestBed.inject(RuleService);
expect(service.rules.map(({ id }) => id)).toEqual([
'default-unplaced-at-risk',
'default-samedi-malin',
'default-christmas',
'default-holiday-week-value',
'default-non-working-day-gain',
'default-longest-work-sequence',
'default-short-sequence-deficit',
'default-supporting-placements',
]);
const presets = service.rules.filter(({ type }) => type === RuleType.OPTIMIZATION);
expect(presets).toHaveLength(6);
expect(presets.every(({ enabled, force }) => enabled && force === 'preference')).toBe(true);
});
it('migrates an old empty list once and retains editing, order, disabled and deleted choices immediately', () => {
localStorage.setItem('vacationRules', '[]');
let service = TestBed.inject(RuleService);
expect(service.rules).toHaveLength(6);
const deficit = service.rules[4];
if (
deficit.type !== RuleType.OPTIMIZATION ||
deficit.configuration.measure !== 'work-sequence-deficit'
)
throw new Error('Expected the deficit preset');
service.updateRule({
...deficit,
name: 'My three-day deficit',
configuration: { ...deficit.configuration, thresholdDays: 3 },
});
service.reorderRules(4, 0);
service.toggleRule(deficit.id);
service.deleteRule('default-holiday-week-value');
const expected = service.rules;
TestBed.resetTestingModule();
service = TestBed.inject(RuleService);
expect(service.rules).toEqual(expected);
expect(service.rules[0]).toMatchObject({
name: 'My three-day deficit',
enabled: false,
configuration: { thresholdDays: 3 },
});
for (const rule of service.rules) service.deleteRule(rule.id);
TestBed.resetTestingModule();
expect(TestBed.inject(RuleService).rules).toEqual([]);
expect(JSON.parse(localStorage.getItem('vacationRules')!)).toEqual({
version: 1,
rules: [],
});
});
it('avoids colliding with existing custom identities without replacing their records', () => {
const custom = ['default-unplaced-at-risk', 'default-unplaced-at-risk-2'].map((id) => ({
id,
name: id,
type: RuleType.IF_THEN,
enabled: false,
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'other',
}));
localStorage.setItem('vacationRules', JSON.stringify(custom));
const rules = TestBed.inject(RuleService).rules;
expect(rules[0].id).toBe('default-unplaced-at-risk-3');
expect(new Set(rules.map(({ id }) => id)).size).toBe(8);
expect(JSON.parse(localStorage.getItem('vacationRules')!).rules.slice(1, 3)).toEqual(
custom,
);
});
it('saves the migrated list and version in one atomic write and retries after a failed save', () => {
localStorage.setItem('vacationRules', '[]');
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota');
});
let service = TestBed.inject(RuleService);
expect(service.rules).toHaveLength(6);
expect(save).toHaveBeenCalledOnce();
expect(localStorage.getItem('vacationRules')).toBe('[]');
service.toggleRule(service.rules[0].id);
expect(localStorage.getItem('vacationRules')).toBe('[]');
save.mockRestore();
TestBed.resetTestingModule();
service = TestBed.inject(RuleService);
expect(service.rules).toHaveLength(6);
expect(service.rules[0].enabled).toBe(true);
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
});
it('keeps edits usable and saves the entire current list once storage recovers', () => {
localStorage.setItem('vacationRules', '[]');
const service = TestBed.inject(RuleService);
const prior = localStorage.getItem('vacationRules');
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota');
});
service.toggleRule(service.rules[0].id);
expect(service.rules[0].enabled).toBe(false);
expect(localStorage.getItem('vacationRules')).toBe(prior);
save.mockRestore();
service.deleteRule(service.rules[1].id);
const expected = service.rules;
TestBed.resetTestingModule();
expect(TestBed.inject(RuleService).rules).toEqual(expected);
});
it.each(['{"version":2,"rules":[]}', '{broken', 'null', '[null]'])(
'preserves unsupported saved data through subsequent edits: %s',
(saved) => {
localStorage.setItem('vacationRules', saved);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const save = vi.spyOn(Storage.prototype, 'setItem');
const service = TestBed.inject(RuleService);
service.toggleRule(service.rules[0].id);
expect(save).not.toHaveBeenCalled();
expect(localStorage.getItem('vacationRules')).toBe(saved);
},
);
it('prevents edits from overwriting unknown valid saved data after a read failure', () => {
const saved = '{"version":1,"rules":[]}';
localStorage.setItem('vacationRules', saved);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const read = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('denied');
});
const service = TestBed.inject(RuleService);
read.mockRestore();
const write = vi.spyOn(Storage.prototype, 'setItem');
service.toggleRule(service.rules[0].id);
expect(write).not.toHaveBeenCalled();
expect(localStorage.getItem('vacationRules')).toBe(saved);
});
it('works in memory when browser storage is unavailable', () => {
vi.stubGlobal('localStorage', undefined);
const service = TestBed.inject(RuleService);
expect(service.rules).toHaveLength(8);
service.toggleRule(service.rules[0].id);
expect(service.rules[0].enabled).toBe(false);
});
it.each(['disabled', 'deleted'])(
'keeps a migrated %s list inactive after reload while preserving declarations and at-risk quantities',
async (choice) => {
localStorage.setItem('vacationRules', '[]');
const service = TestBed.inject(RuleService);
for (const rule of service.rules) {
if (choice === 'disabled') service.toggleRule(rule.id);
else service.deleteRule(rule.id);
}
const saved = localStorage.getItem('vacationRules');
TestBed.resetTestingModule();
const restored = TestBed.inject(RuleService).rules;
expect(restored).toHaveLength(choice === 'disabled' ? 6 : 0);
expect(restored.every(({ enabled }) => !enabled)).toBe(true);
expect(localStorage.getItem('vacationRules')).toBe(saved);
vi.stubGlobal('window', undefined);
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-05' },
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-01-04' }],
declaredDays: [{ date: '2024-01-03', type: 'other' }],
rules: restored,
});
const outcome = await calculation.outcome;
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [],
declaredDays: [{ date: '2024-01-03', type: 'other' }],
remainingBalances: [
{
id: 'cp',
quantity: 2,
classification: 'at-risk',
reason: { code: 'active-priorities' },
},
],
});
expect(await calculation.explanation).toMatchObject({
kind: 'PlanExplanation',
placements: [],
});
},
);
it('migrates before the first duration request and actual calculation', async () => {
localStorage.setItem('vacationRules', '[]');
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-02', '2024-01-05');
TestBed.inject(UserInputService).updateBalances(() => [
{
id: 'cp',
type: SelectableDayType.CP,
numberOfDays: 1,
availableDate: new Date('2024-01-02'),
expiryDate: new Date('2024-01-05'),
},
]);
const request = TestBed.inject(PlanningDurationService).request()!;
expect(request.rules).toHaveLength(6);
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
vi.stubGlobal('window', undefined);
const calculation = createPlanningCalculation(request);
const outcome = await calculation.outcome;
expect(outcome).toMatchObject({
kind: 'Plan',
metrics: { unplacedAtRisk: 0 },
placements: [{ balanceId: 'cp' }],
});
});
});