145 lines
4.2 KiB
TypeScript
145 lines
4.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
findOverlap,
|
||
formatTime,
|
||
snapMinutes,
|
||
toHm,
|
||
validateBlock,
|
||
type BlockInput,
|
||
type BlockSibling
|
||
} from './schedule-utils';
|
||
|
||
const input = (overrides: Partial<BlockInput> = {}): 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);
|
||
});
|
||
});
|