Files
dashcaddy/dashcaddy-api/__tests__/calendar/dc168-cache-contract.test.js
T
Hermes e4b5372b6d [pending-judge] feat(DC-168 r4): calendar widget — month-following fetch, window-authoritative event cache, universal theme contrast
Owner directives: "show no upcoming appointments" when empty; "days and times
highlighted" per appointment; "properly stylize ... to fit with each theme
correctly"; "relatively small widget basically about the size of the weather
widget".

r3 judge blockers fixed:
- Cache semantics extracted to status/js/calendar-cache.js (bundled ahead of
  the widget; module.exports for tests, window.CalendarCache for browser):
  entries keyed by uid+start (recurring instances kept distinct); a FETCHED
  WINDOW IS AUTHORITATIVE — on refetch, entries starting inside that window
  are replaced/purged (deletions + reschedules propagate), entries outside
  survive. 5 direct contract tests cover merge, duplicate suppression,
  recurring instances, stale removal on refetch, cross-window survival.
- Chip filters by CURRENT INSTANT against event END (ongoing appointments
  qualify; started-long-ago with no end excluded). Covered by contract test.
- Contract suite (4 cache tests) + ns suite (9) + widget-contract (4) = 18
  calendar tests; smoke harness DC168-SMOKE-OK; eslint clean.
r1/r2 items carried: universal light-bg contrast (objective WCAG >= 4.5:1 for
all 10 builtin themes, worst 4.60 ocean), month-following fetch with refetch
on navigation, weather-widget-sized chip, month grid with highlighted days.
2026-10-12 00:14:56 -07:00

81 lines
3.7 KiB
JavaScript

/**
* 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);
});
});