The four closed recurrence shapes computed in the definition's own IANA zone, a fourteen-day materialisation horizon with projections beyond it, series as a managed thing, and the admin calendar that replaces the plugin this feature exists to replace. An event now happens on its own. No schema change: Phase 1 built every column this needed. - events/recurrence.js is the ONE place an occurrence is computed, so the runner's expansion and the calendar's forecast cannot disagree. No date library added — Node ships the tzdata one would vendor, behind Intl. - The runner's materialise leg is now two halves: expand, then sweep. The window starts at `now - grace`, so an occurrence nobody could have seen is never invented retroactively; the horizon is what makes the missed sweep mean anything for a recurrence. - Publishing is the schedule switch and archiving turns it off, and publishing re-pins every occurrence that has not started. - A projection is never drawn over an instant a run occupies, so a cancelled occurrence does not reappear as a forecast. 54 new tests, incl. the DST fixture set the plan asked for and three new statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail (pre-existing CRLF). Walked end to end on the local review stack. Docs: RunicGateway/docs#PENDING Co-Authored-By: Claude <noreply@anthropic.com>
274 lines
13 KiB
JavaScript
274 lines
13 KiB
JavaScript
// ── Occurrence arithmetic (EVENTS.md §E, Phase 4) ──────────────────────────
|
|
//
|
|
// The plan asks for DST-crossing cases as EXPLICIT FIXTURES, and this file is
|
|
// them. The reason it is worth a test file of its own is that every failure here
|
|
// is silent in production: an event computed an hour off, or dropped for one
|
|
// week a year, looks exactly like an event that happened correctly until an
|
|
// operator is standing in the wrong place at the wrong time.
|
|
//
|
|
// The zone data is Node's own tzdata behind `Intl`, so these fixtures assert
|
|
// against the real transitions rather than against a hand-written offset table:
|
|
//
|
|
// • Europe/Berlin, 2026-03-29 — CET (+1) to CEST (+2). 02:00 to 03:00 does not
|
|
// exist. A weekly 02:30 event is the case the org lead decided.
|
|
// • Europe/Berlin, 2026-10-25 — CEST (+2) back to CET (+1). 02:00 to 03:00
|
|
// happens twice.
|
|
// • Asia/Kolkata — +05:30, no DST at all, and a half-hour offset, which is
|
|
// what catches an implementation that assumed whole hours.
|
|
// • Australia/Lord_Howe — a THIRTY-MINUTE DST shift, which is what catches one
|
|
// that assumed the gap is always an hour.
|
|
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const { test, after } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const r = require('../src/events/recurrence')
|
|
const db = require('../src/utils/db')
|
|
|
|
after(() => db.close())
|
|
|
|
/** What an instant reads as on the wall in a zone — the assertion that matters. */
|
|
const wall = (zone, at) => {
|
|
const p = r.wallPartsAt(zone, at instanceof Date ? at.getTime() : at)
|
|
const pad = (n) => String(n).padStart(2, '0')
|
|
return `${p.y}-${pad(p.m)}-${pad(p.d)} ${pad(p.h)}:${pad(p.mi)}`
|
|
}
|
|
|
|
const walls = (occurrences, zone) => occurrences.map((o) => wall(zone, o.at))
|
|
|
|
// ── The rule the whole feature rests on ────────────────────────────────────
|
|
|
|
test('a weekly event keeps its LOCAL time across a DST boundary', () => {
|
|
// The single most important assertion in this file. Friday 20:00 in Berlin is
|
|
// 19:00 UTC in winter and 18:00 UTC in summer, and it is 20:00 on the wall on
|
|
// every one of those Fridays. A recurrence computed in UTC would put half the
|
|
// year an hour out, which is exactly what §E forbids.
|
|
const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
|
|
const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11))
|
|
|
|
assert.deepEqual(walls(found, 'Europe/Berlin'), [
|
|
'2026-03-20 20:00',
|
|
'2026-03-27 20:00',
|
|
'2026-04-03 20:00',
|
|
'2026-04-10 20:00',
|
|
])
|
|
// And the UTC instants really did move, which is what proves the zone was
|
|
// consulted rather than the arithmetic accidentally agreeing.
|
|
assert.equal(found[1].at.toISOString(), '2026-03-27T19:00:00.000Z')
|
|
assert.equal(found[2].at.toISOString(), '2026-04-03T18:00:00.000Z')
|
|
})
|
|
|
|
test('a weekly event keeps its local time across the October transition too', () => {
|
|
const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
|
|
const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 20), Date.UTC(2026, 10, 7))
|
|
assert.deepEqual(walls(found, 'Europe/Berlin'), [
|
|
'2026-10-23 20:00',
|
|
'2026-10-30 20:00',
|
|
'2026-11-06 20:00',
|
|
])
|
|
assert.equal(found[0].at.toISOString(), '2026-10-23T18:00:00.000Z')
|
|
assert.equal(found[1].at.toISOString(), '2026-10-30T19:00:00.000Z')
|
|
})
|
|
|
|
// ── The two DST rules, as decided ──────────────────────────────────────────
|
|
|
|
test('a local time the spring gap swallows moves FORWARD to the first one that exists', () => {
|
|
// 2026-03-29 in Berlin: 02:00 becomes 03:00 and 02:30 never happens. The
|
|
// decision is the first valid instant — 03:00 — rather than "shift by the gap"
|
|
// (03:30): the event happens as close to the authored time as the calendar
|
|
// allows.
|
|
const resolved = r.resolveWall('Europe/Berlin', 2026, 3, 29, 2, 30)
|
|
assert.equal(resolved.adjusted, 'gap')
|
|
assert.equal(wall('Europe/Berlin', resolved.at), '2026-03-29 03:00')
|
|
assert.equal(resolved.at.toISOString(), '2026-03-29T01:00:00.000Z')
|
|
assert.equal(resolved.shiftMinutes, 30)
|
|
})
|
|
|
|
test('a local time that happens twice takes the FIRST of them', () => {
|
|
// 2026-10-25 in Berlin: 02:30 comes round at 00:30Z (+2, still CEST) and again
|
|
// at 01:30Z (+1, now CET). The first is the answer, and the second must not be
|
|
// — an event that fired at the later one would be an hour late by the clock
|
|
// the author wrote it against.
|
|
const resolved = r.resolveWall('Europe/Berlin', 2026, 10, 25, 2, 30)
|
|
assert.equal(resolved.adjusted, 'ambiguous')
|
|
assert.equal(resolved.at.toISOString(), '2026-10-25T00:30:00.000Z')
|
|
assert.equal(wall('Europe/Berlin', resolved.at), '2026-10-25 02:30')
|
|
|
|
// Both instants really do read 02:30 — the fixture is only meaningful if the
|
|
// ambiguity is real.
|
|
const both = r.instantsForWall('Europe/Berlin', Date.UTC(2026, 9, 25, 2, 30))
|
|
assert.equal(both.length, 2)
|
|
assert.equal(both[0], Date.UTC(2026, 9, 25, 0, 30))
|
|
assert.equal(both[1], Date.UTC(2026, 9, 25, 1, 30))
|
|
})
|
|
|
|
test('a weekly event in the gap still happens that week — it is never dropped', () => {
|
|
// The rule that makes the gap decision worth having. A Sunday 02:30 event in
|
|
// Berlin happens on 29 March like every other Sunday; it simply happens at
|
|
// 03:00.
|
|
const schedule = { kind: 'weekly', days: ['sunday'], time: '02:30' }
|
|
const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 6))
|
|
assert.deepEqual(walls(found, 'Europe/Berlin'), [
|
|
'2026-03-22 02:30',
|
|
'2026-03-29 03:00',
|
|
'2026-04-05 02:30',
|
|
])
|
|
assert.equal(found[1].adjusted, 'gap')
|
|
assert.equal(found[0].adjusted, null)
|
|
})
|
|
|
|
test('a thirty-minute DST shift resolves too — the gap is not always an hour', () => {
|
|
// Lord Howe Island shifts by 30 minutes (+10:30 to +11:00). 2026-10-04 has no
|
|
// 02:15 local. An implementation that assumed a whole-hour gap gets this wrong.
|
|
const resolved = r.resolveWall('Australia/Lord_Howe', 2026, 10, 4, 2, 15)
|
|
assert.equal(resolved.adjusted, 'gap')
|
|
assert.equal(wall('Australia/Lord_Howe', resolved.at), '2026-10-04 02:30')
|
|
})
|
|
|
|
test('a zone with no DST at all is left completely alone', () => {
|
|
// Asia/Kolkata is +05:30 all year, and the half hour is the point: an
|
|
// implementation carrying whole-hour offsets around would be 30 minutes out
|
|
// here, every day, without any transition to blame.
|
|
const schedule = { kind: 'weekly', days: ['friday'], time: '19:30' }
|
|
const found = r.occurrencesBetween(schedule, 'Asia/Kolkata', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11))
|
|
assert.equal(found.length, 4)
|
|
for (const o of found) {
|
|
assert.equal(o.adjusted, null)
|
|
assert.equal(wall('Asia/Kolkata', o.at).slice(11), '19:30')
|
|
assert.equal(o.at.toISOString().slice(11, 16), '14:00')
|
|
}
|
|
})
|
|
|
|
// ── The shapes ─────────────────────────────────────────────────────────────
|
|
|
|
test('`once` produces its single occurrence, and only inside the window', () => {
|
|
const schedule = { kind: 'once', at: '2026-10-31T20:00' }
|
|
const inside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 1), Date.UTC(2026, 10, 1))
|
|
assert.equal(inside.length, 1)
|
|
assert.equal(wall('Europe/Berlin', inside[0].at), '2026-10-31 20:00')
|
|
|
|
const outside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 10, 1), Date.UTC(2026, 11, 1))
|
|
assert.deepEqual(outside, [])
|
|
})
|
|
|
|
test('`weekly` honours every named day, in week order', () => {
|
|
const schedule = { kind: 'weekly', days: ['saturday', 'wednesday'], time: '18:00' }
|
|
const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 15))
|
|
assert.deepEqual(walls(found, 'UTC'), [
|
|
'2026-06-03 18:00',
|
|
'2026-06-06 18:00',
|
|
'2026-06-10 18:00',
|
|
'2026-06-13 18:00',
|
|
])
|
|
})
|
|
|
|
test('`monthly` with nth: -1 is the LAST weekday, which is not always the fourth', () => {
|
|
// The whole reason -1 exists. May 2026 has five Fridays and July 2026 has five;
|
|
// in those months "last" and "fourth" are different days, and a fishing contest
|
|
// on the last Friday is exactly that shape.
|
|
const last = r.occurrencesBetween(
|
|
{ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:00' },
|
|
'UTC',
|
|
Date.UTC(2026, 4, 1),
|
|
Date.UTC(2026, 8, 1),
|
|
)
|
|
const fourth = r.occurrencesBetween(
|
|
{ kind: 'monthly', nth: 4, weekday: 'friday', time: '19:00' },
|
|
'UTC',
|
|
Date.UTC(2026, 4, 1),
|
|
Date.UTC(2026, 8, 1),
|
|
)
|
|
// August 2026 has four Fridays, so "last" and "fourth" agree there and
|
|
// disagree in May and July. That the two lists share a member is the point:
|
|
// -1 is not a synonym for 4, and it is not a synonym for "different" either.
|
|
assert.deepEqual(walls(last, 'UTC'), [
|
|
'2026-05-29 19:00',
|
|
'2026-06-26 19:00',
|
|
'2026-07-31 19:00',
|
|
'2026-08-28 19:00',
|
|
])
|
|
assert.deepEqual(walls(fourth, 'UTC'), [
|
|
'2026-05-22 19:00',
|
|
'2026-06-26 19:00',
|
|
'2026-07-24 19:00',
|
|
'2026-08-28 19:00',
|
|
])
|
|
assert.notDeepEqual(walls(last, 'UTC'), walls(fourth, 'UTC'))
|
|
})
|
|
|
|
test('every month has a first through fourth of every weekday', () => {
|
|
// The claim the closed set rests on: because there is no `nth: 5`, there is no
|
|
// absent-occurrence case to define. Checked across three years rather than
|
|
// asserted in a comment.
|
|
for (let year = 2026; year <= 2028; year += 1) {
|
|
for (let month = 1; month <= 12; month += 1) {
|
|
for (let weekday = 0; weekday <= 6; weekday += 1) {
|
|
for (const nth of [1, 2, 3, 4, -1]) {
|
|
const day = r.nthWeekdayDay(year, month, weekday, nth)
|
|
assert.ok(day, `${year}-${month} weekday ${weekday} nth ${nth} should exist`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
test('`manual` is not a recurrence and expands to nothing', () => {
|
|
assert.deepEqual(r.occurrencesBetween({ kind: 'manual' }, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1)), [])
|
|
})
|
|
|
|
// ── Bounds and refusals ────────────────────────────────────────────────────
|
|
|
|
test('an inverted or empty window answers with nothing rather than throwing', () => {
|
|
const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
|
|
assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 4, 1)), [])
|
|
assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 1)), [])
|
|
})
|
|
|
|
test('a malformed schedule expands to nothing rather than to a wrong instant', () => {
|
|
// These shapes cannot come through `spec.js`, but they can come from a row
|
|
// written directly into the database — and the runner must not turn one into a
|
|
// world change at an invented time.
|
|
assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: [], time: '20:00' }, 'UTC', 0, 1e12), [])
|
|
assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: ['friday'], time: '25:00' }, 'UTC', 0, 1e12), [])
|
|
assert.deepEqual(r.occurrencesBetween({ kind: 'monthly', nth: 9, weekday: 'friday', time: '19:00' }, 'UTC', 0, 1e12), [])
|
|
assert.deepEqual(r.occurrencesBetween({ kind: 'once', at: 'tomorrow' }, 'UTC', 0, 1e12), [])
|
|
assert.deepEqual(r.occurrencesBetween(null, 'UTC', 0, 1e12), [])
|
|
})
|
|
|
|
test('the expansion is bounded, so a wide window cannot become an outage', () => {
|
|
const schedule = {
|
|
kind: 'weekly',
|
|
days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'],
|
|
time: '12:00',
|
|
}
|
|
const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2020, 0, 1), Date.UTC(2030, 0, 1))
|
|
assert.equal(found.length, r.MAX_OCCURRENCES)
|
|
|
|
const smaller = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1), { limit: 10 })
|
|
assert.equal(smaller.length, 10)
|
|
})
|
|
|
|
test('nextOccurrence looks forward and finds nothing when there is nothing', () => {
|
|
const weekly = r.nextOccurrence({ kind: 'weekly', days: ['friday'], time: '20:00' }, 'UTC', Date.UTC(2026, 5, 1))
|
|
assert.equal(wall('UTC', weekly.at), '2026-06-05 20:00')
|
|
|
|
// A `once` already in the past has no next occurrence, which is what stops a
|
|
// one-off event being re-materialised for ever.
|
|
assert.equal(r.nextOccurrence({ kind: 'once', at: '2020-01-01T12:00' }, 'UTC', Date.UTC(2026, 5, 1)), null)
|
|
assert.equal(r.nextOccurrence({ kind: 'manual' }, 'UTC', Date.UTC(2026, 5, 1)), null)
|
|
})
|
|
|
|
test('describe says the schedule back in words, in the event own zone', () => {
|
|
assert.equal(
|
|
r.describe({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
|
|
'Every Friday and Saturday at 20:00 (Europe/Berlin)',
|
|
)
|
|
assert.equal(
|
|
r.describe({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
|
|
'The last Friday of every month at 19:30 (Asia/Kolkata)',
|
|
)
|
|
assert.equal(r.describe({ kind: 'manual' }), 'Started by hand')
|
|
})
|