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