Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
364 lines
21 KiB
JavaScript
364 lines
21 KiB
JavaScript
// 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/<dir>', { 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 from the manifest only (keyed by picId); 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('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}`);
|
||
}
|
||
});
|
||
});
|