diff --git a/tests/js/unit/dateRanges.test.ts b/tests/js/unit/dateRanges.test.ts index c6768f0..d7272e3 100644 --- a/tests/js/unit/dateRanges.test.ts +++ b/tests/js/unit/dateRanges.test.ts @@ -4,7 +4,7 @@ import { monthGridVisibleRange, shiftLocalDays, shiftLocalMonths, -} from '../../../src/utils/dateRanges' +} from '../../../src/utils/date' describe('calendar visible ranges', () => { it('uses a half-open range for a day span', () => { diff --git a/tests/js/unit/entityInstances.test.ts b/tests/js/unit/entityInstances.test.ts new file mode 100644 index 0000000..ed002b7 --- /dev/null +++ b/tests/js/unit/entityInstances.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import type { EntityObject } from '../../../../chrono_manager/src/models/entity' +import { daysVisibleRange } from '../../../src/utils/date' +import { expandEntityInstances } from '../../../src/utils/recurrence' + +function localIso(year: number, month: number, day: number, hour = 0): string { + return new Date(year, month, day, hour).toISOString() +} + +function eventEntity( + identifier: string, + startsOn: string | null, + endsOn: string | null, + timeless = false, +): EntityObject { + return { + identifier, + properties: { + startsOn, + endsOn, + timeless, + }, + } as unknown as EntityObject +} + +describe('entity instance expansion', () => { + it('normalizes timestamps and duration once', () => { + const [instance] = expandEntityInstances( + eventEntity('meeting', localIso(2026, 5, 15, 9), localIso(2026, 5, 15, 10)), + ) + + expect(instance.durationMs).toBe(60 * 60 * 1000) + expect(new Date(instance.startMs).getHours()).toBe(9) + expect(new Date(instance.dayStartMs).getHours()).toBe(0) + expect(instance.instanceId).toBeNull() + expect(instance.multiDay).toBe(false) + }) + + it('produces no instance for an invalid start timestamp', () => { + expect(expandEntityInstances(eventEntity('invalid', 'not-a-date', null))).toEqual([]) + }) + + it('marks an overnight event as multi-day', () => { + const [instance] = expandEntityInstances( + eventEntity('overnight', localIso(2026, 5, 15, 23), localIso(2026, 5, 16, 1)), + ) + + expect(instance.multiDay).toBe(true) + expect(instance.timeless).toBe(true) + }) + + it('honors an exclusive midnight end', () => { + const [instance] = expandEntityInstances( + eventEntity('all-day', localIso(2026, 5, 20), localIso(2026, 5, 21), true), + ) + + const end = new Date(instance.endMs) + expect([end.getDate(), end.getHours()]).toEqual([21, 0]) + expect(new Date(instance.dayStartMs).getDate()).toBe(20) + expect(instance.multiDay).toBe(false) + }) + + it('treats UTC timestamps on timeless events as floating calendar dates', () => { + const [instance] = expandEntityInstances( + eventEntity('utc-all-day', '2025-03-27T00:00:00+00:00', '2025-03-28T00:00:00+00:00', true), + ) + + const start = new Date(instance.startMs) + expect([start.getMonth(), start.getDate(), start.getHours()]).toEqual([2, 27, 0]) + expect(new Date(instance.endMs).getDate()).toBe(28) + }) + + it('gives a zero-duration event a queryable minimum duration', () => { + const [instance] = expandEntityInstances( + eventEntity('instant', localIso(2026, 5, 15, 9), null), + ) + + expect(instance.durationMs).toBe(1) + }) + + it('omits a non-recurring event outside the requested range', () => { + const instances = expandEntityInstances( + eventEntity('outside', localIso(2026, 6, 1, 9), localIso(2026, 6, 1, 10)), + daysVisibleRange(new Date(2026, 5, 15), 2), + ) + + expect(instances).toEqual([]) + }) +}) diff --git a/tests/js/unit/occurrenceIndex.test.ts b/tests/js/unit/occurrenceIndex.test.ts deleted file mode 100644 index 4dd7c75..0000000 --- a/tests/js/unit/occurrenceIndex.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { EntityObject } from '../../../../chrono_manager/src/models/entity' -import { - buildOccurrenceIndex, - occurrencesForDay, - occurrencesForRange, -} from '../../../src/utils/occurrenceIndex' -import { daysVisibleRange } from '../../../src/utils/dateRanges' - -function localIso(year: number, month: number, day: number, hour = 0): string { - return new Date(year, month, day, hour).toISOString() -} - -function eventEntity( - identifier: string, - startsOn: string | null, - endsOn: string | null, - timeless = false, -): EntityObject { - return { - identifier, - properties: { - startsOn, - endsOn, - timeless, - }, - } as unknown as EntityObject -} - -describe('occurrence index', () => { - it('normalizes and sorts event timestamps once', () => { - const later = eventEntity('later', localIso(2026, 5, 15, 13), localIso(2026, 5, 15, 14)) - const earlier = eventEntity('earlier', localIso(2026, 5, 15, 9), localIso(2026, 5, 15, 10)) - const invalid = eventEntity('invalid', 'not-a-date', null) - const index = buildOccurrenceIndex([later, invalid, earlier]) - - expect(index.sorted.map(occurrence => occurrence.entity.identifier)).toEqual(['earlier', 'later']) - expect(index.byEntity.get('earlier')?.[0]?.durationMs).toBe(60 * 60 * 1000) - expect(index.byEntity.has('invalid')).toBe(false) - }) - - it('places an overlapping event in each occupied day bucket', () => { - const overnight = eventEntity( - 'overnight', - localIso(2026, 5, 15, 23), - localIso(2026, 5, 16, 1), - ) - const index = buildOccurrenceIndex([overnight]) - - expect(occurrencesForDay(index, new Date(2026, 5, 15))).toHaveLength(1) - expect(occurrencesForDay(index, new Date(2026, 5, 16))).toHaveLength(1) - expect(index.sorted[0].multiDay).toBe(true) - expect(index.sorted[0].timeless).toBe(true) - }) - - it('honors an exclusive midnight end', () => { - const allDay = eventEntity( - 'all-day', - localIso(2026, 5, 20), - localIso(2026, 5, 21), - true, - ) - const index = buildOccurrenceIndex([allDay]) - - expect(occurrencesForDay(index, new Date(2026, 5, 20))).toHaveLength(1) - expect(occurrencesForDay(index, new Date(2026, 5, 21))).toHaveLength(0) - }) - - it('treats UTC timestamps on timeless events as floating calendar dates', () => { - const allDay = eventEntity( - 'utc-all-day', - '2025-03-27T00:00:00+00:00', - '2025-03-28T00:00:00+00:00', - true, - ) - const index = buildOccurrenceIndex([allDay]) - - expect(occurrencesForDay(index, new Date(2025, 2, 26))).toHaveLength(0) - expect(occurrencesForDay(index, new Date(2025, 2, 27))).toHaveLength(1) - expect(occurrencesForDay(index, new Date(2025, 2, 28))).toHaveLength(0) - expect(new Date(index.sorted[0].startMs).getHours()).toBe(0) - }) - - it('retrieves overlapping occurrences without duplicates', () => { - const spanning = eventEntity( - 'spanning', - localIso(2026, 5, 14), - localIso(2026, 5, 18), - true, - ) - const outside = eventEntity('outside', localIso(2026, 6, 1), localIso(2026, 6, 1, 1)) - const index = buildOccurrenceIndex([outside, spanning]) - const occurrences = occurrencesForRange(index, daysVisibleRange(new Date(2026, 5, 15), 2)) - - expect(occurrences.map(occurrence => occurrence.entity.identifier)).toEqual(['spanning']) - }) - - it('gives a zero-duration event a queryable minimum duration', () => { - const instant = eventEntity('instant', localIso(2026, 5, 15, 9), null) - const index = buildOccurrenceIndex([instant]) - - expect(index.sorted[0].durationMs).toBe(1) - expect(occurrencesForDay(index, new Date(2026, 5, 15))).toHaveLength(1) - }) -}) diff --git a/tests/js/unit/recurrence.test.ts b/tests/js/unit/recurrence.test.ts index c870ceb..67c1b16 100644 --- a/tests/js/unit/recurrence.test.ts +++ b/tests/js/unit/recurrence.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import type { EntityObject } from '../../../../chrono_manager/src/models/entity' import type { EventOccurrence, EventMutation } from '../../../../chrono_manager/src/types/event' -import { daysVisibleRange } from '../../../src/utils/dateRanges' -import { expandEntityOccurrences } from '../../../src/utils/recurrence' +import { daysVisibleRange } from '../../../src/utils/date' +import { expandEntityInstances } from '../../../src/utils/recurrence' function localIso(year: number, month: number, day: number, hour = 9): string { return new Date(year, month, day, hour).toISOString() @@ -34,14 +34,14 @@ describe('daily recurrence', () => { precision: 'daily', interval: 2, }) - const occurrences = expandEntityOccurrences( + const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 5, 4), 6), ) - expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([5, 7, 9]) - expect(occurrences.every(occurrence => occurrence.durationMs === 60 * 60 * 1000)).toBe(true) - expect(new Set(occurrences.map(occurrence => occurrence.key)).size).toBe(3) + expect(instances.map(instance => new Date(instance.startMs).getDate())).toEqual([5, 7, 9]) + expect(instances.every(instance => instance.durationMs === 60 * 60 * 1000)).toBe(true) + expect(new Set(instances.map(instance => instance.key)).size).toBe(3) }) it('honors iteration limits before the requested range', () => { @@ -51,12 +51,12 @@ describe('daily recurrence', () => { interval: 1, iterations: 3, }) - const occurrences = expandEntityOccurrences( + const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 5, 10), 3), ) - expect(occurrences).toEqual([]) + expect(instances).toEqual([]) }) it('treats a date-only conclusion as an inclusive local date', () => { @@ -66,12 +66,12 @@ describe('daily recurrence', () => { interval: 1, concludes: '2026-06-03', }) - const occurrences = expandEntityOccurrences( + const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 5, 1), 5), ) - expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([1, 2, 3]) + expect(instances.map(instance => new Date(instance.startMs).getDate())).toEqual([1, 2, 3]) }) it('preserves local wall-clock time across daylight-saving changes', () => { @@ -80,12 +80,12 @@ describe('daily recurrence', () => { precision: 'daily', interval: 1, }, {}, localIso(2026, 2, 7, 9)) - const occurrences = expandEntityOccurrences( + const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 2, 7), 3), ) - expect(occurrences.map(occurrence => new Date(occurrence.startMs).getHours())).toEqual([9, 9, 9]) + expect(instances.map(instance => new Date(instance.startMs).getHours())).toEqual([9, 9, 9]) }) it('preserves exclusive calendar-day duration for recurring timeless events', () => { @@ -101,15 +101,15 @@ describe('daily recurrence', () => { event.endsOn = '2026-03-08T00:00:00+00:00' event.timeless = true - const occurrences = expandEntityOccurrences( + const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 2, 7), 3), ) - expect(occurrences).toHaveLength(3) - expect(occurrences.map(occurrence => new Date(occurrence.startMs).getHours())).toEqual([0, 0, 0]) - expect(occurrences.map(occurrence => { - const end = new Date(occurrence.endMs) + expect(instances).toHaveLength(3) + expect(instances.map(instance => new Date(instance.startMs).getHours())).toEqual([0, 0, 0]) + expect(instances.map(instance => { + const end = new Date(instance.endMs) return [end.getDate(), end.getHours()] })).toEqual([[8, 0], [9, 0], [10, 0]]) }) @@ -123,12 +123,12 @@ describe('weekly recurrence', () => { interval: 1, onDayOfWeek: [1, 3], }) - const occurrences = expandEntityOccurrences( + const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 5, 1), 10), ) - expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([1, 3, 8, 10]) + expect(instances.map(instance => new Date(instance.startMs).getDate())).toEqual([1, 3, 8, 10]) }) }) @@ -145,12 +145,12 @@ describe('recurrence mutations', () => { mutationExclusion: true, }, }) - const occurrences = expandEntityOccurrences( + const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 5, 1), 3), ) - expect(occurrences.map(occurrence => new Date(occurrence.startMs).getDate())).toEqual([1, 3]) + expect(instances.map(instance => new Date(instance.startMs).getDate())).toEqual([1, 3]) }) it('moves and restyles a modified occurrence without duplicating it', () => { @@ -171,13 +171,13 @@ describe('recurrence mutations', () => { color: '#abcdef', }, }) - const occurrences = expandEntityOccurrences( + const instances = expandEntityInstances( entity, daysVisibleRange(new Date(2026, 5, 1), 5), ) - const moved = occurrences.find(occurrence => occurrence.occurrenceId === sourceId) + const moved = instances.find(instance => instance.instanceId === sourceId) - expect(occurrences).toHaveLength(2) + expect(instances).toHaveLength(2) expect(new Date(moved!.startMs).getHours()).toBe(14) expect(moved!.durationMs).toBe(2 * 60 * 60 * 1000) expect(moved!.label).toBe('Moved standup')