screentinker/server/test/schedule-calendar-expansion.test.js
screentinker 0511e9b5bb
Let a locale ship without every string translated (#286)
Follow-up to #285. Three corrections that rode in with the Japanese locale.

1. The ja key-parity check failed the build whenever en.js had a key ja.js
   lacked. i18n.js lookup() is already
   `registry[lang]?.[key] ?? fallback[key] ?? key`, so an untranslated string
   renders in English and nothing is broken by a gap - the only effect was
   that adding any English string blocked CI until a Japanese translation
   existed. It also singled out one locale; es/fr/de/pt/hi/it were never held
   to it, and hi.js is a deliberate skeleton whose own header explains that
   every key falls back to English on purpose.

   Replaced with two checks over EVERY locale: a locale may not define a key
   that English does not (dead weight after a rename, and fixable by whoever
   touched the file, whatever language they speak), and coverage is printed
   rather than gated. Help tips still have to exist everywhere - that test is
   unchanged and still fails.

   Current coverage: ja 100%, es 65.5%, fr/de/pt 63.7%, it 59.5%, hi 0%.

2. Applying the strict half to all locales immediately found
   add_display.smart_tv_note living in fr, pt, it and de but not in en.js and
   referenced by no view - a string dropped from English that left four
   translations behind. Removed.

3. The new timezone test restored process.env.TZ by assigning the saved value
   back. When TZ was not set to begin with - which is the case in CI - that
   assigns undefined, which writes the STRING "undefined"; Node cannot parse
   it and silently falls back to UTC for the rest of the process. Every test
   after it in that file is date arithmetic. It now deletes the key when it
   was previously unset.

4. package-lock.json removed from .gitignore. server/package-lock.json is
   tracked, so the rule was inert, but it would silently prevent a future
   lockfile and works against the SBOM and reproducible-install setup added
   in #282.


Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56

Co-authored-by: Dan Walters <dan.walters@bytetinker.net>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:37:56 -05:00

114 lines
5.2 KiB
JavaScript

'use strict';
// The calendar is the operator's only view of what is scheduled, and it disagreed with the engine
// in both directions for the two most-used repeat presets.
//
// The old expansion stepped by the recurrence unit from the schedule's original start:
// - WEEKLY advanced a whole week at a time, so dayOfWeek never changed and a
// FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR rule matched only its start day. Created on a Monday it drew
// one event a week; created on a Saturday it drew nothing at all.
// - The walk began at the original start under a 366-iteration cap, so a schedule begun more than
// a year ago never reached the current week and drew nothing.
// Meanwhile the engine evaluates day-of-week directly, so those schedules ran Mon-Fri the whole
// time. Screens were switching content that the calendar said was not scheduled.
//
// The invariant: the calendar draws an event on every day the schedule actually fires.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-cal-'));
process.env.DATA_DIR = tmp;
process.env.JWT_SECRET = 'test-secret-calendar';
const { expandSchedule, parseCalendarDate } = require('../routes/schedules');
// A Monday-to-Sunday window well clear of the schedules' start dates.
const WEEK_START = new Date('2026-08-03T00:00:00'); // Monday
const WEEK_END = new Date('2026-08-09T23:59:59'); // Sunday
const mk = (recurrence, startISO, recurrenceEnd = null) => ({
id: 's', recurrence, recurrence_end: recurrenceEnd,
start_time: startISO,
end_time: new Date(new Date(startISO).getTime() + 60 * 60 * 1000).toISOString(),
});
const weekdaysOf = (events) => events.map(e => new Date(e.instance_start).getDay()).sort();
test('a date-only week anchor retains its calendar day west of UTC', () => {
// ⚠️ Record WHETHER it was set, not just its value. `process.env.TZ = undefined` writes the
// STRING "undefined", which Node cannot parse and silently resolves to UTC - changing the zone
// for every test that runs after this one in this file, all of which are date arithmetic.
const hadTz = Object.prototype.hasOwnProperty.call(process.env, 'TZ');
const originalTz = process.env.TZ;
process.env.TZ = 'America/Los_Angeles';
try {
// new Date('2026-08-09') is UTC midnight, so it is still Saturday on a US server.
// The calendar date parser must retain the Sunday the browser selected.
assert.equal(new Date('2026-08-09').getDay(), 6, 'the legacy parser sees Saturday');
const selected = parseCalendarDate('2026-08-09');
assert.equal(selected.getFullYear(), 2026);
assert.equal(selected.getMonth(), 7);
assert.equal(selected.getDate(), 9);
assert.equal(selected.getDay(), 0, 'Sunday remains Sunday');
} finally {
if (hadTz) process.env.TZ = originalTz;
else delete process.env.TZ;
}
});
test('THE BUG: a Mon-Fri rule draws five events, not one', () => {
const ev = expandSchedule(mk('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', '2026-07-27T09:00:00'), WEEK_START, WEEK_END);
assert.equal(ev.length, 5);
assert.deepEqual(weekdaysOf(ev), [1, 2, 3, 4, 5], 'Mon..Fri');
});
test('...and it does not matter which day the rule was created on', () => {
// Created on a Saturday, the old code drew nothing whatsoever.
const ev = expandSchedule(mk('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', '2026-08-01T09:00:00'), WEEK_START, WEEK_END);
assert.equal(ev.length, 5);
assert.deepEqual(weekdaysOf(ev), [1, 2, 3, 4, 5]);
});
test('a DAILY schedule older than a year still draws', () => {
// The 366-iteration cap meant the walk never reached the visible window.
const ev = expandSchedule(mk('FREQ=DAILY', '2024-05-01T09:00:00'), WEEK_START, WEEK_END);
assert.equal(ev.length, 7, 'every day of the week');
});
test('WEEKLY without byDay still means "the same weekday as the start"', () => {
const ev = expandSchedule(mk('FREQ=WEEKLY', '2026-07-27T09:00:00'), WEEK_START, WEEK_END); // a Monday
assert.equal(ev.length, 1);
assert.deepEqual(weekdaysOf(ev), [1]);
});
test('a DAILY interval is honoured rather than drawn every day', () => {
const ev = expandSchedule(mk('FREQ=DAILY;INTERVAL=2', '2026-08-03T09:00:00'), WEEK_START, WEEK_END);
assert.equal(ev.length, 4, 'Mon, Wed, Fri, Sun');
});
test('recurrence_end stops the drawing', () => {
const ev = expandSchedule(
mk('FREQ=DAILY', '2026-07-27T09:00:00', '2026-08-05T23:59:59'), WEEK_START, WEEK_END);
assert.equal(ev.length, 3, 'Mon, Tue, Wed then it ends');
});
test('a one-off schedule is unaffected', () => {
const ev = expandSchedule(
{ id: 's', recurrence: null, start_time: '2026-08-05T09:00:00', end_time: '2026-08-05T10:00:00' },
WEEK_START, WEEK_END);
assert.equal(ev.length, 1);
});
test('each drawn event keeps the schedule duration', () => {
const ev = expandSchedule(mk('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', '2026-07-27T09:00:00'), WEEK_START, WEEK_END);
for (const e of ev) {
const mins = (new Date(e.instance_end) - new Date(e.instance_start)) / 60000;
assert.equal(mins, 60);
}
});
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });