/** * DC-168 r4: direct contract tests for calendar-cache.js (the extracted * cache semantics module) — merge, duplicate suppression, recurring * instances (uid+start), stale-entry removal on refetch of the same window, * and ended-appointment exclusion in chip selection semantics. */ 'use strict'; const { createCache } = require('../../../status/js/calendar-cache.js'); const RANGE_OCT = { start: new Date('2026-10-01T00:00:00Z'), end: new Date('2026-10-31T23:59:59Z') }; const RANGE_NOV = { start: new Date('2026-11-01T00:00:00Z'), end: new Date('2026-11-30T23:59:59Z') }; const ev = (title, start, extra) => Object.assign({ title, start }, extra || {}); describe('DC-168 r4: calendar-cache contract', () => { test('merge + duplicate suppression (same uid+start counted once)', () => { const c = createCache(); const a = { events: [ev('A', '2026-10-05T10:00:00Z', { uid: 'u1' })] }; const merged1 = c.merge(a, RANGE_OCT); expect(merged1.events).toHaveLength(1); // duplicate fetch of the same window: still one entry const merged2 = c.merge(a, RANGE_OCT); expect(merged2.events).toHaveLength(1); }); test('recurring instances share uid but differ by start — both kept', () => { const c = createCache(); const merged = c.merge({ events: [ ev('Weekly', '2026-10-05T10:00:00Z', { uid: 'recur' }), ev('Weekly', '2026-10-12T10:00:00Z', { uid: 'recur' }), ] }, RANGE_OCT); expect(merged.events).toHaveLength(2); }); test('refetching a window is authoritative: deletions and reschedules propagate', () => { const c = createCache(); c.merge({ events: [ ev('Keep', '2026-10-05T10:00:00Z'), ev('Gone', '2026-10-06T10:00:00Z'), ev('Moved', '2026-10-07T10:00:00Z'), ] }, RANGE_OCT); expect(c.merge({ events: [] }, RANGE_OCT).events).toHaveLength(0); // all inside window → server says none // reschedule: refetching the OLD window removes the stale occurrence // (server says it's no longer there)... const afterOld = c.merge({ events: [] }, RANGE_OCT); expect(afterOld.events).toHaveLength(0); // ...then fetching the NEW window adds the new occurrence: exactly 1 const merged = c.merge({ events: [ev('Moved', '2026-11-07T10:00:00Z')] }, RANGE_NOV); expect(merged.events).toHaveLength(1); expect(merged.events[0].start).toBe('2026-11-07T10:00:00Z'); }); test('entries OUTSIDE the refetched window survive (other-month data kept)', () => { const c = createCache(); c.merge({ events: [ev('Nov thing', '2026-11-03T09:00:00Z')] }, RANGE_NOV); c.merge({ events: [ev('Oct thing', '2026-10-14T09:00:00Z')] }, RANGE_OCT); const merged = c.merge({ events: [ev('Oct thing', '2026-10-14T09:00:00Z')] }, RANGE_OCT); expect(merged.events).toHaveLength(2); }); test('chip semantics: ended appointments excluded, ongoing included (mirrors renderWidget filter)', () => { const now = Date.now(); const mk = (mins) => new Date(now + mins * 60000).toISOString(); const events = [ { title: 'past', start: mk(-90), end: mk(-30) }, // ended → out { title: 'ongoing', start: mk(-30), end: mk(30) }, // in progress → in { title: 'future', start: mk(120), end: mk(180) }, // in ]; const upcoming = events .filter(e => { const endT = e.end ? new Date(e.end).getTime() : new Date(e.start).getTime(); return endT >= now; }) .sort((a, b) => new Date(a.start) - new Date(b.start)); expect(upcoming.map(e => e.title)).toEqual(['ongoing', 'future']); // no end field: start is the qualifier const noEnd = [{ title: 'started-long-ago', start: mk(-600) }]; expect(noEnd.filter(e => new Date(e.start).getTime() >= now)).toHaveLength(0); }); });