mirror of
https://github.com/misenhower/splatoon2.ink.git
synced 2026-10-02 06:06:51 -05:00
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:
70
test/storage/BucketStorage.test.js
Normal file
70
test/storage/BucketStorage.test.js
Normal 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);
|
||||
});
|
||||
35
test/storage/MemoryBucket.test.js
Normal file
35
test/storage/MemoryBucket.test.js
Normal 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);
|
||||
});
|
||||
51
test/storage/storage-contract.test.js
Normal file
51
test/storage/storage-contract.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
54
test/updater/LocalizationProcessor.test.js
Normal file
54
test/updater/LocalizationProcessor.test.js
Normal 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);
|
||||
});
|
||||
109
test/updater/Updater.test.js
Normal file
109
test/updater/Updater.test.js
Normal 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
57
test/updater/support.js
Normal 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();
|
||||
140
test/updater/updaters.test.js
Normal file
140
test/updater/updaters.test.js
Normal 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/')));
|
||||
});
|
||||
Reference in New Issue
Block a user