Move updaters onto public and private storage

Updaters take a storage object instead of touching the filesystem: a
publicStorage (what the site serves: data/, assets/) and a privateStorage
(updater state). Two implementations share one small interface
(exists / readJson / writeJson / readBytes / writeText / writeBytes):

- FilesystemStorage over a directory, used by npm run splatnet, so dist/
  and storage/ end up exactly as they always have and the S3 sync is
  untouched;
- BucketStorage over an R2 bucket binding (MemoryBucket in tests), with a
  thin per-run cache: exists() lists a directory once and answers from
  memory, readJson() parses a document once, and writeJson() skips the put
  when the content is unchanged.

The updater base class and localization processor keep their original
structure with awaits added; the processor reads its document lazily and
writes once per update, as splatoon3.ink's does. Objects written to a
bucket carry the content type and cache-control the S3 sync used to apply.

Tests cover the storage contract (run against both implementations), the
BucketStorage cache behaviour, MemoryBucket's R2-style listing, the
localization processor, the updater base, and each concrete updater.
scripts/compare-data.mjs compares two data directories ignoring SplatNet's
random ordering and ICS timestamps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Matt Isenhower
2026-09-04 20:12:56 -07:00
parent 2c636257f3
commit 19d1fb5d3d
26 changed files with 975 additions and 178 deletions

View File

@@ -0,0 +1,70 @@
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { MemoryBucket, BucketStorage } from '../../src/common/storage/index.js';
test('exists lists a directory once and remembers what it wrote', async () => {
const bucket = new MemoryBucket;
await bucket.put('assets/splatnet/images/stage/a.png', 'a');
await bucket.put('assets/splatnet/images/weapon/w.png', 'w');
const list = mock.method(bucket, 'list');
const head = mock.method(bucket, 'head');
const storage = new BucketStorage(bucket);
assert.equal(await storage.exists('assets/splatnet/images/stage/a.png'), true);
assert.equal(await storage.exists('assets/splatnet/images/stage/b.png'), false);
assert.equal(await storage.exists('assets/splatnet/images/weapon/w.png'), true);
assert.deepEqual(list.mock.calls.map(c => c.arguments[0].prefix), ['assets/splatnet/images/stage/', 'assets/splatnet/images/weapon/']);
assert.equal(head.mock.calls.length, 0);
await storage.writeBytes('assets/splatnet/images/stage/b.png', new Uint8Array([1]));
assert.equal(await storage.exists('assets/splatnet/images/stage/b.png'), true);
assert.equal(list.mock.calls.length, 2);
});
test('readJson parses once and returns the same object, null for missing keys', async () => {
const bucket = new MemoryBucket;
await bucket.put('data/x.json', JSON.stringify({ a: 1 }));
const get = mock.method(bucket, 'get');
const storage = new BucketStorage(bucket);
const first = await storage.readJson('data/x.json');
const second = await storage.readJson('data/x.json');
assert.deepEqual(first, { a: 1 });
assert.equal(first, second);
assert.equal(await storage.readJson('data/missing.json'), null);
assert.equal(await storage.readJson('data/missing.json'), null);
assert.equal(get.mock.calls.length, 2);
});
test('writeJson skips unchanged content, writes changed content with metadata, and updates the cache', async () => {
const bucket = new MemoryBucket;
await bucket.put('data/x.json', JSON.stringify({ a: 1 }));
const put = mock.method(bucket, 'put');
const storage = new BucketStorage(bucket);
const doc = await storage.readJson('data/x.json');
assert.equal(await storage.writeJson('data/x.json', { a: 1 }), false);
assert.equal(put.mock.calls.length, 0);
doc.a = 2; // mutate the shared object, as the localization processor does
assert.equal(await storage.writeJson('data/x.json', doc, { cacheControl: 'no-cache' }), true);
assert.equal(put.mock.calls.length, 1);
assert.deepEqual((await bucket.get('data/x.json')).httpMetadata, { contentType: 'application/json', cacheControl: 'no-cache' });
assert.deepEqual(await (await bucket.get('data/x.json')).json(), { a: 2 });
assert.equal(await storage.writeJson('data/x.json', { a: 2 }), false);
assert.equal(await storage.writeJson('data/new.json', { fresh: true }), true);
assert.equal(await storage.exists('data/new.json'), true);
assert.deepEqual(await storage.readJson('data/new.json'), { fresh: true });
});
test('writeText and readBytes go straight through with content types by extension', async () => {
const bucket = new MemoryBucket;
const storage = new BucketStorage(bucket);
await storage.writeText('data/cal.ics', 'BEGIN:VCALENDAR', { cacheControl: 'no-cache' });
assert.deepEqual((await bucket.get('data/cal.ics')).httpMetadata, { contentType: 'text/calendar', cacheControl: 'no-cache' });
await storage.writeBytes('assets/a.png', new Uint8Array([9, 9]));
assert.equal((await bucket.get('assets/a.png')).httpMetadata.contentType, 'image/png');
assert.deepEqual(await storage.readBytes('assets/a.png'), new Uint8Array([9, 9]));
assert.equal(await storage.readBytes('assets/none.png'), null);
});

View File

@@ -0,0 +1,35 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MemoryBucket } from '../../src/common/storage/index.js';
// MemoryBucket stands in for the R2 binding in tests, so its list() must paginate like R2's.
test('list filters by prefix, sorts, and paginates with cursors', async () => {
const bucket = new MemoryBucket;
for (const key of ['assets/images/stage/b.png', 'assets/images/stage/a.png', 'assets/images/stage/deep/c.png', 'assets/images/weapon/w.png', 'data/x.json'])
await bucket.put(key, 'x');
const all = await bucket.list({ prefix: 'assets/images/stage/' });
assert.deepEqual(all.objects.map(o => o.key), ['assets/images/stage/a.png', 'assets/images/stage/b.png', 'assets/images/stage/deep/c.png']);
assert.equal(all.truncated, false);
const first = await bucket.list({ prefix: 'assets/', limit: 2 });
assert.equal(first.truncated, true);
const second = await bucket.list({ prefix: 'assets/', limit: 2, cursor: first.cursor });
assert.deepEqual([...first.objects, ...second.objects].map(o => o.key).length, 4);
assert.equal(second.truncated, false);
});
test('put and get round-trip strings, bytes, and streams with metadata', async () => {
const bucket = new MemoryBucket;
await bucket.put('a.json', '{"a":1}', { httpMetadata: { contentType: 'application/json' } });
await bucket.put('b.png', new Uint8Array([1, 2]));
await bucket.put('c.png', new Response(new Uint8Array([3])).body);
assert.deepEqual(await (await bucket.get('a.json')).json(), { a: 1 });
assert.equal((await bucket.head('a.json')).httpMetadata.contentType, 'application/json');
assert.deepEqual(new Uint8Array(await (await bucket.get('b.png')).arrayBuffer()), new Uint8Array([1, 2]));
assert.deepEqual(new Uint8Array(await (await bucket.get('c.png')).arrayBuffer()), new Uint8Array([3]));
assert.equal(await bucket.get('missing'), null);
await bucket.delete('a.json');
assert.equal(await bucket.head('a.json'), null);
});

View File

@@ -0,0 +1,51 @@
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { MemoryBucket, BucketStorage, FilesystemStorage } from '../../src/common/storage/index.js';
// One contract for the storage interface the updaters use, run against every implementation.
const implementations = {
BucketStorage: {
create: async () => new BucketStorage(new MemoryBucket),
destroy: async () => {},
},
FilesystemStorage: {
create: async () => new FilesystemStorage(await mkdtemp(path.join(tmpdir(), 'storage-'))),
destroy: storage => rm(storage.root, { recursive: true, force: true }),
},
};
for (const [name, { create, destroy }] of Object.entries(implementations)) {
describe(name, () => {
let storage;
beforeEach(async () => { storage = await create(); });
afterEach(() => destroy(storage));
test('missing keys do not exist and read as null', async () => {
assert.equal(await storage.exists('data/missing.json'), false);
assert.equal(await storage.readJson('data/missing.json'), null);
assert.equal(await storage.readBytes('assets/missing.png'), null);
});
test('json round-trips and becomes visible to exists', async () => {
await storage.writeJson('data/nested/a.json', { hello: 'world' });
assert.equal(await storage.exists('data/nested/a.json'), true);
assert.deepEqual(await storage.readJson('data/nested/a.json'), { hello: 'world' });
await storage.writeJson('data/nested/a.json', { hello: 'again' });
assert.deepEqual(await storage.readJson('data/nested/a.json'), { hello: 'again' });
});
test('bytes and text round-trip', async () => {
await storage.writeBytes('assets/x.png', new Uint8Array([1, 2, 3]));
assert.deepEqual(await storage.readBytes('assets/x.png'), new Uint8Array([1, 2, 3]));
assert.equal(await storage.exists('assets/x.png'), true);
await storage.writeText('data/cal.ics', 'BEGIN:VCALENDAR');
assert.equal(await storage.exists('data/cal.ics'), true);
assert.equal(new TextDecoder().decode(await storage.readBytes('data/cal.ics')), 'BEGIN:VCALENDAR');
});
});
}

View File

@@ -0,0 +1,54 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import { MemoryBucket, BucketStorage } from '../../src/common/storage/index.js';
import LocalizationProcessor from '../../src/app/updater/LocalizationProcessor.js';
const en = { region: 'NA', language: 'en' };
const ja = { region: 'JP', language: 'ja' };
const stages = { name: 'stages', entities: ['$..stage_a', '$..stage_b'], id: 'id', values: 'name' };
const rules = { name: 'rules', entities: '$..rule', id: 'key', values: ['name', 'multiline_name'] };
const gear = { name: 'gear', entities: '$..gear', id: ['kind', 'id'], values: 'name' };
const data = {
regular: [{ stage_a: { id: '0', name: 'The Reef' }, stage_b: { id: '22', name: 'Skipper Pavilion' }, rule: { key: 'turf_war', name: 'Turf War', multiline_name: 'Turf\nWar' } }],
};
const json = async (bucket, key) => (await bucket.get(key)).json();
test('records localized values under name/ids/value and reports what is missing', async () => {
const bucket = new MemoryBucket;
await bucket.put('data/locale/en.json', JSON.stringify({ stages: { 0: { name: 'The Reef' } } }));
const storage = new BucketStorage(bucket);
const stageProcessor = new LocalizationProcessor(stages, en, storage);
assert.equal(await stageProcessor.hasLocalizations(data), false); // stage 22 unknown
await stageProcessor.updateLocalizations(data);
assert.equal(await stageProcessor.hasLocalizations(data), true);
const ruleProcessor = new LocalizationProcessor(rules, en, storage);
assert.equal(await ruleProcessor.hasLocalizations(data), false);
await ruleProcessor.updateLocalizations(data);
assert.deepEqual(await json(bucket, 'data/locale/en.json'), {
stages: { 0: { name: 'The Reef' }, 22: { name: 'Skipper Pavilion' } },
rules: { turf_war: { name: 'Turf War', multiline_name: 'Turf\nWar' } },
});
assert.equal((await bucket.head('data/locale/en.json')).httpMetadata.cacheControl, 'no-cache, stale-while-revalidate=5, stale-if-error=86400');
});
test('supports multi-part ids and creates a missing document', async () => {
const bucket = new MemoryBucket;
const processor = new LocalizationProcessor(gear, en, new BucketStorage(bucket));
await processor.updateLocalizations({ merchandises: [{ gear: { kind: 'head', id: '5', name: 'Fake Hat' } }] });
assert.deepEqual(await json(bucket, 'data/locale/en.json'), { gear: { head: { 5: { name: 'Fake Hat' } } } });
});
test('does not rewrite a document that already has every string', async () => {
const bucket = new MemoryBucket;
await bucket.put('data/locale/en.json', JSON.stringify({ stages: { 0: { name: 'The Reef' }, 22: { name: 'Skipper Pavilion' } } }));
const puts = mock.method(bucket, 'put');
await new LocalizationProcessor(stages, en, new BucketStorage(bucket)).updateLocalizations(data);
assert.equal(puts.mock.calls.length, 0);
});

View File

