import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; import { frenchPublicHolidaysForYear } from './declared-non-working-days'; import { addDays, compareDate, derivePlacements, notePlacementAdded, notePlacementRemoved, observationsFromScratch, optimizationObservations, periodData, placementTieKey, setObservationShadowMode, utcDate, type ObservationCalendar, type ObservedPlacement, type ObservedPlan, } from './plan-observations'; function calendar(start: string, length: number, declared: readonly string[]): ObservationCalendar { const end = addDays(start, length); const first = addDays(start, -400); const last = addDays(end, 400); const holidays = new Set(); for (let year = Number(first.slice(0, 4)); year <= Number(last.slice(0, 4)); year++) for (const holiday of frenchPublicHolidaysForYear(year)) holidays.add(holiday); const isWorking = (date: string) => ![0, 6].includes(utcDate(date).getUTCDay()) && !holidays.has(date); const calendarDays: string[] = []; const calendarIndex = new Map(); const workingCalendar = new Set(); const workingDays: string[] = []; const workingPrefix = [0]; for (let date = first; compareDate(date, last) < 0; date = addDays(date, 1)) { calendarIndex.set(date, calendarDays.length); calendarDays.push(date); const working = isWorking(date); if (working) { workingCalendar.add(date); if (date >= start && date < end) workingDays.push(date); } workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(working)); } const weeks = new Map(); for (const holiday of holidays) { const weekday = utcDate(holiday).getUTCDay(); if (holiday < start || holiday >= end || weekday < 1 || weekday > 5) continue; const monday = addDays(holiday, 1 - weekday); const dates = [0, 1, 2, 3, 4].map((i) => addDays(monday, i)).filter(isWorking); weeks.set(monday, { dates, value: 1 + (weekday % 3) }); } return { horizon: { start, end }, declaredDates: new Set(declared), holidayWeeks: [...weeks.values()], calendarDays, calendarIndex, workingCalendar, workingPrefix, workingDays, check: () => {}, }; } type Op = { kind: 'add' | 'remove' | 'retype'; pick: number }; const scenario = fc.record({ // Horizons around year boundaries, Easter Monday, 1 May and Ascension 2024. start: fc.constantFrom('2023-12-18', '2024-03-28', '2024-04-24', '2024-05-06', '2024-12-23'), length: fc.integer({ min: 1, max: 45 }), declared: fc.array(fc.integer({ min: -3, max: 48 }), { maxLength: 8 }), ops: fc.array( fc.record({ kind: fc.constantFrom('add', 'add', 'remove', 'retype'), pick: fc.nat(1000), }), { maxLength: 40 }, ), }); function observe(plan: ObservedPlan, context: ObservationCalendar) { const observations = optimizationObservations(plan, context); return { tieKey: placementTieKey(plan, context), periods: periodData(plan, context), publicHolidayWeeks: observations.publicHolidayWeeks, timeOffPeriods: observations.timeOffPeriods, workSequences: observations.workSequences, }; } function expectScratch(plan: ObservedPlan, context: ObservationCalendar) { const scratch = observationsFromScratch(plan, context); expect(observe(plan, context)).toEqual({ tieKey: scratch.tieKey, periods: scratch.periods, publicHolidayWeeks: scratch.publicHolidayWeeks, timeOffPeriods: scratch.timeOffPeriods, workSequences: scratch.workSequences, }); } const placement = (date: string, balanceId: string, supporting = false): ObservedPlacement => ({ date, balanceId, supporting, }); describe('incremental plan observations', () => { it('match the from-scratch computation after every command', () => { fc.assert( fc.property(scenario, ({ start, length, declared, ops }) => { const context = calendar( start, length, declared.map((offset) => addDays(start, offset)), ); let plan: ObservedPlan = { balances: [], placements: new Map() }; expectScratch(plan, context); ops.forEach(({ kind, pick }, step) => { const parent = plan.placements as Map; // Every fourth add may hit a weekend, holiday, declared date or a date up to three // days outside the horizon, as snapshots can. const free = ( pick % 4 ? context.workingDays.filter((date) => !context.declaredDates.has(date)) : context.calendarDays.filter( (date) => date >= addDays(context.horizon.start, -3) && date < addDays(context.horizon.end, 3), ) ).filter((date) => !parent.has(date)); const used = [...parent.keys()]; const child = new Map(parent); if (kind === 'add' && free.length) { const date = free[pick % free.length]; notePlacementAdded(parent, child, date); child.set(date, placement(date, `b${step}`)); } else if (kind === 'remove' && used.length) { const date = used[pick % used.length]; notePlacementRemoved(parent, child, date); child.delete(date); } else if (kind === 'retype' && used.length) { const date = used[pick % used.length]; derivePlacements(parent, child); child.set(date, placement(date, `r${step}`, true)); } else return; plan = { balances: [], placements: child }; expectScratch(plan, context); }); }), { numRuns: 300 }, ); }); it('resolves a chain of unobserved derivations', () => { const context = calendar('2024-03-28', 20, ['2024-04-04']); const root = new Map([['2024-04-02', placement('2024-04-02', 'a')]]); expectScratch({ balances: [], placements: root }, context); let parent = root; for (const date of ['2024-04-03', '2024-04-05', '2024-04-08']) { const child = new Map(parent); notePlacementAdded(parent, child, date); child.set(date, placement(date, 'b')); parent = child; } const child = new Map(parent); notePlacementRemoved(parent, child, '2024-04-03'); child.delete('2024-04-03'); expectScratch({ balances: [], placements: child }, context); }); it('derives a child from an observed parent', () => { const context = calendar('2024-03-28', 20, []); const parent = new Map([['2024-04-02', placement('2024-04-02', 'a')]]); expectScratch({ balances: [], placements: parent }, context); const child = new Map(parent); notePlacementAdded(parent, child, '2024-04-03'); child.set('2024-04-03', placement('2024-04-03', 'b')); expectScratch({ balances: [], placements: child }, context); }); }); describe('observation shadow mode', () => { it('throws when a placements Map is mutated in place after being observed', () => { const context = calendar('2024-03-28', 20, []); const placements = new Map([['2024-04-02', placement('2024-04-02', 'a')]]); const plan: ObservedPlan = { balances: [], placements }; setObservationShadowMode(true); try { optimizationObservations(plan, context); placements.set('2024-04-10', placement('2024-04-10', 'b')); expect(() => optimizationObservations(plan, context)).toThrow(/shadow mismatch/); } finally { setObservationShadowMode(false); } }); });