feat(schedule): add absolute-time reminders
This commit is contained in:
@@ -5,7 +5,9 @@ import {
|
||||
ScheduleInputError,
|
||||
ScheduleLogError,
|
||||
allocateScheduleId,
|
||||
canonicalizeTimeZone,
|
||||
createAfterScheduleRecord,
|
||||
createAtScheduleRecord,
|
||||
decodeScheduleChange,
|
||||
foldScheduleEvents,
|
||||
renderReminderFraming,
|
||||
@@ -24,16 +26,27 @@ function createData(id = 'schedule-1', prompt = 'check logs', scheduledAt = '202
|
||||
}
|
||||
}
|
||||
|
||||
function atCreateData(id = 'schedule-at', prompt = 'join meeting', scheduledAt = '2026-08-06T01:00:00.000Z') {
|
||||
return {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: { id, kind: 'at', prompt, scheduledAt },
|
||||
}
|
||||
}
|
||||
|
||||
describe('version-1 Schedule decoding and folding', () => {
|
||||
it('decodes and freezes each exact v1 operation', () => {
|
||||
const create = decodeScheduleChange(createData())
|
||||
const at = decodeScheduleChange(atCreateData())
|
||||
const remove = decodeScheduleChange({ version: 1, operation: 'delete', id: 'schedule-1' })
|
||||
const dispatch = decodeScheduleChange({ version: 1, operation: 'dispatch', id: 'schedule-1' })
|
||||
|
||||
expect(create).toEqual(createData())
|
||||
expect(at).toEqual(atCreateData())
|
||||
expect(remove).toEqual({ version: 1, operation: 'delete', id: 'schedule-1' })
|
||||
expect(dispatch).toEqual({ version: 1, operation: 'dispatch', id: 'schedule-1' })
|
||||
expect(Object.isFrozen(create)).toBe(true)
|
||||
expect(Object.isFrozen(at)).toBe(true)
|
||||
if (create.operation !== 'create') throw new Error('expected create')
|
||||
expect(Object.isFrozen(create.schedule)).toBe(true)
|
||||
})
|
||||
@@ -48,18 +61,22 @@ describe('version-1 Schedule decoding and folding', () => {
|
||||
{ ...createData(), extra: true },
|
||||
{ ...createData(), schedule: { ...createData().schedule, extra: true } },
|
||||
{ ...createData(), schedule: { ...createData().schedule, kind: 'at' } },
|
||||
{ ...atCreateData(), schedule: { ...atCreateData().schedule, extra: true } },
|
||||
{ ...atCreateData(), schedule: { ...atCreateData().schedule, prompt: ' ' } },
|
||||
{ ...createData(), schedule: { ...createData().schedule, prompt: ' ' } },
|
||||
{ ...createData(), schedule: { ...createData().schedule, afterSeconds: 0 } },
|
||||
{ ...createData(), schedule: { ...createData().schedule, afterSeconds: 1.5 } },
|
||||
{ ...createData(), schedule: { ...createData().schedule, scheduledAt: '2026-02-30T00:00:00.000Z' } },
|
||||
{ ...createData(), schedule: { ...createData().schedule, scheduledAt: '10000-01-01T00:00:00.000Z' } },
|
||||
{ ...createData(), schedule: null },
|
||||
{ ...atCreateData(), schedule: { ...atCreateData().schedule, kind: 'every' } },
|
||||
])('rejects malformed durable data %#', (data) => {
|
||||
expect(() => decodeScheduleChange(data)).toThrow(ScheduleLogError)
|
||||
})
|
||||
|
||||
it('folds active records in create order and rejects invalid transitions', () => {
|
||||
const first = scheduleEvent(createData('first'), 0)
|
||||
const second = scheduleEvent(createData('second'), 1)
|
||||
const second = scheduleEvent(atCreateData('second'), 1)
|
||||
const removed = scheduleEvent({ version: 1, operation: 'delete', id: 'first' }, 2)
|
||||
expect(foldScheduleEvents([first, second, removed])).toEqual({
|
||||
active: [expect.objectContaining({ id: 'second' })],
|
||||
@@ -144,3 +161,177 @@ describe('after record and model framing', () => {
|
||||
].join('\n'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('absolute record and time-zone resolution', () => {
|
||||
const now = Date.parse('2026-08-05T12:00:00.000Z')
|
||||
|
||||
it.each([
|
||||
['2026-08-06T09:00:00+08:00', '2026-08-06T01:00:00.000Z'],
|
||||
['2026-08-06T01:00:00Z', '2026-08-06T01:00:00.000Z'],
|
||||
['2026-08-06T01:00:00+00:00', '2026-08-06T01:00:00.000Z'],
|
||||
['2026-08-06T01:00:00.1Z', '2026-08-06T01:00:00.100Z'],
|
||||
['2026-08-06T01:00:00.12Z', '2026-08-06T01:00:00.120Z'],
|
||||
['2026-08-05T20:30:00-05:30', '2026-08-06T02:00:00.000Z'],
|
||||
])('normalizes strict offset input %s', (at, scheduledAt) => {
|
||||
expect(createAtScheduleRecord(ScheduleId('schedule-at'), ' join meeting ', at, now)).toEqual({
|
||||
id: 'schedule-at',
|
||||
kind: 'at',
|
||||
prompt: 'join meeting',
|
||||
scheduledAt,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'2026-08-06T01:00:00',
|
||||
'2026-08-06 01:00:00Z',
|
||||
'2026-02-30T01:00:00Z',
|
||||
'2026-08-06T24:00:00Z',
|
||||
'2026-08-06T01:00:60Z',
|
||||
'2026-08-06T01:00:00.1234Z',
|
||||
'2026-08-06T01:00:00-00:00',
|
||||
'2026-08-06T01:00:00+24:00',
|
||||
'2026-08-06T01:00:00+01:60',
|
||||
'0000-01-01T00:00:00Z',
|
||||
])('rejects invalid strict offset input %s', (at) => {
|
||||
expect(() => createAtScheduleRecord(ScheduleId('schedule-at'), 'x', at, now))
|
||||
.toThrow(ScheduleInputError)
|
||||
})
|
||||
|
||||
it('distinguishes non-future and out-of-range absolute targets', () => {
|
||||
for (const at of ['2026-08-05T12:00:00Z', '2026-08-05T11:59:59Z']) {
|
||||
try {
|
||||
createAtScheduleRecord(ScheduleId('schedule-at'), 'x', at, now)
|
||||
throw new Error('expected not-future failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('not_future')
|
||||
}
|
||||
}
|
||||
try {
|
||||
createAtScheduleRecord(
|
||||
ScheduleId('schedule-at'),
|
||||
'x',
|
||||
'9999-12-31T23:59:59.999-23:59',
|
||||
now,
|
||||
)
|
||||
throw new Error('expected range failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('time_out_of_range')
|
||||
}
|
||||
for (const [at, sampleNow] of [
|
||||
['0001-01-01T00:00:00+23:59', Date.parse('0001-01-01T00:00:00.000Z') - 1],
|
||||
['2026-08-06T01:00:00Z', Number.NaN],
|
||||
] as const) {
|
||||
try {
|
||||
createAtScheduleRecord(ScheduleId('schedule-at'), 'x', at, sampleNow)
|
||||
throw new Error('expected range failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('time_out_of_range')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('canonicalizes allowed IANA names and rejects abbreviations or offsets', () => {
|
||||
expect(canonicalizeTimeZone('UTC')).toBe('UTC')
|
||||
expect(canonicalizeTimeZone('America/New_York')).toBe('America/New_York')
|
||||
expect(canonicalizeTimeZone('US/Eastern')).toBe('America/New_York')
|
||||
for (const zone of ['', ' UTC', 'CST', 'PST', 'GMT', '+08:00', 'Not/A_Real_Zone']) {
|
||||
try {
|
||||
canonicalizeTimeZone(zone)
|
||||
throw new Error('expected zone failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('invalid_time_zone')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves local calendar time, rejects a gap, and chooses the first overlap instant', () => {
|
||||
expect(createAtScheduleRecord(ScheduleId('shanghai'), 'x', {
|
||||
date: '2026-08-06', time: '09:00:00', time_zone: 'Asia/Shanghai',
|
||||
}, now).scheduledAt).toBe('2026-08-06T01:00:00.000Z')
|
||||
expect(createAtScheduleRecord(ScheduleId('implicit'), 'x', {
|
||||
date: '2026-08-06', time: '09:00:00.25',
|
||||
}, now, 'Asia/Shanghai').scheduledAt).toBe('2026-08-06T01:00:00.250Z')
|
||||
expect(createAtScheduleRecord(ScheduleId('utc'), 'x', {
|
||||
date: '2026-08-06', time: '09:00:00', time_zone: 'UTC',
|
||||
}, now).scheduledAt).toBe('2026-08-06T09:00:00.000Z')
|
||||
expect(createAtScheduleRecord(ScheduleId('overlap'), 'x', {
|
||||
date: '2026-11-01', time: '01:30:00', time_zone: 'America/New_York',
|
||||
}, now).scheduledAt).toBe('2026-11-01T05:30:00.000Z')
|
||||
try {
|
||||
createAtScheduleRecord(ScheduleId('gap'), 'x', {
|
||||
date: '2026-03-08', time: '02:30:00', time_zone: 'America/New_York',
|
||||
}, Date.parse('2026-01-01T00:00:00.000Z'))
|
||||
throw new Error('expected gap failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('invalid_rule')
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ date: '2026-08-06', time: '09:00:00', time_zone: 'UTC', extra: true }],
|
||||
[{ date: 20260806, time: '09:00:00', time_zone: 'UTC' }],
|
||||
[{ date: '2026-08-06', time: '09:00:00', time_zone: 8 }],
|
||||
[{ date: '2026-02-30', time: '09:00:00', time_zone: 'UTC' }],
|
||||
[{ date: '2026-08-06', time: '24:00:00', time_zone: 'UTC' }],
|
||||
[{ date: '2026/08/06', time: '09:00:00', time_zone: 'UTC' }],
|
||||
[42],
|
||||
])('rejects malformed local selector %#', (at) => {
|
||||
expect(() => createAtScheduleRecord(
|
||||
ScheduleId('schedule-at'),
|
||||
'x',
|
||||
at as never,
|
||||
now,
|
||||
)).toThrow(ScheduleInputError)
|
||||
})
|
||||
|
||||
it('rejects empty at prompts and local instants outside the four-digit range', () => {
|
||||
expect(() => createAtScheduleRecord(
|
||||
ScheduleId('schedule-at'), ' ', '2026-08-06T01:00:00Z', now,
|
||||
)).toThrow(ScheduleInputError)
|
||||
try {
|
||||
createAtScheduleRecord(ScheduleId('schedule-at'), 'x', {
|
||||
date: '9999-12-31', time: '23:59:59.999', time_zone: 'America/New_York',
|
||||
}, now)
|
||||
throw new Error('expected local range failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('time_out_of_range')
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed when local calendar input has no confirmed zone', () => {
|
||||
try {
|
||||
createAtScheduleRecord(ScheduleId('schedule-at'), 'x', {
|
||||
date: '2026-08-06', time: '09:00:00',
|
||||
}, now)
|
||||
throw new Error('expected confirmation failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('timezone_confirmation_required')
|
||||
}
|
||||
})
|
||||
|
||||
it('derives an at view and reminder framing without persisting input interpretation', () => {
|
||||
const record = createAtScheduleRecord(
|
||||
ScheduleId('schedule-at'),
|
||||
'join meeting',
|
||||
'2026-08-06T09:00:00+08:00',
|
||||
now,
|
||||
)
|
||||
expect(scheduleView(record, now)).toEqual({
|
||||
...record,
|
||||
state: 'scheduled',
|
||||
deliveryMode: 'session-local',
|
||||
})
|
||||
expect(renderReminderFraming(record)).toContain('occurrence_at: 2026-08-06T01:00:00.000Z')
|
||||
expect(scheduleReminderPresentation([
|
||||
scheduleEvent(atCreateData(), 0),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'schedule-at' }, 1),
|
||||
], 1)).toMatchObject({ scheduleId: 'schedule-at', occurrenceAt: '2026-08-06T01:00:00.000Z' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user