Add schedule validation and service layer
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user