import { describe, it, expect } from 'vitest' import fc from 'fast-check' import { isWithinSchedule } from '../../app/utils/schedule_window.js' import type { ScheduleWindow } from '../../app/domain/configuration/configuration.js' /** * Property 12: Schedule window evaluation * Validates: Requirements 8.1, 8.3 * * Verifies midnight-spanning logic and day-of-week filtering using * randomly generated schedules and timestamps. */ describe('Property 12: Schedule window evaluation', () => { // --- Arbitraries --- /** Generate a valid hour (0-23) */ const arbHour = fc.integer({ min: 0, max: 23 }) /** Generate a valid day-of-week subset (0=Sunday ... 6=Saturday) */ const arbDaysOfWeek = fc.subarray([0, 1, 2, 3, 4, 5, 6], { minLength: 1 }) /** Generate a Date within a reasonable range */ const arbDate = fc.date({ min: new Date('2020-01-01T00:00:00Z'), max: new Date('2030-12-31T23:59:59Z'), }) /** Generate a non-midnight-spanning schedule (startHour < endHour) */ const arbNormalSchedule: fc.Arbitrary = fc .tuple(arbHour, arbHour, arbDaysOfWeek) .filter(([s, e]) => s < e) .map(([startHour, endHour, daysOfWeek]) => ({ startHour, endHour, daysOfWeek, timezone: 'UTC', })) /** Generate a midnight-spanning schedule (startHour > endHour) */ const arbMidnightSchedule: fc.Arbitrary = fc .tuple(arbHour, arbHour, arbDaysOfWeek) .filter(([s, e]) => s > e) .map(([startHour, endHour, daysOfWeek]) => ({ startHour, endHour, daysOfWeek, timezone: 'UTC', })) /** Generate any valid schedule (startHour !== endHour) */ const arbSchedule: fc.Arbitrary = fc .tuple(arbHour, arbHour, arbDaysOfWeek) .filter(([s, e]) => s !== e) .map(([startHour, endHour, daysOfWeek]) => ({ startHour, endHour, daysOfWeek, timezone: 'UTC', })) /** * Helper: create a UTC Date at a specific hour on a specific day-of-week. * Uses a known reference week (2025-01-05 is Sunday). */ function makeDateAtHourAndDay(hour: number, dayOfWeek: number): Date { // 2025-01-05 is a Sunday (dayOfWeek=0) const baseDate = new Date('2025-01-05T00:00:00Z') baseDate.setUTCDate(baseDate.getUTCDate() + dayOfWeek) baseDate.setUTCHours(hour, 0, 0, 0) return baseDate } // --- Property 1: undefined schedule always returns true --- it('isWithinSchedule(undefined, anyDate) always returns true', () => { fc.assert( fc.property(arbDate, (date) => { expect(isWithinSchedule(undefined, date)).toBe(true) }) ) }) // --- Property 2: Non-midnight-spanning window boundary behavior --- /** * **Validates: Requirements 8.1, 8.3** * * For a non-midnight-spanning window (startHour < endHour): * - A time at startHour is within the window * - A time at endHour is NOT within the window (exclusive end) */ it('non-midnight-spanning: startHour is within, endHour is NOT within', () => { fc.assert( fc.property(arbNormalSchedule, (schedule) => { // Pick a day that IS in daysOfWeek const day = schedule.daysOfWeek[0] const atStart = makeDateAtHourAndDay(schedule.startHour, day) const atEnd = makeDateAtHourAndDay(schedule.endHour, day) expect(isWithinSchedule(schedule, atStart)).toBe(true) expect(isWithinSchedule(schedule, atEnd)).toBe(false) }) ) }) // --- Property 3: Midnight-spanning window behavior --- /** * **Validates: Requirements 8.1, 8.3** * * For a midnight-spanning window (startHour > endHour): * - A time at startHour is within the window * - A time between endHour and startHour (exclusive) is NOT within the window */ it('midnight-spanning: startHour is within, hour between endHour and startHour is NOT within', () => { fc.assert( fc.property( arbMidnightSchedule, fc.integer({ min: 0, max: 23 }), (schedule, randomHour) => { // Pick a day that IS in daysOfWeek const day = schedule.daysOfWeek[0] // startHour should be within const atStart = makeDateAtHourAndDay(schedule.startHour, day) expect(isWithinSchedule(schedule, atStart)).toBe(true) // An hour strictly between endHour and startHour should be outside // For midnight-spanning: outside range is [endHour, startHour) const outsideHour = schedule.endHour + (randomHour % (schedule.startHour - schedule.endHour)) if (outsideHour >= schedule.endHour && outsideHour < schedule.startHour) { const outsideDate = makeDateAtHourAndDay(outsideHour, day) expect(isWithinSchedule(schedule, outsideDate)).toBe(false) } } ) ) }) // --- Property 4: Day-of-week filtering --- /** * **Validates: Requirements 8.1, 8.3** * * If the day is NOT in daysOfWeek, returns false regardless of hour. */ it('day-of-week filtering: excluded day always returns false', () => { fc.assert( fc.property(arbSchedule, arbHour, (schedule, hour) => { const allDays = [0, 1, 2, 3, 4, 5, 6] const excludedDays = allDays.filter((d) => !schedule.daysOfWeek.includes(d)) // Only test if there are excluded days if (excludedDays.length > 0) { const excludedDay = excludedDays[0] const date = makeDateAtHourAndDay(hour, excludedDay) expect(isWithinSchedule(schedule, date)).toBe(false) } }) ) }) // --- Property 5: Included day + within hour range returns true --- /** * **Validates: Requirements 8.1, 8.3** * * If daysOfWeek includes the current day and hour is within range, returns true. */ it('included day + hour within range returns true', () => { fc.assert( fc.property(arbSchedule, (schedule) => { const day = schedule.daysOfWeek[0] const { startHour, endHour } = schedule // Pick an hour that is definitely within the window let withinHour: number if (startHour < endHour) { // Normal window: any hour in [startHour, endHour) withinHour = startHour } else { // Midnight-spanning: startHour is always within withinHour = startHour } const date = makeDateAtHourAndDay(withinHour, day) expect(isWithinSchedule(schedule, date)).toBe(true) }) ) }) })