feat(rust): slash commands and the next wipe, server half (phase 16)
Five read-only commands registered with api.registerSlashCommands: /status, /wipe, /top, /online and /clan (D126). Every refusal is private, and any answer narrower than public (online names, a clan roster) goes to the caller alone (D127). No command asks a sidecar. The next wipe (D128, D130): six nullable columns on rust_servers, a pure nextWipe(row, now) with the zone arithmetic through Intl, computed on every read. The public server shape gains nextWipe; the admin shape gains the stored schedule; PUT /admin/rust/servers/:id takes the six fields and writes them only when wipeRule is present. server/commands joins ci/bundle.json, which checkBundle caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -129,6 +129,7 @@ function fakeApi() {
|
||||
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
|
||||
triggers: null, audiences: null, engagementSeeds: null,
|
||||
eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null,
|
||||
slashCommands: null,
|
||||
}
|
||||
const called = new Set()
|
||||
const once = (name) => {
|
||||
@@ -157,6 +158,8 @@ function fakeApi() {
|
||||
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
||||
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
||||
// Core's `once()` holds here too: a module's commands are one batch.
|
||||
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
}
|
||||
|
||||
370
server/test/commands.test.js
Normal file
370
server/test/commands.test.js
Normal file
@@ -0,0 +1,370 @@
|
||||
// ── The slash commands (phase 16) ─────────────────────────────────────────
|
||||
//
|
||||
// The handlers against stubbed models. What matters most is D127: **an answer
|
||||
// is public only when everything in it is public.** Every refusal is private,
|
||||
// and a permitted answer narrower than `public` goes to the caller alone — the
|
||||
// case core cannot catch, because an answer without the flag is posted to the
|
||||
// channel.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx, fakeApi } = require('./_fakes')
|
||||
|
||||
// Accounts as `core.users.getById` answers them. The role on the ACTOR is never
|
||||
// trusted — the row is re-read — so a test gives the actor one role and the row
|
||||
// another to prove it.
|
||||
const USERS = {
|
||||
1: { id: 1, role: 'user', status: 'active' },
|
||||
2: { id: 2, role: 'moderator', status: 'active' },
|
||||
3: { id: 3, role: 'moderator', status: 'banned' },
|
||||
}
|
||||
|
||||
function withCore() {
|
||||
const ctx = fakeCtx({ users: { getById: async (id) => USERS[id] || null } })
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const actor = (userId, role = 'user') => ({
|
||||
platform: 'discord',
|
||||
platformUserId: `d${userId || 0}`,
|
||||
guildId: 'g1',
|
||||
userId: userId || null,
|
||||
role: userId ? role : null,
|
||||
isLinked: Boolean(userId),
|
||||
isStaff: role === 'admin' || role === 'moderator',
|
||||
})
|
||||
const STRANGER = actor(null)
|
||||
const PLAYER = actor(1)
|
||||
const MOD = actor(2, 'moderator')
|
||||
|
||||
const server = (over = {}) => ({
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
online: true,
|
||||
players: 3,
|
||||
maxPlayers: 100,
|
||||
worldSize: 4000,
|
||||
seed: 1234,
|
||||
wipeId: 'w-2026-09',
|
||||
wipedAt: '2026-09-03T18:00:00Z',
|
||||
lastSeenAt: '2026-09-25T11:59:00Z',
|
||||
stale: false,
|
||||
nextWipe: { at: '2026-10-01T18:00:00.000Z', source: 'forced' },
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Swap model functions for one test, and put them back whatever happens. */
|
||||
async function stubbed(stubs, body) {
|
||||
const saved = []
|
||||
for (const [mod, name, fn] of stubs) {
|
||||
saved.push([mod, name, mod[name]])
|
||||
mod[name] = fn
|
||||
}
|
||||
try {
|
||||
return await body()
|
||||
} finally {
|
||||
for (const [mod, name, fn] of saved) mod[name] = fn
|
||||
}
|
||||
}
|
||||
|
||||
function models() {
|
||||
return {
|
||||
servers: require('../model/servers/servers.model'),
|
||||
events: require('../model/events/events.model'),
|
||||
visibility: require('../model/visibility/visibility.model'),
|
||||
clans: require('../model/clans/clans.model'),
|
||||
visibilityDb: require('../model/visibility/visibility.db'),
|
||||
}
|
||||
}
|
||||
|
||||
const command = (name) => require('../commands').find((cmd) => cmd.name === name)
|
||||
|
||||
/** Everything an answer would put in front of a reader, as one string. */
|
||||
const said = (env) => JSON.stringify([env.title, env.text, env.fields, env.notice])
|
||||
|
||||
// ── Registration ──────────────────────────────────────────────────────────
|
||||
|
||||
test('the entry point registers the five commands as one batch', () => {
|
||||
withCore()
|
||||
const api = fakeApi()
|
||||
require('../index')(fakeCtx(), api)
|
||||
assert.deepStrictEqual(api.record.slashCommands.map((cmd) => cmd.name), ['status', 'wipe', 'top', 'online', 'clan'])
|
||||
})
|
||||
|
||||
test('every definition is one core will accept, and one Discord will', () => {
|
||||
// Core's `checkSlashCommandShape` rules, restated: a definition it refuses
|
||||
// fails the module at register(), and one Discord refuses takes every command
|
||||
// in the batch down with it, the bot's own included.
|
||||
const BUILT_INS = ['announce', 'autorole', 'ban', 'filter', 'filterallow', 'invite', 'kick', 'modlog', 'mute',
|
||||
'news', 'ping', 'role', 'rolemenu', 'roles', 'schedule', 'warn', 'warnings', 'wiki']
|
||||
for (const cmd of require('../commands')) {
|
||||
assert.match(cmd.name, /^[a-z0-9_-]{1,32}$/)
|
||||
assert.ok(!BUILT_INS.includes(cmd.name), `${cmd.name} collides with a bot built-in`)
|
||||
assert.ok(cmd.description.length >= 1 && cmd.description.length <= 100, `${cmd.name}: description length`)
|
||||
assert.strictEqual(cmd.access, 'everyone')
|
||||
assert.strictEqual(typeof cmd.handler, 'function')
|
||||
let optionalSeen = false
|
||||
for (const o of cmd.options) {
|
||||
assert.match(o.name, /^[a-z0-9_-]{1,32}$/)
|
||||
assert.ok(['string', 'integer', 'boolean', 'user'].includes(o.type))
|
||||
assert.ok(o.description.length >= 1 && o.description.length <= 100, `${cmd.name}.${o.name}: description length`)
|
||||
if (o.choices) assert.ok(['string', 'integer'].includes(o.type))
|
||||
if (!o.required) optionalSeen = true
|
||||
else assert.ok(!optionalSeen, `${cmd.name}: a required option after an optional one`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Picking a server ──────────────────────────────────────────────────────
|
||||
|
||||
test('a server is named by id, then name, then a unique prefix — never guessed', () => {
|
||||
withCore()
|
||||
const { pickServer } = require('../commands/common')
|
||||
const list = [server(), server({ id: 'eu-2x', name: 'EU 2x' }), server({ id: 'eu-5x', name: 'EU 5x' })]
|
||||
|
||||
assert.strictEqual(pickServer(list, 'MAIN').server.id, 'main')
|
||||
assert.strictEqual(pickServer(list, 'eu 2x').server.id, 'eu-2x')
|
||||
assert.strictEqual(pickServer(list, 'eu-5').server.id, 'eu-5x')
|
||||
assert.strictEqual(pickServer(list, 'eu').ambiguous.length, 2)
|
||||
assert.strictEqual(pickServer(list, 'us').missing, 'us')
|
||||
assert.strictEqual(pickServer(list, undefined).all.length, 3)
|
||||
assert.strictEqual(pickServer([server()], undefined).server.id, 'main', 'a fleet of one is that server')
|
||||
assert.ok(pickServer([], 'main').none)
|
||||
})
|
||||
|
||||
// ── Every refusal is private ──────────────────────────────────────────────
|
||||
|
||||
test('every refusal is ephemeral: unknown, ambiguous, no servers, /top on a fleet', async () => {
|
||||
withCore()
|
||||
const { servers } = models()
|
||||
const two = [server(), server({ id: 'main-2', name: 'Main 2' })]
|
||||
|
||||
await stubbed([[servers, 'listPublic', async () => two]], async () => {
|
||||
for (const [name, options] of [
|
||||
['status', { server: 'nope' }],
|
||||
['wipe', { server: 'mai' }],
|
||||
['online', { server: 'nope' }],
|
||||
['top', {}],
|
||||
['top', { server: 'm' }],
|
||||
['clan', { name: '' }],
|
||||
]) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const env = await command(name).handler({ command: name, options, actor: STRANGER })
|
||||
assert.strictEqual(env.ephemeral, true, `/${name} ${JSON.stringify(options)} refused in public: ${env.text}`)
|
||||
}
|
||||
})
|
||||
|
||||
await stubbed([[servers, 'listPublic', async () => []]], async () => {
|
||||
const env = await command('status').handler({ options: {}, actor: STRANGER })
|
||||
assert.strictEqual(env.ephemeral, true)
|
||||
assert.match(env.text, /No Rust servers/)
|
||||
})
|
||||
})
|
||||
|
||||
// ── /status and /wipe ─────────────────────────────────────────────────────
|
||||
|
||||
test('/status answers in public, with the next wipe, and says when an offline server was last seen', async () => {
|
||||
withCore()
|
||||
const { servers } = models()
|
||||
await stubbed([[servers, 'listPublic', async () => [server(), server({ id: 'b', name: 'B', online: false, players: 0 })]]], async () => {
|
||||
const one = await command('status').handler({ options: { server: 'main' }, actor: STRANGER })
|
||||
assert.ok(!one.ephemeral)
|
||||
assert.strictEqual(one.url, 'http://localhost:5173/rust/servers/main')
|
||||
assert.match(said(one), /3\/100/)
|
||||
assert.match(said(one), /Next wipe.*Thu 1 Oct, 18:00 UTC.*monthly forced wipe/)
|
||||
|
||||
const down = await command('status').handler({ options: { server: 'b' }, actor: STRANGER })
|
||||
assert.match(said(down), /Offline/)
|
||||
assert.match(said(down), /Last seen/)
|
||||
|
||||
const fleet = await command('status').handler({ options: {}, actor: STRANGER })
|
||||
assert.ok(!fleet.ephemeral)
|
||||
assert.strictEqual(fleet.fields.length, 2)
|
||||
})
|
||||
})
|
||||
|
||||
test('/wipe says "no schedule set" rather than inventing a forecast', async () => {
|
||||
withCore()
|
||||
const { servers } = models()
|
||||
await stubbed([[servers, 'listPublic', async () => [server({ nextWipe: null })]]], async () => {
|
||||
const env = await command('wipe').handler({ options: {}, actor: STRANGER })
|
||||
assert.ok(!env.ephemeral)
|
||||
assert.match(env.text, /Next: no schedule set/)
|
||||
assert.match(env.text, /Last: Thu 3 Sept?, 18:00 UTC/)
|
||||
})
|
||||
await stubbed([[servers, 'listPublic', async () => [server({ nextWipe: { at: '2026-10-03T17:00:00.000Z', source: 'once' } })]]], async () => {
|
||||
const env = await command('wipe').handler({ options: {}, actor: STRANGER })
|
||||
assert.match(env.text, /rescheduled by the operator/)
|
||||
})
|
||||
})
|
||||
|
||||
// ── /top ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('/top reads the current wipe, never asks for presence, and prints no Steam id', async () => {
|
||||
withCore()
|
||||
const { servers, events } = models()
|
||||
const asked = []
|
||||
const rows = [{ steamId: '76561198000000001', name: 'Alice', kills: 9, deaths: 1, npcKills: 0, playtimeSec: 7260, lastSeen: 'x' }]
|
||||
await stubbed(
|
||||
[
|
||||
[servers, 'listPublic', async () => [server()]],
|
||||
[events, 'leaderboard', async (q) => { asked.push(q); return rows }],
|
||||
],
|
||||
async () => {
|
||||
const env = await command('top').handler({ options: {}, actor: MOD })
|
||||
assert.ok(!env.ephemeral, 'the leaderboard names are public at every setting')
|
||||
assert.deepStrictEqual(asked[0], { serverId: 'main', wipeId: 'w-2026-09', sort: 'kills', limit: 10, presence: false })
|
||||
assert.match(env.text, /1\. Alice — 9/)
|
||||
assert.ok(!said(env).includes('76561198000000001'))
|
||||
|
||||
const all = await command('top').handler({ options: { stat: 'playtime', alltime: true }, actor: STRANGER })
|
||||
assert.strictEqual(asked[1].wipeId, null)
|
||||
assert.strictEqual(asked[1].sort, 'playtime')
|
||||
assert.match(all.text, /2h 1m/)
|
||||
assert.match(all.title, /all time/)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// ── /online — D127 ────────────────────────────────────────────────────────
|
||||
|
||||
async function online(audience, who, over = {}) {
|
||||
const { servers, events, visibility } = models()
|
||||
return stubbed(
|
||||
[
|
||||
[servers, 'listPublic', async () => [server(over)]],
|
||||
[visibility, 'presenceFor', async () => audience],
|
||||
[events, 'online', async () => [{ name: 'Alice', sleeping: false }, { name: 'Bob', sleeping: true }]],
|
||||
],
|
||||
() => command('online').handler({ options: {}, actor: who }),
|
||||
)
|
||||
}
|
||||
|
||||
test('/online below the audience: the count, in public, and no names', async () => {
|
||||
withCore()
|
||||
const env = await online('staff', STRANGER)
|
||||
assert.ok(!env.ephemeral)
|
||||
assert.match(env.title, /3 online/)
|
||||
assert.ok(!said(env).includes('Alice'))
|
||||
// Staff is not something linking earns, so there is no nudge to link.
|
||||
assert.ok(!env.notice)
|
||||
})
|
||||
|
||||
test('/online inside a narrower audience: the names, to the caller ALONE', async () => {
|
||||
withCore()
|
||||
const env = await online('staff', MOD)
|
||||
assert.strictEqual(env.ephemeral, true, 'a moderator’s roll call was posted to the channel')
|
||||
assert.match(env.text, /Alice/)
|
||||
assert.match(env.text, /Bob \(sleeping\)/)
|
||||
|
||||
const signedIn = await online('signed_in', PLAYER)
|
||||
assert.strictEqual(signedIn.ephemeral, true)
|
||||
})
|
||||
|
||||
test('/online with a public audience posts the names in the open', async () => {
|
||||
withCore()
|
||||
const env = await online('public', STRANGER)
|
||||
assert.ok(!env.ephemeral)
|
||||
assert.match(env.text, /Alice/)
|
||||
})
|
||||
|
||||
test('/online nudges an unlinked caller to link only when linking would reach the names', async () => {
|
||||
withCore()
|
||||
const env = await online('signed_in', STRANGER)
|
||||
assert.ok(!env.ephemeral)
|
||||
assert.match(env.notice, /Link your Discord account/)
|
||||
})
|
||||
|
||||
test('/online judges the ACCOUNT, not the actor: a banned moderator is nobody', async () => {
|
||||
withCore()
|
||||
const env = await online('staff', actor(3, 'moderator'))
|
||||
assert.ok(!said(env).includes('Alice'))
|
||||
})
|
||||
|
||||
test('/online for an offline server names nobody, whatever the audience', async () => {
|
||||
withCore()
|
||||
const env = await online('public', MOD, { online: false, players: 0 })
|
||||
assert.match(env.text, /offline/)
|
||||
assert.ok(!said(env).includes('Alice'))
|
||||
})
|
||||
|
||||
// ── /clan — D127 with D48 ─────────────────────────────────────────────────
|
||||
|
||||
const CLAN = { externalId: 'main:7:1', name: 'Wolves', color: '#aa3300', score: 50, memberCount: 2, maxMembers: 8 }
|
||||
|
||||
async function clan(rosterAudience, who, options = { name: 'wolves' }, board = { supported: true, fresh: true }) {
|
||||
const { servers, clans, visibilityDb, visibility } = models()
|
||||
return stubbed(
|
||||
[
|
||||
[servers, 'listPublic', async () => [server()]],
|
||||
[clans, 'listForServer', async () => ({ clans: [CLAN, { ...CLAN, externalId: 'main:8:1', name: 'Wolverines' }], board })],
|
||||
[visibilityDb, 'getSetting', async (key) => (key === visibility.CLAN_ROSTER_KEY ? rosterAudience : null)],
|
||||
[require('../model/clans/clans.db'), 'findClan', async () => ({ ...CLAN, serverId: 'main', serverName: 'Main', createdMs: 1 })],
|
||||
[require('../model/clans/clans.db'), 'userIsMember', async (_ext, userId) => userId === 1],
|
||||
[require('../model/clans/clans.db'), 'listMembers', async () => [
|
||||
{ name: 'Alice', rank: 1, online: 1 },
|
||||
{ name: 'Bob', rank: 2, online: 0 },
|
||||
]],
|
||||
],
|
||||
() => command('clan').handler({ options, actor: who }),
|
||||
)
|
||||
}
|
||||
|
||||
test('/clan outside the roster audience: the public facts, in public, no names', async () => {
|
||||
withCore()
|
||||
const env = await clan('members', STRANGER)
|
||||
assert.ok(!env.ephemeral)
|
||||
assert.strictEqual(env.title, 'Wolves')
|
||||
assert.match(said(env), /50/)
|
||||
assert.ok(!said(env).includes('Alice'))
|
||||
})
|
||||
|
||||
test('/clan inside a narrower roster audience: the roster, to the caller ALONE', async () => {
|
||||
withCore()
|
||||
const member = await clan('members', PLAYER)
|
||||
assert.strictEqual(member.ephemeral, true, 'a clan roster was posted to the channel')
|
||||
assert.match(said(member), /Alice \(leader, online\)/)
|
||||
|
||||
const mod = await clan('members', MOD)
|
||||
assert.strictEqual(mod.ephemeral, true)
|
||||
})
|
||||
|
||||
test('/clan with a public roster audience posts the roster in the open', async () => {
|
||||
withCore()
|
||||
const env = await clan('public', STRANGER)
|
||||
assert.ok(!env.ephemeral)
|
||||
assert.match(said(env), /Alice/)
|
||||
})
|
||||
|
||||
test('/clan: an exact name wins over a prefix, and a shared prefix is ambiguous and private', async () => {
|
||||
withCore()
|
||||
assert.strictEqual((await clan('members', STRANGER, { name: 'WOLVES' })).title, 'Wolves')
|
||||
const env = await clan('members', STRANGER, { name: 'wol' })
|
||||
assert.strictEqual(env.ephemeral, true)
|
||||
assert.match(env.text, /Wolves, Wolverines/)
|
||||
})
|
||||
|
||||
test('/clan does not say "no such clan" from a board it cannot trust', async () => {
|
||||
withCore()
|
||||
const env = await clan('members', STRANGER, { name: 'bears' }, { supported: true, fresh: false })
|
||||
assert.strictEqual(env.ephemeral, true)
|
||||
assert.match(env.text, /not available, so it may be there/)
|
||||
|
||||
const sure = await clan('members', STRANGER, { name: 'bears' })
|
||||
assert.match(sure.text, /^No clan called “bears”\.$/)
|
||||
})
|
||||
|
||||
// ── Words ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('times are written in UTC with the distance beside them', () => {
|
||||
withCore()
|
||||
const { when, relative } = require('../commands/common')
|
||||
const now = Date.parse('2026-09-25T12:00:00Z')
|
||||
assert.strictEqual(when('2026-10-01T18:00:00Z', now), 'Thu 1 Oct, 18:00 UTC (in 6 days)')
|
||||
assert.strictEqual(relative(now - 3 * 3_600_000, now), '3 hours ago')
|
||||
assert.strictEqual(relative(now + 90_000, now), 'in 2 minutes')
|
||||
assert.strictEqual(when(null, now), null)
|
||||
})
|
||||
136
server/test/nextWipe.test.js
Normal file
136
server/test/nextWipe.test.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// ── 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), [])
|
||||
})
|
||||
@@ -97,7 +97,7 @@ test('the public shape carries nothing about the sidecar', () => {
|
||||
// someone who did not read this file, and an allowlist is the only assertion
|
||||
// that catches one.
|
||||
assert.deepStrictEqual(Object.keys(shaped).sort(), [
|
||||
'hostname', 'id', 'lastSeenAt', 'level', 'maxPlayers', 'name', 'online', 'players', 'seed', 'stale',
|
||||
'hostname', 'id', 'lastSeenAt', 'level', 'maxPlayers', 'name', 'nextWipe', 'online', 'players', 'seed', 'stale',
|
||||
'updatedAt', 'wipeId', 'wipedAt', 'worldSize',
|
||||
])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user