// ── The next wipe (phase 16, D130) ──────────────────────────────────────── // // Pure arithmetic, so no core and no database. The DST edges are here rather // than in the walk (§32.3 step 5): waiting for a clock change is not a test. const test = require('node:test') const assert = require('node:assert') const { nextWipe, validateSchedule, instantOf, parseDay, weekdayOf, isZone } = require('../model/servers/nextWipe') const at = (iso) => Date.parse(iso) const day = (iso) => parseDay(iso) test('the forced wipe is the first Thursday, 19:00 UK time — 18:00 UTC in summer', () => { // The verified fact the plan rests on: Thursday 1 October 2026, 18:00 UTC. const next = nextWipe({ wipeRule: 'forced' }, at('2026-09-25T12:00:00Z')) assert.deepStrictEqual(next, { at: '2026-10-01T18:00:00.000Z', source: 'forced' }) // In winter the UK clock is UTC, and 19:00 is 19:00Z. const winter = nextWipe({ wipeRule: 'forced' }, at('2026-11-10T00:00:00Z')) assert.deepStrictEqual(winter, { at: '2026-12-03T19:00:00.000Z', source: 'forced' }) }) test('a forced wipe that has just happened names next month’s', () => { const next = nextWipe({ wipeRule: 'forced' }, at('2026-10-01T18:00:00Z')) assert.strictEqual(next.at, '2026-11-05T19:00:00.000Z') }) test('`none` forecasts nothing, not even the forced wipe', () => { assert.strictEqual(nextWipe({ wipeRule: 'none' }, at('2026-09-25T12:00:00Z')), null) assert.strictEqual(nextWipe({}, at('2026-09-25T12:00:00Z')), null) // An unknown rule word narrows to none rather than guessing. assert.strictEqual(nextWipe({ wipeRule: 'daily' }, at('2026-09-25T12:00:00Z')), null) }) test('a weekly rule is the earlier of its own day and the forced wipe', () => { const weekly = { wipeRule: 'weekly', wipeDay: 4, wipeTime: '14:00', wipeTz: 'America/Chicago' } // Friday 25 Sep: the next Thursday is 1 Oct, 14:00 CDT = 19:00Z — AFTER the // forced wipe at 18:00Z the same day, so the forced one is next. assert.deepStrictEqual(nextWipe(weekly, at('2026-09-25T12:00:00Z')), { at: '2026-10-01T18:00:00.000Z', source: 'forced' }) // Once that has passed, the rule's own Thursday comes first. assert.deepStrictEqual(nextWipe(weekly, at('2026-10-01T18:30:00Z')), { at: '2026-10-01T19:00:00.000Z', source: 'rule' }) assert.deepStrictEqual(nextWipe(weekly, at('2026-10-01T19:00:00Z')), { at: '2026-10-08T19:00:00.000Z', source: 'rule' }) }) test('a biweekly rule wipes on its anchor’s weeks only', () => { const biweekly = { wipeRule: 'biweekly', wipeDay: 1, wipeTime: '20:00', wipeTz: 'Europe/London', wipeAnchor: '2026-09-14' } // Mon 14 Sep is on, 21 Sep off, 28 Sep on (20:00 BST = 19:00Z). assert.deepStrictEqual(nextWipe(biweekly, at('2026-09-15T00:00:00Z')), { at: '2026-09-28T19:00:00.000Z', source: 'rule' }) // An anchor in the future still defines the parity. assert.deepStrictEqual( nextWipe({ ...biweekly, wipeAnchor: '2026-12-07' }, at('2026-09-15T00:00:00Z')), { at: '2026-09-28T19:00:00.000Z', source: 'rule' }, ) }) test('a one-off date in the future is the next wipe, before or after the computed one', () => { const forced = { wipeRule: 'forced' } const now = at('2026-09-25T12:00:00Z') // Before the computed wipe: an extra wipe. assert.deepStrictEqual(nextWipe({ ...forced, wipeOnceAt: '2026-09-27T17:00:00Z' }, now), { at: '2026-09-27T17:00:00.000Z', source: 'once' }) // After it: a delay — the computed 1 Oct wipe is skipped. assert.deepStrictEqual(nextWipe({ ...forced, wipeOnceAt: new Date('2026-10-03T17:00:00Z') }, now), { at: '2026-10-03T17:00:00.000Z', source: 'once' }) // Past: ignored, and the rule answers again. assert.deepStrictEqual(nextWipe({ ...forced, wipeOnceAt: '2026-09-20T17:00:00Z' }, now), { at: '2026-10-01T18:00:00.000Z', source: 'forced' }) // Under `none` a stated date is still a forecast. assert.deepStrictEqual(nextWipe({ wipeRule: 'none', wipeOnceAt: '2026-09-27T17:00:00Z' }, now), { at: '2026-09-27T17:00:00.000Z', source: 'once' }) }) test('a broken rule still answers the forced wipe, and never throws', () => { const now = at('2026-09-25T12:00:00Z') const broken = { wipeRule: 'weekly', wipeDay: 4, wipeTime: '25:00', wipeTz: 'Mars/Olympus' } assert.deepStrictEqual(nextWipe(broken, now), { at: '2026-10-01T18:00:00.000Z', source: 'forced' }) assert.strictEqual(nextWipe(null, now), null) }) // ── The DST edges, both zones, both directions (reading 6) ──────────────── test('Europe/London: a skipped hour moves forward, a repeated hour takes the first', () => { // Spring 2027: 29 Mar 01:00 GMT → 02:00 BST. 01:30 does not exist. assert.strictEqual(new Date(instantOf(day('2027-03-28'), 1, 30, 'Europe/London')).toISOString(), '2027-03-28T01:30:00.000Z') // Autumn 2026: 25 Oct 02:00 BST → 01:00 GMT. 01:30 happens twice; the first is BST. assert.strictEqual(new Date(instantOf(day('2026-10-25'), 1, 30, 'Europe/London')).toISOString(), '2026-10-25T00:30:00.000Z') // Either side of the change, ordinary. assert.strictEqual(new Date(instantOf(day('2026-10-24'), 19, 0, 'Europe/London')).toISOString(), '2026-10-24T18:00:00.000Z') assert.strictEqual(new Date(instantOf(day('2026-10-26'), 19, 0, 'Europe/London')).toISOString(), '2026-10-26T19:00:00.000Z') }) test('America/Chicago: a skipped hour moves forward, a repeated hour takes the first', () => { // Spring 2027: 14 Mar 02:00 CST → 03:00 CDT. 02:30 does not exist → 03:30 CDT = 08:30Z. assert.strictEqual(new Date(instantOf(day('2027-03-14'), 2, 30, 'America/Chicago')).toISOString(), '2027-03-14T08:30:00.000Z') // Autumn 2026: 1 Nov 02:00 CDT → 01:00 CST. 01:30 twice; the first is CDT = 06:30Z. assert.strictEqual(new Date(instantOf(day('2026-11-01'), 1, 30, 'America/Chicago')).toISOString(), '2026-11-01T06:30:00.000Z') }) test('a weekly rule across the autumn change keeps its wall-clock time', () => { const weekly = { wipeRule: 'weekly', wipeDay: 0, wipeTime: '14:00', wipeTz: 'America/Chicago' } // Sun 25 Oct 14:00 CDT = 19:00Z; Sun 1 Nov 14:00 CST = 20:00Z. assert.strictEqual(nextWipe(weekly, at('2026-10-24T00:00:00Z')).at, '2026-10-25T19:00:00.000Z') assert.strictEqual(nextWipe(weekly, at('2026-10-26T00:00:00Z')).at, '2026-11-01T20:00:00.000Z') }) test('the calendar helpers', () => { assert.strictEqual(weekdayOf(day('2026-10-01')), 4) assert.strictEqual(weekdayOf(day('1969-12-31')), 3) assert.strictEqual(parseDay('2026-02-30'), null) assert.strictEqual(parseDay('26-02-01'), null) assert.strictEqual(isZone('Europe/London'), true) assert.strictEqual(isZone('Europe/Londn'), false) assert.strictEqual(isZone(''), false) }) test('validation names every problem, and a one-off date must be in the future', () => { const now = at('2026-09-25T12:00:00Z') assert.deepStrictEqual(validateSchedule({ wipeRule: 'none' }, now), []) assert.deepStrictEqual(validateSchedule({ wipeRule: 'forced' }, now), []) assert.deepStrictEqual(validateSchedule({ wipeRule: 'weekly', wipeDay: 4, wipeTime: '19:00', wipeTz: 'Europe/London' }, now), []) assert.strictEqual(validateSchedule({ wipeRule: 'monthly' }, now).length, 1) assert.strictEqual(validateSchedule({ wipeRule: 'weekly' }, now).length, 3) assert.match(validateSchedule({ wipeRule: 'weekly', wipeDay: 4, wipeTime: '19:00', wipeTz: 'UK' }, now)[0], /time zone/) // The anchor must be a wipe on the rule: a Tuesday anchor for a Monday rule is refused. const bi = { wipeRule: 'biweekly', wipeDay: 1, wipeTime: '20:00', wipeTz: 'Europe/London' } assert.match(validateSchedule(bi, now)[0], /date of one wipe/) assert.match(validateSchedule({ ...bi, wipeAnchor: '2026-09-15' }, now)[0], /day of the week/) assert.deepStrictEqual(validateSchedule({ ...bi, wipeAnchor: '2026-09-14' }, now), []) assert.match(validateSchedule({ wipeRule: 'forced', wipeOnceAt: '2026-09-24T00:00:00Z' }, now)[0], /future/) assert.match(validateSchedule({ wipeRule: 'forced', wipeOnceAt: 'soon' }, now)[0], /not a date/) assert.deepStrictEqual(validateSchedule({ wipeRule: 'forced', wipeOnceAt: null }, now), []) })