// Official emotes (research 09 §4 / §6.6): the 36 in-match emotes of 盟约 (6 themes × 6), their generated reference // data/emotes.json (tools/build-emotes.mjs), the local-client extraction (tools/local-extract/extract.py), the protocol // whitelist and the pure helpers of public/js/ui/emotes.js. Browser behaviour (picture-only bubbles, pager, cooldown) // is covered by test/ui/emotes.e2e.test.js (SP_E2E=1). import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, existsSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { EMOTES, EMOTE_THEMES, EMOTE_CATALOG, EMOTE_THEME, EMOTE_LABEL, EMOTE_COOLDOWN_MS, EMOTE_BUBBLE_MS, emoteInfo, emoteArtPath, emoteArtGroup, } from '../../shared/constants.js'; import { validateC2S } from '../../shared/protocol.js'; import { buildEmotes, formatEmotes, THEME_DIRS } from '../../tools/build-emotes.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8')); const tryJson = (rel) => { try { return readJson(rel); } catch { return null; } }; const EMOTES_JSON = readJson('data/emotes.json'); const ENABLED = ['emoticon_autochess_basic', 'emoticon_originium_slug', 'emoticon_autochess_basic_2', 'emoticon_foolsday_doctor', 'emoticon_foolsday_amiya', 'emoticon_foolsday_wisdel']; describe('emote catalog (shared/constants.js)', () => { test('6 themes × 6 battle emotes = 36 unique official ids, in wheel order', () => { assert.deepEqual(EMOTE_THEMES.map((t) => t.themeId), ENABLED, 'pages follow autoChessData.enabledEmoticonThemeIdList'); for (const t of EMOTE_THEMES) { assert.equal(t.emotes.length, 6, t.themeId); const sorts = t.emotes.map((e) => e.sortId); assert.deepEqual(sorts, [...sorts].sort((a, b) => a - b), `${t.themeId} by sortId`); } assert.equal(EMOTES.length, 36); assert.equal(new Set(EMOTES).size, 36); assert.deepEqual([...EMOTES], EMOTE_CATALOG.map((e) => e.id)); for (const id of EMOTES) assert.match(id, /^(slug_)?autochess_battle_[a-z0-9_]+$/, id); assert.ok(Object.isFrozen(EMOTES) && Object.isFrozen(EMOTE_THEMES) && Object.isFrozen(EMOTE_THEMES[0].emotes)); }); test('picId comes from the table, not from the id (fooldoctor 03–06 → pics 04/05/06/08)', () => { const doc = EMOTE_THEMES.find((t) => t.themeId === 'emoticon_foolsday_doctor'); assert.deepEqual(doc.emotes.map((e) => e.picId), ['pic_fooldoctor_01_battle', 'pic_fooldoctor_02_battle', 'pic_fooldoctor_04_battle', 'pic_fooldoctor_05_battle', 'pic_fooldoctor_06_battle', 'pic_fooldoctor_08_battle']); assert.equal(emoteArtPath('autochess_battle_fooldoctor_06'), '/assets/local/emoticon/fooldoctor/pic_fooldoctor_08_battle.png'); assert.equal(emoteArtPath('autochess_battle_nice_cooperate'), '/assets/local/emoticon/basic/pic_cooperate_battle.png'); assert.equal(emoteArtPath('slug_autochess_battle_thanks'), '/assets/local/emoticon/slug/pic_thanks_battle.png'); assert.equal(emoteArtGroup('autochess_battle_dying'), 'emoticon/basic_2'); for (const e of EMOTE_CATALOG) assert.match(e.picId, /^pic_.+_battle$/, e.id); }); test('lookups are safe for any input', () => { for (const bad of ['happy', 'autochess_room_hello', '__proto__', 'constructor', 'toString', '', null, undefined, 42, {}]) { assert.equal(emoteInfo(bad), null, String(bad)); assert.equal(emoteArtPath(bad), null, String(bad)); assert.equal(emoteArtGroup(bad), null, String(bad)); } assert.equal(emoteInfo('autochess_battle_call').themeId, 'emoticon_autochess_basic_2'); assert.equal(EMOTE_THEME.slug_autochess_battle_bye, 'emoticon_originium_slug'); assert.equal(typeof EMOTE_LABEL.autochess_battle_happy, 'string'); for (const bad of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) { assert.equal(EMOTE_THEME[bad], undefined, `EMOTE_THEME[${bad}]`); assert.equal(EMOTE_LABEL[bad], undefined, `EMOTE_LABEL[${bad}]`); } assert.equal(Object.keys(EMOTE_THEME).length, 36); assert.ok(Object.isFrozen(EMOTE_THEME) && Object.isFrozen(EMOTE_LABEL)); }); test('protocol accepts exactly the 36 official battle ids (g.emote { id })', () => { for (const id of EMOTES) assert.equal(validateC2S({ t: 'g.emote', id }), null, id); for (const id of ['happy', 'thanks', 'autochess_room_hello', 'multiv3_battle_thanks', 'duel_battle_happy', '__proto__', '', 7, null]) { assert.notEqual(validateC2S({ t: 'g.emote', id }), null, String(id)); } }); test('cooldown and bubble time are the official chatCD / chatTime', () => { assert.equal(EMOTE_COOLDOWN_MS, EMOTES_JSON.chatCD * 1000); assert.equal(EMOTE_BUBBLE_MS, EMOTES_JSON.chatTime * 1000); assert.equal(EMOTE_COOLDOWN_MS, 1000); assert.equal(EMOTE_BUBBLE_MS, 3000); // the client reads the bubble time from config.timers (game.js) and the server rate-limits with the constant const timers = tryJson('data/config.json')?.timers; if (timers) assert.deepEqual([timers.chatCd * 1000, timers.chatBubble * 1000], [EMOTE_COOLDOWN_MS, EMOTE_BUBBLE_MS]); }); }); describe('data/emotes.json (tools/build-emotes.mjs)', () => { test('is identical to the shared/constants.js table', () => { assert.deepEqual(EMOTES_JSON.themes.map(({ themeId, dir, sortId, isBasic, name, emotes }) => ({ themeId, dir, sortId, isBasic, name, emotes })), EMOTE_THEMES.map((t) => ({ themeId: t.themeId, dir: t.dir, sortId: t.sortId, isBasic: t.isBasic, name: t.name, emotes: t.emotes.map((e) => e.id) }))); assert.deepEqual(EMOTES_JSON.emotes, EMOTE_CATALOG.map((e) => ({ id: e.id, themeId: e.themeId, sortId: e.sortId, picId: e.picId, art: emoteArtPath(e.id), label: e.label, }))); }); test('regenerating from the cached official tables reproduces it byte for byte', { skip: !existsSync(path.join(ROOT, '.cache/gamedata/excel/display_meta_table.json')) && 'no cached display_meta_table.json', }, () => { const { doc, warnings } = buildEmotes({ display: readJson('.cache/gamedata/excel/display_meta_table.json'), activity: readJson('.cache/gamedata/excel/activity_table.json'), items: tryJson('.cache/gamedata/excel/item_table.json'), }); assert.deepEqual(warnings, []); assert.equal(formatEmotes(doc), readFileSync(path.join(ROOT, 'data/emotes.json'), 'utf8')); }); test('builder: scene filter, enabled-list page order, sortId order, picId from the table, fallbacks', () => { const e = (id, type, sortId, picId) => ({ id, type, sortId, picId, desc: null }); const display = { emoticonData: { emojiDataDict: { a_room: e('a_room', 'AUTOCHESS_ROOM', 1, 'pic_a'), a_2: e('a_2', 'AUTOCHESS_BATTLE', 20, 'pic_a2_battle'), a_1: e('a_1', 'AUTOCHESS_BATTLE', 10, 'pic_a9_battle'), b_1: e('b_1', 'AUTOCHESS_BATTLE', 5, 'pic_b_battle'), b_multi: e('b_multi', 'ACTMULTIV3_BATTLE', 1, 'pic_m_battle'), autochess_battle_happy: e('autochess_battle_happy', 'AUTOCHESS_BATTLE', 1, 'pic_happy_battle'), }, emoticonThemeDataDict: { emoticon_x: ['a_room', 'a_2', 'a_1'], emoticon_originium_slug: ['b_multi', 'b_1'], emoticon_empty: ['a_room'] }, emoticonThemeTypeDict: { emoticon_x: { sortId: 7, isBasic: true }, emoticon_originium_slug: { sortId: 1001, isBasic: false } }, } }; const activity = { autoChessData: { enabledEmoticonThemeIdList: ['emoticon_originium_slug', 'emoticon_missing', 'emoticon_x', 'emoticon_empty'], constData: { chatCD: 1, chatTime: 3 }, } }; const items = { items: { emoticon_originium_slug: { name: '表情套组:虫动' } } }; const { doc, warnings } = buildEmotes({ display, activity, items }); assert.deepEqual(doc.themes.map((t) => [t.themeId, t.dir, t.emotes]), [ ['emoticon_originium_slug', 'slug', ['b_1']], ['emoticon_x', 'x', ['a_1', 'a_2']], ]); assert.equal(doc.emotes.find((x) => x.id === 'a_1').art, '/assets/local/emoticon/x/pic_a9_battle.png'); assert.equal(doc.emotes.find((x) => x.id === 'a_1').label, 'emoticon_x 1', 'no official text: generic aria label'); assert.equal(doc.emotes.find((x) => x.id === 'b_1').label, '虫动 1'); assert.equal(doc.themes[0].name, '表情套组:虫动'); assert.deepEqual([doc.chatCD, doc.chatTime], [1, 3]); assert.equal(warnings.length, 2, warnings.join('; ')); assert.throws(() => buildEmotes({ display: {}, activity }), /emoticonData/); assert.throws(() => buildEmotes({ display, activity: {} }), /enabledEmoticonThemeIdList/); const text = formatEmotes(doc); assert.deepEqual(JSON.parse(text), doc); assert.equal(formatEmotes(buildEmotes({ display, activity, items }).doc), text, 'deterministic'); }); test('builder: an emoji listed by two themes / a theme listed twice is kept once; art dirs must be unique', () => { const e = (id, sortId, picId = `pic_${id}_battle`) => ({ id, type: 'AUTOCHESS_BATTLE', sortId, picId, desc: null }); const display = { emoticonData: { emojiDataDict: { a: e('a', 1), b: e('b', 2), c: e('c', 3), nopic: { id: 'nopic', type: 'AUTOCHESS_BATTLE', sortId: 4, picId: null } }, emoticonThemeDataDict: { emoticon_one: ['a', 'b', 'nopic', 'toString'], emoticon_two: ['b', 'c', '__proto__'] }, emoticonThemeTypeDict: {}, } }; const activity = { autoChessData: { enabledEmoticonThemeIdList: ['emoticon_one', 'emoticon_two', 'emoticon_one', 'constructor'] } }; const { doc, warnings } = buildEmotes({ display, activity }); assert.deepEqual(doc.themes.map((t) => [t.themeId, t.emotes]), [['emoticon_one', ['a', 'b']], ['emoticon_two', ['c']]]); assert.deepEqual(doc.emotes.map((x) => x.id), ['a', 'b', 'c'], 'every protocol id once'); assert.equal(new Set(doc.emotes.map((x) => x.art)).size, 3); assert.ok(warnings.some((w) => /^b: already on an earlier page/.test(w)), warnings.join('; ')); assert.ok(warnings.some((w) => /emoticon_one: listed twice/.test(w))); assert.ok(warnings.some((w) => /constructor: not in emoticonThemeDataDict/.test(w)), 'prototype keys are not themes'); assert.throws(() => buildEmotes({ display: { emoticonData: { ...display.emoticonData, emoticonThemeDataDict: { emoticon_x: ['a'], emticon_x: ['c'] } } }, activity: { autoChessData: { enabledEmoticonThemeIdList: ['emoticon_x', 'emticon_x'] } } }), /already used/); }); test('theme dirs agree between the builder, the constants and data/emotes.json', () => { for (const t of EMOTE_THEMES) assert.equal(THEME_DIRS[t.themeId], t.dir, t.themeId); assert.equal(Object.keys(THEME_DIRS).length, EMOTE_THEMES.length); }); }); const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0); const PY_ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' }; describe('local-client extraction (tools/local-extract/extract.py)', () => { test('extracts every emote theme (battle sprites only) into emoticon/', { skip: !PY && 'no python3' }, () => { const r = spawnSync(PY, [path.join(ROOT, 'tools/local-extract/extract.py'), '--print-jobs'], { encoding: 'utf8', env: PY_ENV }); assert.equal(r.status, 0, r.stderr); const { emoteThemes, jobs } = JSON.parse(r.stdout); assert.deepEqual(emoteThemes, EMOTE_THEMES.map((t) => ({ themeId: t.themeId, dir: t.dir }))); for (const t of EMOTE_THEMES) { const job = jobs.find((j) => j.sub === `emoticon/${t.dir}`); assert.ok(job, t.themeId); assert.equal(job.bundle, `ui/emoticon/theme/[uc]${t.themeId}.ab`); assert.deepEqual(job.kinds, ['Sprite'], 'the Sprite (true aspect), not the padded Texture2D'); const keep = new RegExp(job.keep); for (const e of t.emotes) assert.ok(keep.test(e.picId), e.picId); for (const other of ['pic_hello', 'pic_thanks', 'pic_bg', 'pic_nice_work', 'pic_very_soon']) assert.ok(!keep.test(other), other); } // the other jobs are untouched for (const sub of ['map/autochess', 'ui/battle', 'ui/outer', 'guide']) assert.ok(jobs.some((j) => j.sub === sub && j.keep === null), sub); }); test('--only re-extracts a subset and merges it into the existing manifest', { skip: !PY && 'no python3' }, () => { const code = `import sys, json; sys.path.insert(0, ${JSON.stringify(path.join(ROOT, 'tools/local-extract'))}); import extract as e print(json.dumps({ 'emo': [j[1] for j in e.select_jobs(['emoticon'])], 'slug': [j[1] for j in e.select_jobs(['emoticon/slug/'])], 'all': len(e.select_jobs([])) == len(e.JOBS), 'none': e.select_jobs(['emo']), 'merged': e.merge_manifest({'ui/battle': {'b': 1, 'a': 2}, 'emoticon/slug': {'old': 1}, 'emoticon/basic': {'keep': 1}}, {'emoticon/slug': {'pic_z_battle': 1, 'pic_a_battle': 2}}, {'emoticon/slug'}), }))`; const r = spawnSync(PY, ['-c', code], { encoding: 'utf8', env: PY_ENV }); assert.equal(r.status, 0, r.stderr); const out = JSON.parse(r.stdout); assert.deepEqual(out.emo, EMOTE_THEMES.map((t) => `emoticon/${t.dir}`)); assert.deepEqual(out.slug, ['emoticon/slug']); assert.equal(out.all, true); assert.deepEqual(out.none, [], 'prefixes match whole path segments'); assert.deepEqual(out.merged, { 'emoticon/basic': { keep: 1 }, 'emoticon/slug': { pic_a_battle: 2, pic_z_battle: 1 }, 'ui/battle': { a: 2, b: 1 } }); assert.deepEqual(Object.keys(out.merged), ['emoticon/basic', 'emoticon/slug', 'ui/battle'], 'sorted, deterministic'); }); const manifest = tryJson('data/local-assets.json'); test('the extracted manifest lists all 36 pictures under their picId', { skip: !manifest && 'no data/local-assets.json' }, () => { for (const e of EMOTE_CATALOG) { const entry = manifest.groups[emoteArtGroup(e.id)]?.[e.picId]; assert.ok(entry, `${e.id} → ${e.picId}`); assert.equal(entry.path, emoteArtPath(e.id)); assert.ok(entry.w >= 64 && entry.h >= 64 && entry.w <= 160 && entry.h <= 160, `${e.picId} ${entry.w}×${entry.h}`); assert.equal(entry.kind, 'Sprite'); } for (const t of EMOTE_THEMES) { const names = Object.keys(manifest.groups[`emoticon/${t.dir}`]); assert.deepEqual(names.sort(), t.emotes.map((e) => e.picId).sort(), `${t.dir}: only the battle sprites`); } for (const n of ['emoji_bubble_bkg', 'emoji_bkg', 'emoji_cell_bkg', 'emoji_btn', 'emoji_btn_disable']) assert.ok(manifest.groups['ui/battle']?.[n], n); }); test('the listed pictures exist on disk', { skip: (!manifest || !existsSync(path.join(ROOT, 'public/assets/local/emoticon'))) && 'no extracted art' }, () => { for (const e of EMOTE_CATALOG) { const file = path.join(ROOT, 'public', emoteArtPath(e.id)); assert.ok(existsSync(file), file); const png = readFileSync(file); assert.equal(png.subarray(1, 4).toString(), 'PNG', file); } }); }); describe('public/js/ui/emotes.js helpers', () => { const store = new Map(); globalThis.localStorage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => { store.set(k, String(v)); }, removeItem: (k) => { store.delete(k); }, }; const manifest = { version: 1, groups: { 'emoticon/fooldoctor': { pic_fooldoctor_08_battle: { path: '/assets/local/emoticon/fooldoctor/pic_fooldoctor_08_battle.png', w: 115, h: 115 } }, 'ui/battle': { emoji_bubble_bkg: { path: '/assets/local/ui/battle/emoji_bubble_bkg.png', w: 108, h: 97 } }, } }; const realFetch = globalThis.fetch; test('art URLs come only from what the manifests list (here the local one, keyed by picId; data/assets.json absent); unknown / unlisted → null', async () => { globalThis.fetch = async (url) => (String(url).endsWith('/local-assets.json') ? { ok: true, status: 200, json: async () => manifest } : { ok: false, status: 404, json: async () => ({}) }); try { const { data } = await import('../../public/js/data.js'); await data.invalidate('local'); await data.load('local'); const { emoteArtUrl, emoteUiSprite } = await import('../../public/js/ui/emotes.js'); assert.equal(emoteArtUrl('autochess_battle_fooldoctor_06'), '/assets/local/emoticon/fooldoctor/pic_fooldoctor_08_battle.png'); assert.equal(emoteArtUrl('autochess_battle_fooldoctor_05'), null, 'unlisted'); assert.equal(emoteArtUrl('happy'), null, 'v1 id'); assert.equal(emoteArtUrl('__proto__'), null); assert.equal(emoteUiSprite('emoji_bubble_bkg'), '/assets/local/ui/battle/emoji_bubble_bkg.png'); assert.equal(emoteUiSprite('emoji_bkg'), null); } finally { globalThis.fetch = realFetch; } }); test('pager helpers: clamp, swipe threshold, remembered theme', async () => { const { clampPage, swipeStep, themeIndex, lastThemeIndex, rememberTheme } = await import('../../public/js/ui/emotes.js'); assert.deepEqual([clampPage(-3), clampPage(2), clampPage(99), clampPage('x'), clampPage(2.7)], [0, 2, 5, 0, 2]); assert.deepEqual([swipeStep(-60), swipeStep(60), swipeStep(-10), swipeStep(0), swipeStep(-40)], [1, -1, 0, 0, 1]); assert.equal(themeIndex('emoticon_foolsday_amiya'), 4); assert.equal(themeIndex('nope'), 0); store.clear(); assert.equal(lastThemeIndex(), 0, 'first page by default'); rememberTheme('emoticon_foolsday_wisdel'); assert.equal(lastThemeIndex(), 5); rememberTheme('bogus'); assert.equal(lastThemeIndex(), 5, 'unknown themes are not stored'); store.set('sp.pref.emoteTheme', '"emoticon_gone"'); assert.equal(lastThemeIndex(), 0, 'a stale theme falls back to the first page'); store.set('sp.pref.emoteTheme', '{bad json'); assert.equal(lastThemeIndex(), 0); }); test('wheel paging: one page per gesture — a trackpad swipe\'s momentum tail never skips themes', async () => { const { wheelStep, wheelDelta, WHEEL_GAP_MS, WHEEL_STEP_PX } = await import('../../public/js/ui/emotes.js'); // one macOS trackpad swipe: 60 events 16 ms apart, decaying deltaX (≈ 750 px in total) const acc = { x: 0, t: -Infinity, spent: false }; let t = 1000; const swipe = (sign) => { const steps = []; for (let i = 0; i < 60; i++, t += 16) steps.push(wheelStep(acc, sign * Math.max(2, 40 * 0.95 ** i), t)); return steps.filter(Boolean); }; assert.deepEqual(swipe(1), [1], 'the whole swipe turns exactly one page'); t += WHEEL_GAP_MS - 20; assert.deepEqual(swipe(1), [], 'a tail that continues without a pause is still the same gesture'); t += WHEEL_GAP_MS + 1; assert.deepEqual(swipe(-1), [-1], 'a new gesture after a pause turns the page again (backwards)'); // mouse wheel notches (100 px) with pauses: one page per notch; small deltas accumulate t += 1000; assert.equal(wheelStep(acc, 100, t), 1); t += 400; assert.equal(wheelStep(acc, 100, t), 1); t += 400; assert.equal(wheelStep(acc, WHEEL_STEP_PX / 2, t), 0); assert.equal(wheelStep(acc, WHEEL_STEP_PX / 2, t + 16), 1); assert.equal(wheelStep(acc, 0, t + 1000), 0); assert.equal(wheelStep({ x: 0, t: 5000, spent: true }, 100, 10), 1, 'a clock that went backwards starts a new gesture'); // Unity ScrollRect rule: a mostly vertical wheel drives the horizontal pager (down = next); lines / pages → px assert.equal(wheelDelta({ deltaX: 0, deltaY: 100 }), 100); assert.equal(wheelDelta({ deltaX: 0, deltaY: -100 }), -100); assert.equal(wheelDelta({ deltaX: 30, deltaY: 10 }), 30); assert.equal(wheelDelta({ deltaX: 0, deltaY: 3, deltaMode: 1 }), 48); assert.equal(wheelDelta({ deltaX: 1, deltaY: 0, deltaMode: 2 }, 271), 271); assert.equal(wheelDelta({}), 0); assert.equal(wheelDelta(null), 0); }); test('cooldown and bubble timelines are computed from timestamps (robust to remounts and clock jumps)', async () => { const { cooldownLeft, bubbleAge } = await import('../../public/js/ui/emotes.js'); assert.equal(cooldownLeft(-Infinity, 5000), 0, 'never sent'); assert.equal(cooldownLeft(5000, 5000), EMOTE_COOLDOWN_MS); assert.equal(cooldownLeft(5000, 5400), 600); assert.equal(cooldownLeft(5000, 6000), 0); assert.equal(cooldownLeft(5000, 1000), 0, 'a clock that went backwards never locks the button'); assert.equal(cooldownLeft(5000, 5100, 500), 400); assert.equal(bubbleAge(undefined, 9000), 0); assert.equal(bubbleAge(8000, 9000), 1000); assert.equal(bubbleAge(8000, 7000), 0, 'future timestamps start fresh'); assert.equal(bubbleAge(1000, 9000), EMOTE_BUBBLE_MS, 'capped at the display time'); assert.equal(bubbleAge(NaN, 9000), 0); }); test('ensureEmoteCss links public/css/emotes.css once, and is a no-op without a document', async () => { const { ensureEmoteCss, EMOTE_CSS_HREF } = await import('../../public/js/ui/emotes.js'); const links = []; const doc = { head: { appendChild: (n) => links.push(n) }, createElement: () => ({}), querySelector: () => (links.length ? links[0] : null), }; assert.equal(ensureEmoteCss(doc), true); assert.equal(ensureEmoteCss(doc), false); assert.deepEqual(links, [{ rel: 'stylesheet', href: EMOTE_CSS_HREF }]); assert.equal(ensureEmoteCss(undefined), false); assert.ok(existsSync(path.join(ROOT, 'public', EMOTE_CSS_HREF))); assert.match(readFileSync(path.join(ROOT, 'public/index.html'), 'utf8'), /href="\/css\/emotes\.css"/); }); test('a hung emote manifest is missing when the clock fires (glyph), and a later retry replaces it', async () => { const { createDataStore } = await import('../../public/js/data.js'); const timers = []; let calls = 0; let releaseWait; const store = createDataStore({ timeoutMs: 1000, retryDelays: [0], wait: () => new Promise((r) => { releaseWait = r; }), setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, clearTimeout: () => {}, fetch: () => { calls++; if (calls === 1) return new Promise(() => {}); return Promise.resolve({ ok: true, status: 200, json: async () => ({ ok: true, groups: { a: { pic: { path: '/p.png' } } } }) }); }, }); const states = []; store.subscribe(() => states.push(store.status('local'))); const p = store.load('local'); assert.equal(store.status('local'), 'loading'); assert.equal(timers.length, 1); assert.equal(timers[0].ms, 1000); timers[0].fn(); await new Promise((r) => setImmediate(r)); assert.equal(store.status('local'), 'missing', 'glyph as soon as the attempt times out, before the retry'); assert.equal(calls, 1, 'still on the first attempt while the retry waits'); releaseWait(); const value = await p; assert.equal(calls, 2); assert.equal(store.status('local'), 'ready'); assert.equal(value.ok, true); assert.equal(store.get('local').groups.a.pic.path, '/p.png'); assert.deepEqual(states, ['missing', 'ready']); for (const t of timers) t.fn(); assert.equal(store.status('local'), 'ready', 'a timer that fires after success does not demote the file'); }); test('a failed emote manifest (no clock) is missing before the retry, then ready; a 404 stays missing', async () => { const { createDataStore } = await import('../../public/js/data.js'); let calls = 0; const store = createDataStore({ timeoutMs: 1000, retryDelays: [0], wait: async () => {}, setTimeout: () => 1, clearTimeout: () => {}, fetch: async () => { calls++; if (calls === 1) throw new TypeError('Failed to fetch'); return { ok: true, status: 200, json: async () => ({ groups: { emoticon: {} } }) }; }, }); const states = []; store.subscribe(() => states.push(store.status('assets'))); const value = await store.load('assets'); assert.equal(value.groups.emoticon !== undefined, true); assert.deepEqual(states, ['missing', 'ready']); const warn = console.warn; console.warn = () => {}; try { const timers = []; const gone = createDataStore({ timeoutMs: 5, retryDelays: [], setTimeout: (fn) => { timers.push(fn); return timers.length; }, clearTimeout: () => {}, fetch: () => new Promise(() => {}), }); const p = gone.load('local'); assert.equal(timers.length, 1); timers[0](); assert.equal(await p, null); assert.equal(gone.status('local'), 'missing'); let n = 0; const missing = createDataStore({ timeoutMs: 5, retryDelays: [0], wait: async () => {}, setTimeout: () => 1, clearTimeout: () => {}, fetch: async () => { n++; return { ok: false, status: 404, json: async () => ({}) }; }, }); assert.equal(await missing.load('assets'), null); assert.equal(missing.status('assets'), 'missing'); assert.equal(n, 1, 'a 404 is not retried'); } finally { console.warn = warn; } }); test('the art-manifest clock is not armed for other files; the default wait is ART_MANIFEST_TIMEOUT_MS', async () => { const { createDataStore, ART_MANIFEST_TIMEOUT_MS } = await import('../../public/js/data.js'); assert.equal(ART_MANIFEST_TIMEOUT_MS, 8000); const arms = []; let release; const store = createDataStore({ setTimeout: (fn, ms) => { arms.push(ms); return arms.length; }, clearTimeout: () => {}, fetch: (url) => new Promise((resolve) => { release = () => resolve(String(url).includes('chess') ? { ok: false, status: 404, json: async () => ({}) } : { ok: true, status: 200, json: async () => ({ ok: 1 }) }); }), }); const local = store.load('local'); assert.deepEqual(arms, [8000]); release(); assert.equal((await local).ok, 1); arms.length = 0; const warn = console.warn; console.warn = () => {}; try { const chess = store.load('chess'); await new Promise((r) => setImmediate(r)); assert.deepEqual(arms, [], 'chess is not on the emote clock'); assert.equal(store.status('chess'), 'loading'); release(); await chess; assert.equal(store.status('chess'), 'missing'); } finally { console.warn = warn; } }); test('emote UI never renders an emote label as text (aria-label only)', () => { const src = readFileSync(path.join(ROOT, 'public/js/ui/emotes.js'), 'utf8'); assert.doesNotMatch(src, /EMOTE_TEXT|EMOTE_LABEL/, 'no text table in the UI'); // every use of a label is an attribute value for (const m of src.matchAll(/\.label\b/g)) { const before = src.slice(Math.max(0, m.index - 40), m.index); assert.match(before, /aria-label=\$\{[^}]*$/, `label used outside aria-label: …${before}`); } }); });