@@ -0,0 +1,109 @@
import { test, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import Updater from '../../src/app/updater/updaters/Updater.js';
import { fakeSplatNet, buckets, json, keys, setSessionEnvironment } from './support.js';
setSessionEnvironment();
const NAMES = {
en: { thing: 'Widget', other: 'Gadget' },
ja: { thing: 'ウィジェット', other: 'ガジェット' },
};
function thingsFor(language) {
const names = NAMES[language] ?? NAMES.en;
return { things: [{ id: '1', name: names.thing, image: '/images/things/one.png' }, { id: '2', name: names.other, image: '/images/things/two.png' }] };
}
class ThingsUpdater extends Updater {
constructor(buckets, options = {}) {
super({
name: 'Things',
filename: 'things.json',
calendarFilename: 'things.ics',
request: splatnet => splatnet.getResponse('things'),
imagePaths: ['$..image'],
localization: [{ name: 'things', entities: '$.things[*]', id: 'id', values: 'name' }],
...options,
}, buckets);
}
getCalendarEntries(data) {
return data.things.map(thing => ({ id: `thing-${thing.id}`, title: thing.name, start_time: 3600, end_time: 7200 }));
}
}
let b;
beforeEach(() => { b = buckets(); });
afterEach(() => mock.restoreAll());
test('publishes data, locale document, and calendar with the right metadata', async () => {
const splatnet = fakeSplatNet({ '/api/things': ({ language }) => thingsFor(language) });
await new ThingsUpdater(b).update();
assert.deepEqual(await json(b.publicBucket, 'data/things.json'), thingsFor('en'));
assert.deepEqual((await b.publicBucket.head('data/things.json')).httpMetadata, {
contentType: 'application/json',
cacheControl: 'no-cache, stale-while-revalidate=5, stale-if-error=86400',
});
assert.deepEqual(await json(b.publicBucket, 'data/locale/en.json'), { things: { 1: { name: 'Widget' }, 2: { name: 'Gadget' } } });
assert.deepEqual(await json(b.publicBucket, 'data/locale/ja.json'), { things: { 1: { name: 'ウィジェット' }, 2: { name: 'ガジェット' } } });
const ics = await (await b.publicBucket.get('data/things.ics')).text();
assert.match(ics, /SUMMARY:Widget/);
assert.match(ics, /X-WR-CALNAME:Things/);
assert.equal((await b.publicBucket.head('data/things.ics')).httpMetadata.contentType, 'text/calendar');
assert.equal(splatnet.api[0].language, 'en');
assert.equal(splatnet.api[0].region, 'NA');
});
test('fetches each language with missing strings using that region\'s session, then stops needing to', async () => {
const splatnet = fakeSplatNet({ '/api/things': ({ language }) => thingsFor(language) });
await new ThingsUpdater(b).update();
assert.deepEqual(splatnet.api.map(r => `${r.region}/${r.language}`), [
'NA/en', 'EU/es', 'NA/es-MX', 'EU/fr', 'NA/fr-CA', 'EU/de', 'EU/nl', 'EU/it', 'EU/ru', 'JP/ja',
]);
const second = fakeSplatNet({ '/api/things': ({ language }) => thingsFor(language) });
const puts = mock.method(b.publicBucket, 'put');
await new ThingsUpdater(b).update();
assert.deepEqual(second.api.map(r => r.language), ['en']);
assert.deepEqual(puts.mock.calls.map(c => c.arguments[0]).filter(k => k.startsWith('data/locale/')), []);
});
test('mirrors only images that are missing, using one list per directory', async () => {
await b.publicBucket.put('assets/splatnet/images/things/one.png', 'existing');
const splatnet = fakeSplatNet({ '/api/things': ({ language }) => thingsFor(language) });
const list = mock.method(b.publicBucket, 'list');
const head = mock.method(b.publicBucket, 'head');
await new ThingsUpdater(b).update();
assert.deepEqual(splatnet.images.map(r => r.path), ['/images/things/two.png']);
assert.equal(splatnet.images[0].cookie, null);
assert.equal(await (await b.publicBucket.get('assets/splatnet/images/things/one.png')).text(), 'existing');
assert.deepEqual(list.mock.calls.map(c => c.arguments[0].prefix), ['assets/splatnet/images/things/']);
assert.equal(head.mock.calls.length, 0);
assert.equal((await b.publicBucket.get('assets/splatnet/images/things/two.png')).httpMetadata.contentType, 'image/png');
});
test('filters root keys, dropping hidden timeline items', async () => {
fakeSplatNet({ '/api/things': () => ({ keep: { importance: 1, x: 1 }, hidden: { importance: -1 }, extra: 'dropped' }) });
await new ThingsUpdater(b, { rootKeys: ['keep', 'hidden', 'absent'], localization: undefined, imagePaths: undefined, calendarFilename: undefined }).update();
assert.deepEqual(await json(b.publicBucket, 'data/things.json'), { keep: { importance: 1, x: 1 }, hidden: null, absent: null });
});
test('fails the run and writes nothing when SplatNet rejects the session', async () => {
fakeSplatNet({ '/api/things': () => new Response('forbidden', { status: 403 }) });
await assert.rejects(new ThingsUpdater(b).update(), /status 403/);
assert.deepEqual(keys(b.publicBucket), []);
});
test('leaves the existing locale document untouched when the update fails', async () => {
await b.publicBucket.put('data/locale/en.json', JSON.stringify({ things: { 9: { name: 'Old' } } }));
fakeSplatNet({ '/api/things': () => new Response('down', { status: 503 }) });
await assert.rejects(new ThingsUpdater(b).update());
assert.deepEqual(await json(b.publicBucket, 'data/locale/en.json'), { things: { 9: { name: 'Old' } } });
});

57
test/updater/support.js Normal file
View File

@@ -0,0 +1,57 @@
// Shared test helpers: a fake SplatNet behind global fetch, and buckets.
import { mock } from 'node:test';
import { MemoryBucket, BucketStorage } from '../../src/common/storage/index.js';
export const SESSIONS = { NA: 'na-session', EU: 'eu-session', JP: 'jp-session' };
export function setSessionEnvironment() {
process.env.NINTENDO_SESSION_ID_NA = SESSIONS.NA;
process.env.NINTENDO_SESSION_ID_EU = SESSIONS.EU;
process.env.NINTENDO_SESSION_ID_JP = SESSIONS.JP;
}
/**
* Install a fake SplatNet on global fetch. `routes` maps a path (e.g. "/api/schedules") to
* a handler receiving { language, region } and returning a JSON value; any "/images/" path
* returns a small PNG-ish payload. Every request is recorded.
*/
export function fakeSplatNet(routes = {}) {
const requests = [];
mock.method(globalThis, 'fetch', async (input, init = {}) => {
const url = new URL(input);
const headers = new Headers(init.headers);
const cookie = headers.get('Cookie');
const region = Object.entries(SESSIONS).find(([, id]) => cookie === `iksm_session=${id}`)?.[0] ?? null;
const request = { path: url.pathname, language: headers.get('Accept-Language'), cookie, region };
requests.push(request);
if (url.pathname.startsWith('/images/'))
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { headers: { 'content-type': 'image/png' } });
const route = routes[url.pathname];
if (!route)
return new Response('not found', { status: 404 });
const result = await route(request);
return result instanceof Response ? result : Response.json(result);
});
return {
requests,
get api() { return requests.filter(r => r.path.startsWith('/api/')); },
get images() { return requests.filter(r => r.path.startsWith('/images/')); },
};
}
/**
* Fresh in-memory buckets plus BucketStorage over each. The one object serves both as the
* updaters' storage argument and as a handle on the raw buckets for assertions.
*/
export function buckets() {
const publicBucket = new MemoryBucket, privateBucket = new MemoryBucket;
return { publicBucket, privateBucket, publicStorage: new BucketStorage(publicBucket), privateStorage: new BucketStorage(privateBucket) };
}
export async function json(bucket, key) {
const object = await bucket.get(key);
return object ? object.json() : null;
}
export const keys = bucket => [...bucket.objects.keys()].sort();

