import { describe, it, expect } from 'vitest'; import { findOverlap, formatTime, snapMinutes, toHm, validateBlock, 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('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('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); }); }); 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 time must be before end time. Overnight blocks aren't supported." }); }); it('rejects an end time past midnight (overnight blocks disallowed)', () => { const result = validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), []); expect(result.ok).toBe(false); }); it('rejects a negative start time', () => { expect(validateBlock(input({ startMinutes: -1 }), []).ok).toBe(false); }); 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); }); });