import { describe, it, expect } from 'vitest'; import { blockDuration, blockSegments, findOverlap, findScheduleOverlap, fiveMinuteOptions, formatTime, hmToMinutes, isOvernight, parseTimeInput, snapMinutes, toHm, validateBlock, validateBlockShape, validateSchedule, type BlockInput, type BlockSibling } from './schedule-utils'; const input = (overrides: Partial = {}): BlockInput => ({ startMinutes: 6 * 60, endMinutes: 8 * 60, proclamationIds: ['p1'], ...overrides }); const sibling = (startMinutes: number, endMinutes: number, id = 'b1'): BlockSibling => ({ id, startMinutes, endMinutes }); describe('formatTime', () => { it('formats 12-hour clock labels', () => { expect(formatTime(0)).toBe('12:00 AM'); expect(formatTime(6 * 60)).toBe('6:00 AM'); expect(formatTime(8 * 60 + 5)).toBe('8:05 AM'); expect(formatTime(12 * 60)).toBe('12:00 PM'); expect(formatTime(13 * 60 + 30)).toBe('1:30 PM'); expect(formatTime(23 * 60 + 59)).toBe('11:59 PM'); }); }); describe('toHm', () => { it('formats 24-hour HH:MM strings', () => { expect(toHm(0)).toBe('00:00'); expect(toHm(6 * 60)).toBe('06:00'); expect(toHm(23 * 60 + 59)).toBe('23:59'); }); }); describe('hmToMinutes', () => { it('parses 24-hour HH:MM strings', () => { expect(hmToMinutes('00:00')).toBe(0); expect(hmToMinutes('07:05')).toBe(7 * 60 + 5); expect(hmToMinutes('23:59')).toBe(23 * 60 + 59); }); it('rejects malformed or out-of-range values', () => { expect(hmToMinutes('7:05')).toBeNull(); expect(hmToMinutes('24:00')).toBeNull(); expect(hmToMinutes('12:60')).toBeNull(); expect(hmToMinutes('')).toBeNull(); }); }); describe('parseTimeInput', () => { it('parses tolerant 12-hour input', () => { expect(parseTimeInput('7:05 pm')).toEqual({ ok: true, minutes: 19 * 60 + 5 }); expect(parseTimeInput('7:05PM')).toEqual({ ok: true, minutes: 19 * 60 + 5 }); expect(parseTimeInput('7pm')).toEqual({ ok: true, minutes: 19 * 60 }); expect(parseTimeInput('7:7')).toEqual({ ok: true, minutes: 7 * 60 + 7 }); expect(parseTimeInput('12:00 am')).toEqual({ ok: true, minutes: 0 }); expect(parseTimeInput('12:30 PM')).toEqual({ ok: true, minutes: 12 * 60 + 30 }); expect(parseTimeInput('11:59 pm')).toEqual({ ok: true, minutes: 23 * 60 + 59 }); expect(parseTimeInput('0:30')).toEqual({ ok: true, minutes: 30 }); }); it('rejects ambiguous or out-of-range input', () => { expect(parseTimeInput('13:00 PM').ok).toBe(false); expect(parseTimeInput('7:70').ok).toBe(false); expect(parseTimeInput('25:00').ok).toBe(false); expect(parseTimeInput('').ok).toBe(false); expect(parseTimeInput('noon').ok).toBe(false); }); }); describe('fiveMinuteOptions', () => { it('lists every 5-minute step across the day', () => { const options = fiveMinuteOptions(); expect(options[0]).toBe(0); expect(options[1]).toBe(5); expect(options[options.length - 1]).toBe(23 * 60 + 55); expect(options).toHaveLength((24 * 60) / 5); }); }); describe('snapMinutes', () => { it('rounds to the nearest 15 minutes and clamps to the day', () => { expect(snapMinutes(0)).toBe(0); expect(snapMinutes(7)).toBe(0); expect(snapMinutes(8)).toBe(15); expect(snapMinutes(370)).toBe(375); expect(snapMinutes(-10)).toBe(0); expect(snapMinutes(2000)).toBe(1440); }); }); describe('isOvernight', () => { it('is true when the end clock time is before the start', () => { expect(isOvernight({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(true); expect(isOvernight({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(true); }); it('is false for same-day ranges and a full-day range', () => { expect(isOvernight({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(false); expect(isOvernight({ startMinutes: 0, endMinutes: 1440 })).toBe(false); }); }); describe('blockDuration', () => { it('counts a same-day range directly', () => { expect(blockDuration({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(120); }); it('counts an overnight range as wrapping to the next day', () => { expect(blockDuration({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(720); }); it('treats an end of midnight as wrapping from the start', () => { expect(blockDuration({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(120); }); }); describe('blockSegments', () => { it('returns a single segment for a same-day range', () => { expect(blockSegments({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toEqual([[6 * 60, 8 * 60]]); }); it('splits an overnight range at midnight', () => { expect(blockSegments({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toEqual([ [21 * 60, 1440], [0, 9 * 60] ]); }); it('returns no segments for a zero-length range', () => { expect(blockSegments({ startMinutes: 360, endMinutes: 360 })).toEqual([]); }); }); describe('findOverlap', () => { it('returns null for adjacent ranges', () => { expect( findOverlap({ startMinutes: 8 * 60, endMinutes: 9 * 60 }, [sibling(6 * 60, 8 * 60)]) ).toBeNull(); }); it('returns the sibling for a partial overlap', () => { const other = sibling(7 * 60, 9 * 60); expect(findOverlap({ startMinutes: 8 * 60, endMinutes: 10 * 60 }, [other])).toBe(other); }); it('detects an overnight block overlapping an early-morning block', () => { const other = sibling(8 * 60, 10 * 60); expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other); }); it('detects an overnight block overlapping a late-evening block', () => { const other = sibling(20 * 60, 22 * 60); expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other); }); it('allows an overnight block and its exact complement (adjacent at both ends)', () => { expect( findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(9 * 60, 21 * 60)]) ).toBeNull(); }); it('allows an overnight block and a midday block it does not touch', () => { expect( findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(10 * 60, 20 * 60)]) ).toBeNull(); }); }); describe('validateBlock', () => { it('accepts a valid block with no siblings', () => { expect(validateBlock(input(), [], { isCreate: true })).toEqual({ ok: true }); }); it('rejects equal start and end times', () => { const result = validateBlock(input({ endMinutes: 6 * 60 }), []); expect(result).toEqual({ ok: false, error: "Start and end time can't be the same." }); }); it('rejects a time outside the 24-hour range', () => { expect(validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), [])).toEqual({ ok: false, error: 'Enter a valid start and end time.' }); }); it('rejects a negative start time', () => { expect(validateBlock(input({ startMinutes: -1 }), [])).toEqual({ ok: false, error: 'Enter a valid start and end time.' }); }); it('accepts an overnight block', () => { expect( validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [], { isCreate: true }) ).toEqual({ ok: true }); }); it('rejects an overnight block that overlaps another and names it', () => { const result = validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [ sibling(8 * 60, 10 * 60) ]); expect(result).toEqual({ ok: false, error: 'This overlaps with your 8:00 AM–10:00 AM block.' }); }); it('requires at least one proclamation', () => { expect(validateBlock(input({ proclamationIds: [] }), [])).toEqual({ ok: false, error: 'Select at least one proclamation.' }); }); it('enforces the four-block maximum on create', () => { const siblings = [ sibling(0, 60, 'a'), sibling(60, 120, 'b'), sibling(120, 180, 'c'), sibling(180, 240, 'd') ]; const result = validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings, { isCreate: true }); expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' }); }); it('allows adjacent blocks (end exactly equals next start)', () => { expect( validateBlock(input({ startMinutes: 8 * 60, endMinutes: 9 * 60 }), [sibling(6 * 60, 8 * 60)]) ).toEqual({ ok: true }); }); it('rejects an exact duplicate range and names the conflicting block', () => { const result = validateBlock(input(), [sibling(6 * 60, 8 * 60)]); expect(result).toEqual({ ok: false, error: 'This overlaps with your 6:00 AM–8:00 AM block.' }); }); it('rejects a partial overlap', () => { const result = validateBlock(input({ startMinutes: 7 * 60, endMinutes: 9 * 60 }), [ sibling(8 * 60, 10 * 60) ]); expect(result.ok).toBe(false); }); it('rejects a block that contains another block', () => { const result = validateBlock(input({ startMinutes: 5 * 60, endMinutes: 11 * 60 }), [ sibling(6 * 60, 8 * 60) ]); expect(result.ok).toBe(false); }); it('does not apply the maximum when updating (isCreate false)', () => { const siblings = [sibling(0, 60, 'a'), sibling(60, 120, 'b'), sibling(120, 180, 'c')]; expect(validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings).ok).toBe(true); }); }); // ─── Schedule-wide validation (daily + weekly) ───────────────────────── const block = ( startMinutes: number, endMinutes: number, day: string | null = null, proclamationIds: string[] = ['p1'] ) => ({ day, startMinutes, endMinutes, proclamationIds }); describe('validateBlockShape', () => { it('rejects start == end', () => { expect(validateBlockShape(block(360, 360))).toEqual({ ok: false, error: "Start and end time can't be the same." }); }); it('rejects an empty block', () => { expect(validateBlockShape(block(360, 420, null, []))).toEqual({ ok: false, error: 'Select at least one proclamation.' }); }); it('accepts a single-minute block', () => { expect(validateBlockShape(block(360, 361))).toEqual({ ok: true }); }); }); describe('validateSchedule daily', () => { it('accepts adjacent blocks (touching boundaries)', () => { expect(validateSchedule('daily', [block(360, 420), block(420, 510)])).toEqual({ ok: true }); }); it('accepts a single-minute block', () => { expect(validateSchedule('daily', [block(360, 361)])).toEqual({ ok: true }); }); it('rejects overlapping blocks', () => { expect(validateSchedule('daily', [block(360, 480), block(420, 540)]).ok).toBe(false); }); it('rejects a midnight-crossing block that spills into an early block', () => { // 21:00→09:00 covers [0,540) on the next day; 08:00→08:30 overlaps. expect(validateSchedule('daily', [block(1260, 540, null), block(480, 510, null)]).ok).toBe( false ); }); it('accepts a crossing block and its exact complement', () => { expect(validateSchedule('daily', [block(1260, 540, null), block(540, 1260, null)])).toEqual({ ok: true }); }); }); describe('validateSchedule weekly', () => { it('applies rules per day', () => { expect(validateSchedule('weekly', [block(360, 420, 'mon'), block(360, 420, 'tue')])).toEqual({ ok: true }); }); it('rejects overlaps within the same day', () => { expect(validateSchedule('weekly', [block(360, 480, 'wed'), block(420, 540, 'wed')]).ok).toBe( false ); }); it('rejects Sunday midnight-crossing spilling into Monday', () => { expect(validateSchedule('weekly', [block(1380, 60, 'sun'), block(0, 120, 'mon')]).ok).toBe( false ); }); it('accepts a Sunday crossing block adjacent to a Monday block', () => { expect(validateSchedule('weekly', [block(1380, 60, 'sun'), block(60, 120, 'mon')])).toEqual({ ok: true }); }); it('rejects a Saturday crossing spilling into Sunday', () => { expect(validateSchedule('weekly', [block(1380, 60, 'sat'), block(30, 90, 'sun')]).ok).toBe( false ); }); }); describe('findScheduleOverlap', () => { it('returns null for gaps', () => { expect(findScheduleOverlap('daily', [block(360, 420), block(600, 660)])).toBeNull(); }); });