View File

@@ -0,0 +1,140 @@
import { test, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import SchedulesUpdater from '../../src/app/updater/updaters/SchedulesUpdater.js';
import CoopSchedulesUpdater from '../../src/app/updater/updaters/CoopSchedulesUpdater.js';
import TimelineUpdater from '../../src/app/updater/updaters/TimelineUpdater.js';
import MerchandisesUpdater from '../../src/app/updater/updaters/MerchandisesUpdater.js';
import FestivalsUpdater from '../../src/app/updater/updaters/FestivalsUpdater.js';
import OriginalGearImageUpdater from '../../src/app/updater/updaters/OriginalGearImageUpdater.js';
import { createUpdaters } from '../../src/app/updater/updateAll.js';
import { getTopOfCurrentHour, readJsonFile } from '../../src/common/utilities.js';
import { fakeSplatNet, buckets, json, keys, setSessionEnvironment } from './support.js';
setSessionEnvironment();
let b;
beforeEach(() => { b = buckets(); });
afterEach(() => mock.restoreAll());
const stage = (id, name) => ({ id, name, image: `/images/stage/${id}.png` });
const rotation = (start, a, c, rule = 'turf_war') => ({
id: start, start_time: start, end_time: start + 7200,
stage_a: a, stage_b: c,
game_mode: { key: 'regular', name: 'Regular Battle' },
rule: { key: rule, name: 'Turf War', multiline_name: 'Turf\nWar' },
});
test('createUpdaters builds every updater against the given buckets', () => {
const updaters = createUpdaters(b);
assert.deepEqual(updaters.map(u => u.options.name), [
'Original Gear', 'Schedules', 'Co-op Schedules', 'Timeline', 'Festivals NA', 'Festivals EU', 'Festivals JP', 'Merchandises',
]);
assert.ok(updaters.every(u => u.publicStorage === b.publicStorage && u.privateStorage === b.privateStorage));
});
test('schedules: seeds the known stages from SplatNet and records new stages once', async () => {
const reef = stage('0', 'The Reef'), fitness = stage('1', 'Musselforge Fitness'), skipper = stage('22', 'Skipper Pavilion');
const schedules = { regular: [rotation(7200, skipper, reef), rotation(3600, reef, fitness)], gachi: [rotation(3600, reef, fitness)], league: [rotation(3600, fitness, reef)] };
const splatnet = fakeSplatNet({
'/api/schedules': () => schedules,
'/api/data/stages': () => ({ stages: [reef, fitness] }),
});
await new SchedulesUpdater(b).update();
assert.deepEqual(await json(b.publicBucket, 'data/schedules.json'), schedules);
assert.deepEqual(await json(b.privateBucket, 'stages.json'), [
{ ...reef, first_seen: -1, first_available: -1 },
{ ...fitness, first_seen: -1, first_available: -1 },
{ ...skipper, first_seen: getTopOfCurrentHour(), first_available: 7200 },
]);
assert.ok(splatnet.api.some(r => r.path === '/api/data/stages'));
const second = fakeSplatNet({ '/api/schedules': () => schedules });
const puts = mock.method(b.privateBucket, 'put');
await new SchedulesUpdater(b).update();
assert.equal(second.api.filter(r => r.path === '/api/data/stages').length, 0);
assert.equal(puts.mock.calls.length, 0);
});
test('coop: publishes the calendar with stage and weapon details', async () => {
const coop = {
schedules: [{ start_time: 3600, end_time: 7200 }, { start_time: 90000, end_time: 93600 }],
details: [{ start_time: 3600, end_time: 7200, stage: { name: 'Spawning Grounds', image: '/images/coop_stage/a.png' }, weapons: [{ id: '0', weapon: { id: '0', name: 'Splattershot', image: '/images/weapon/0.png', sub: { id: '1', name: 'Bomb' }, special: { id: '2', name: 'Special' } } }, null] }],
};
fakeSplatNet({ '/api/coop_schedules': () => coop });
await new CoopSchedulesUpdater(b).update();
const ics = await (await b.publicBucket.get('data/coop-schedules.ics')).text();
assert.match(ics, /SUMMARY:Salmon Run on Spawning Grounds/);
assert.match(ics, /Splattershot/);
assert.match(ics, /Random/);
assert.match(ics, /SUMMARY:Salmon Run\r?\n/); // the shift without details keeps the plain title
assert.deepEqual(await json(b.publicBucket, 'data/coop-schedules.json'), coop);
});
test('timeline: keeps only coop and weapon availability, dropping hidden items', async () => {
fakeSplatNet({ '/api/timeline': () => ({ coop: { importance: -1 }, weapon_availability: { importance: 1, availabilities: [] }, stats: { ignored: true } }) });
await new TimelineUpdater(b).update();
assert.deepEqual(await json(b.publicBucket, 'data/timeline.json'), { coop: null, weapon_availability: { importance: 1, availabilities: [] } });
});
test('merchandises: attaches original gear from the bundled data without its brand', async () => {
const gearData = readJsonFile(new URL('../../src/common/data/gear.json', import.meta.url));
const original = gearData.head[0];
const brand = { id: '0', name: 'Brand', image: '/images/brand/0.png', frequent_skill: { id: '0', name: 'Skill', image: '/images/skill/0.png' } };
const skill = { id: '0', name: 'Skill', image: '/images/skill/0.png' };
fakeSplatNet({ '/api/onlineshop/merchandises': () => ({
merchandises: [
{ end_time: 7200, gear: { kind: 'head', id: '1', name: original.name.toUpperCase(), image: '/images/gear/1.png', brand }, skill },
{ end_time: 7200, gear: { kind: 'head', id: '2', name: 'No Such Gear', image: '/images/gear/2.png', brand }, skill },
],
extra: 'dropped',
}) });
await new MerchandisesUpdater(b).update();
const data = await json(b.publicBucket, 'data/merchandises.json');
assert.deepEqual(Object.keys(data), ['merchandises']);
assert.equal(data.merchandises[0].original_gear.name, original.name);
assert.equal('brand' in data.merchandises[0].original_gear, false);
assert.ok(data.merchandises[0].original_gear.skill);
assert.equal(data.merchandises[1].original_gear, null);
});
test('festivals: merges one region into the shared file, fetches missing rankings, uses regional languages', async () => {
await b.publicBucket.put('data/festivals.json', JSON.stringify({ eu: { festivals: [], results: [] } }));
await b.publicBucket.put('data/festivals/na-1-rankings.json', JSON.stringify({ existing: true }));
const festival = id => ({ festival_id: id, names: { alpha_short: 'A', bravo_short: 'B' }, times: { start: 3600, end: 7200 }, images: { alpha: '/images/festival/a.png', bravo: '/images/festival/b.png', panel: '/images/festival/p.png' }, special_stage: { id: '100', name: 'Shifty Station', image: '/images/stage/100.png' } });
const splatnet = fakeSplatNet({
'/api/festivals/active': () => ({ festivals: [festival(2)] }),
'/api/festivals/pasts': () => ({ festivals: [festival(1)], results: [{ festival_id: 1 }, { festival_id: 2 }] }),
'/api/festivals/2/rankings': () => ({ rankings: 'two' }),
});
await new FestivalsUpdater('NA', b).update();
const data = await json(b.publicBucket, 'data/festivals.json');
assert.deepEqual(Object.keys(data).sort(), ['eu', 'na']);
assert.deepEqual(data.na.festivals.map(f => f.festival_id), [2, 1]);
assert.deepEqual(await json(b.publicBucket, 'data/festivals/na-1-rankings.json'), { existing: true });
assert.deepEqual(await json(b.publicBucket, 'data/festivals/na-2-rankings.json'), { rankings: 'two' });
assert.deepEqual(splatnet.api.filter(r => r.path.endsWith('/rankings')).map(r => r.path), ['/api/festivals/2/rankings']);
// Only this region's languages are localized (NA: en, es-MX, fr-CA), all with the NA session
const localized = splatnet.api.filter(r => r.path === '/api/festivals/active');
assert.deepEqual(localized.map(r => `${r.region}/${r.language}`), ['NA/en', 'NA/es-MX', 'NA/fr-CA']);
const ics = await (await b.publicBucket.get('data/festivals-na.ics')).text();
assert.match(ics, /X-WR-CALNAME:Splatfests \(NA\)/);
assert.match(ics, /SUMMARY:NA Splatfest: A vs. B/);
});
test('original gear: mirrors skill images from the bundled skills data', async () => {
const skills = readJsonFile(new URL('../../src/common/data/skills.json', import.meta.url));
const images = Object.values(skills).map(s => s.image).filter(Boolean);
await b.publicBucket.put(`assets/splatnet${images[0]}`, 'existing');
const splatnet = fakeSplatNet();
await new OriginalGearImageUpdater(b).update();
assert.equal(splatnet.images.length, images.length - 1);
assert.equal(keys(b.publicBucket).length, images.length);
assert.ok(keys(b.publicBucket).every(k => k.startsWith('assets/splatnet/images/skill/')));
});