Files
ark-stronghold-protocol/test/ui/emotes.e2e.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
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).
2026-09-29 16:21:31 +08:00

335 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Browser checks of the official emotes (research 09 §4) in headless Chrome (puppeteer-core + system Chrome):
// the UI-kit demo (/dev/uikit.html §13) and the in-match mock harness (/dev/game-mock.html). Opt-in like the other
// browser suites: SP_E2E=1 node --test test/ui/emotes.e2e.test.js → screenshots in test/e2e/out/emotes-*.png
//
// Asserted: picture-only wheel pages (3×2 per theme) and bubbles (no text anywhere), swipe / arrow keys / dots change
// the theme, sending closes the panel and greys 交流 for the 1 s cooldown, the panel reopens on the last-used theme,
// a bubble fades out after 3 s and a newer one replaces it, the neutral glyph fallback when the art is missing, the
// bubble sits beside the sender's avatar in the team panel, and zero console errors.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { EMOTE_THEMES } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const HAS_ART = existsSync(path.join(ROOT, 'public/assets/local/emoticon/fooldoctor'));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('official emotes in the browser', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--force-device-scale-factor=1'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(url, { w = 1600, h = 900, manifest = null, ignore = [] } = {}) {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h });
const problems = [];
const bad = (s) => !ignore.some((re) => re.test(s));
page.on('console', (m) => { if (m.type() === 'error' && bad(m.text())) problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
// a reload cancels the images still in flight (net::ERR_ABORTED): not a failure of the page
page.on('requestfailed', (r) => { if (bad(r.url()) && r.failure()?.errorText !== 'net::ERR_ABORTED') problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
page.on('response', (r) => { if (r.status() >= 400 && bad(r.url())) problems.push(`http ${r.status()}: ${r.url()}`); });
if (manifest) {
await page.setRequestInterception(true);
page.on('request', (req) => (req.url().endsWith('/data/local-assets.json')
? req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(manifest) })
: req.continue()));
}
await page.goto(`${base}${url}`, { waitUntil: 'networkidle0' });
return { page, problems };
}
const wheelState = (page, root = '') => page.evaluate((root) => {
const q = (s) => document.querySelector(`${root} ${s}`.trim());
const panel = q('.ewheel__panel');
return {
open: !!panel,
theme: q('.ewheel__page')?.dataset.theme || null,
items: document.querySelectorAll(`${root} .ewheel__item`.trim()).length,
imgs: [...document.querySelectorAll(`${root} .ewheel__item img`.trim())].filter((i) => i.complete && i.naturalWidth > 0).length,
glyphs: document.querySelectorAll(`${root} .ewheel__item .eart--glyph`.trim()).length,
panelText: panel ? panel.textContent.trim() : '',
btnDisabled: q('.ewheel__btn')?.disabled ?? null,
dotOn: [...document.querySelectorAll(`${root} .ewheel__dot`.trim())].findIndex((d) => d.classList.contains('is-on')),
};
}, root);
test('UI kit: pager per theme, picture-only, swipe / keys / dots, cooldown, remembered theme, 3 s bubble', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
const { page, problems } = await open('/dev/uikit.html');
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
await page.reload({ waitUntil: 'networkidle0' });
await page.waitForSelector('#emo-stage .ewheel__panel');
await page.$eval('#emo-stage', (el) => el.closest('section').scrollIntoView());
await sleep(500);
const section = await page.evaluateHandle(() => document.querySelector('#emo-stage').closest('section'));
let s = await wheelState(page, '#emo-stage');
assert.equal(s.theme, EMOTE_THEMES[0].themeId, 'first page by default');
assert.equal(s.items, 6, '3×2 cells');
assert.equal(s.imgs, 6, 'official pictures loaded');
assert.equal(s.panelText, '', 'no text in the panel');
assert.equal(s.dotOn, 0);
assert.equal(await page.$$eval('.emo-cat img', (els) => els.filter((i) => i.complete && i.naturalWidth > 0).length), 36, 'all 36 pictures load');
await section.screenshot({ path: path.join(OUT, 'emotes-uikit.png') });
// swipe left → next theme (mouse drag across the pager)
const box = await (await page.$('#emo-stage .ewheel__viewport')).boundingBox();
const y = box.y + box.height / 2;
await page.mouse.move(box.x + box.width * 0.8, y);
await page.mouse.down();
for (let i = 1; i <= 8; i++) { await page.mouse.move(box.x + box.width * (0.8 - i * 0.08), y); await sleep(16); }
await page.mouse.up();
await sleep(250);
s = await wheelState(page, '#emo-stage');
assert.equal(s.theme, EMOTE_THEMES[1].themeId, 'swipe → next page');
assert.equal(s.open, true, 'a drag is not a send');
// a short drag is ignored
await page.mouse.move(box.x + box.width * 0.5, y);
await page.mouse.down();
await page.mouse.move(box.x + box.width * 0.5 - 20, y, { steps: 3 });
await page.mouse.up();
await sleep(150);
assert.equal((await wheelState(page, '#emo-stage')).theme, EMOTE_THEMES[1].themeId);
// swipe right at the first page does nothing beyond the edge
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
await sleep(200);
assert.equal((await wheelState(page, '#emo-stage')).theme, EMOTE_THEMES[0].themeId, 'arrow keys, clamped at the first page');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
await sleep(200);
assert.equal((await wheelState(page, '#emo-stage')).theme, EMOTE_THEMES[3].themeId);
await page.click('#emo-stage .ewheel__dot:nth-child(5)');
await sleep(250);
s = await wheelState(page, '#emo-stage');
assert.equal(s.theme, EMOTE_THEMES[4].themeId, 'dot → page');
assert.equal(s.dotOn, 4);
assert.equal(s.imgs, 6);
await section.screenshot({ path: path.join(OUT, 'emotes-uikit-page5.png') });
// send: panel closes, bubble beside the avatar (picture only), 交流 greyed for 1 s
const sent = EMOTE_THEMES[4].emotes[2].id;
await page.click(`#emo-stage .ewheel__item[data-emote="${sent}"]`);
await sleep(150);
s = await wheelState(page, '#emo-stage');
assert.equal(s.open, false);
assert.equal(s.btnDisabled, true, 'cooldown');
const bubble = await page.$eval('.emo-row .ebubble', (b) => ({ id: b.dataset.emote, text: b.textContent.trim(), img: !!b.querySelector('img') }));
assert.deepEqual(bubble, { id: sent, text: '', img: true });
await section.screenshot({ path: path.join(OUT, 'emotes-uikit-sent.png') });
await sleep(1000);
assert.equal((await wheelState(page, '#emo-stage')).btnDisabled, false, 'cooldown over after 1 s');
await page.click('#emo-stage .ewheel__btn');
await sleep(200);
assert.equal((await wheelState(page, '#emo-stage')).theme, EMOTE_THEMES[4].themeId, 'reopens on the last used theme');
await page.reload({ waitUntil: 'networkidle0' });
await page.waitForSelector('#emo-stage .ewheel__panel');
assert.equal((await wheelState(page, '#emo-stage')).theme, EMOTE_THEMES[4].themeId, 'remembered across reloads');
// bubble lifetime: gone (faded) after 3 s; a newer emote replaces it and pops again
await page.click('#emo-stage .ewheel__item:nth-child(1)');
await sleep(3250);
const op = await page.$eval('.emo-row .ebubble', (b) => Number(getComputedStyle(b).opacity));
assert.ok(op < 0.05, `faded after 3 s (opacity ${op})`);
const texts = await page.$$eval('.ebubble', (els) => els.map((b) => b.textContent.trim()).filter(Boolean));
assert.deepEqual(texts, [], 'no bubble ever shows text');
assert.deepEqual(problems, []);
await page.close();
});
test('wheel: one trackpad swipe (momentum included) turns one page, a mouse-wheel notch turns one page', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
const { page, problems } = await open('/dev/uikit.html');
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
await page.reload({ waitUntil: 'networkidle0' });
await page.waitForSelector('#emo-stage .ewheel__panel');
await page.$eval('#emo-stage', (el) => el.scrollIntoView({ block: 'center' }));
await sleep(300);
const idx = async () => EMOTE_THEMES.map((t) => t.themeId).indexOf((await wheelState(page, '#emo-stage')).theme);
assert.equal(await idx(), 0);
// a macOS trackpad swipe: 60 wheel events 16 ms apart with a decaying deltaX (≈ 750 px) — v1 skipped to the last theme
await page.evaluate(async () => {
const vp = document.querySelector('#emo-stage .ewheel__viewport');
for (let i = 0; i < 60; i++) {
vp.dispatchEvent(new WheelEvent('wheel', { deltaX: Math.max(2, 40 * 0.95 ** i), deltaY: 0, bubbles: true, cancelable: true }));
await new Promise((r) => setTimeout(r, 16));
}
});
await sleep(350);
assert.equal(await idx(), 1, 'one swipe = one theme');
// a plain mouse wheel drives the horizontal pager (Unity ScrollRect): down = next, up = previous
const box = await (await page.$('#emo-stage .ewheel__viewport')).boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel({ deltaY: 100 });
await sleep(350);
assert.equal(await idx(), 2, 'wheel down → next theme');
await page.mouse.wheel({ deltaY: -100 });
await sleep(350);
assert.equal(await idx(), 1, 'wheel up → previous theme');
assert.equal((await wheelState(page, '#emo-stage')).open, true);
// hover over an official cell (emoji_cell_bkg: rounded, transparent corners) brightens it, never fills its square box
const cell = await (await page.$('#emo-stage .ewheel__item:nth-child(2)')).boundingBox();
await page.mouse.move(cell.x + cell.width / 2, cell.y + cell.height / 2);
await sleep(200);
const hover = await page.$eval('#emo-stage .ewheel__item:nth-child(2)', (el) => ({
sprite: el.closest('.ewheel__panel').classList.contains('has-cell'), bg: getComputedStyle(el).backgroundColor, filter: getComputedStyle(el).filter,
}));
assert.deepEqual(hover, { sprite: true, bg: 'rgba(0, 0, 0, 0)', filter: 'brightness(1.35)' });
assert.deepEqual(problems, []);
await page.close();
});
test('the cooldown survives a remount of the wheel; a bubble mounted late resumes its timeline', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
const { page, problems } = await open('/dev/uikit.html');
await page.waitForSelector('#emo-stage .ewheel__panel');
await page.evaluate(async () => {
const { render } = await import('preact');
const { useState } = await import('preact/hooks');
const { html } = await import('/js/ui/components.js');
const { EmoteWheel, EmoteBubble } = await import('/js/ui/emotes.js');
const host = document.createElement('div');
host.id = 'emo-probe';
host.style.cssText = 'position:fixed;left:40px;bottom:40px;z-index:99';
const bubbles = document.createElement('div');
bubbles.id = 'emo-probe-bubbles';
bubbles.style.cssText = 'position:fixed;right:40px;top:40px;z-index:99;display:flex;gap:10px';
document.body.append(host, bubbles);
function Probe() {
const [open, setOpen] = useState(true);
return html`<${EmoteWheel} open=${open} onToggle=${setOpen} onSend=${() => {}} />`;
}
window.__probe = {
mount: (n) => render(html`<${Probe} key=${n} />`, host),
unmount: () => render(null, host),
bubbles: (age) => render(html`<div class="late"><${EmoteBubble} id="autochess_battle_happy" at=${Date.now() - age} /></div>
<div class="fresh"><${EmoteBubble} id="autochess_battle_happy" at=${Date.now()} /></div>`, bubbles),
};
window.__probe.mount(1);
});
await page.waitForSelector('#emo-probe .ewheel__item');
await page.click('#emo-probe .ewheel__item:nth-child(2)');
await sleep(100);
const btn = () => page.$eval('#emo-probe .ewheel__btn', (b) => b.disabled);
assert.equal(await btn(), true, 'cooling after the send');
await page.evaluate(() => { window.__probe.unmount(); window.__probe.mount(2); });
await sleep(50);
assert.equal(await btn(), true, 'a remounted wheel is still cooling (v1 re-enabled it at once)');
await sleep(1000);
assert.equal(await btn(), false, 'cooldown over 1 s after the send');
await page.evaluate(() => window.__probe.bubbles(2000));
await sleep(60);
const delay = await page.$eval('#emo-probe-bubbles .late .ebubble', (b) => getComputedStyle(b).animationDelay);
assert.match(delay, /^-2(\.0\d*)?s,/, `late bubble resumes 2 s into its timeline (${delay})`);
await sleep(1250);
const op = await page.$$eval('#emo-probe-bubbles .ebubble', (els) => els.map((b) => Number(getComputedStyle(b).opacity)));
assert.ok(op[0] < 0.05, `the late bubble faded at its own 3 s mark (${op[0]})`);
assert.ok(op[1] > 0.95, `the fresh bubble is still up (${op[1]})`);
assert.deepEqual(problems, []);
await page.close();
});
test('touch: a horizontal swipe on the pager turns the page, a tap sends', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
const { page, problems } = await open('/dev/uikit.html');
await page.setViewport({ width: 1280, height: 720, hasTouch: true, isMobile: false });
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
await page.reload({ waitUntil: 'networkidle0' });
await page.waitForSelector('#emo-stage .ewheel__panel');
await page.$eval('#emo-stage', (el) => el.scrollIntoView({ block: 'center' }));
await sleep(300);
const box = await (await page.$('#emo-stage .ewheel__viewport')).boundingBox();
const y = box.y + box.height / 2;
await page.touchscreen.touchStart(box.x + box.width * 0.85, y);
for (let i = 1; i <= 8; i++) { await page.touchscreen.touchMove(box.x + box.width * (0.85 - i * 0.08), y); await sleep(16); }
await page.touchscreen.touchEnd();
await sleep(250);
let s = await wheelState(page, '#emo-stage');
assert.equal(s.theme, EMOTE_THEMES[1].themeId, 'swipe left → next theme');
assert.equal(s.open, true);
const cell = await (await page.$('#emo-stage .ewheel__item:nth-child(4)')).boundingBox();
await page.touchscreen.tap(cell.x + cell.width / 2, cell.y + cell.height / 2);
await sleep(200);
s = await wheelState(page, '#emo-stage');
assert.equal(s.open, false, 'tap sends and closes');
assert.equal(await page.$eval('.emo-row .ebubble', (b) => b.dataset.emote), EMOTE_THEMES[1].emotes[3].id);
assert.deepEqual(problems, []);
await page.close();
});
test('missing art: neutral glyphs, never text', async () => {
const { page, problems } = await open('/dev/uikit.html', {
manifest: { version: 1, groups: { 'emoticon/basic': { pic_happy_battle: { path: '/assets/local/emoticon/basic/nope_404.png', w: 120, h: 120 } } } },
ignore: [/nope_404\.png/, /Failed to load resource/],
});
await page.evaluate(() => localStorage.removeItem('sp.pref.emoteTheme'));
await page.reload({ waitUntil: 'networkidle0' });
await page.waitForSelector('#emo-stage .ewheel__panel');
await sleep(400);
const s = await wheelState(page, '#emo-stage');
assert.equal(s.items, 6);
assert.equal(s.imgs, 0);
assert.equal(s.glyphs, 6, 'unlisted and broken pictures both fall back to the glyph');
assert.equal(s.panelText, '');
const bubbles = await page.$$eval('.ebubble', (els) => els.map((b) => ({ text: b.textContent.trim(), glyph: !!b.querySelector('.eart--glyph') })));
assert.ok(bubbles.length >= 7);
for (const b of bubbles) assert.deepEqual(b, { text: '', glyph: true });
await (await page.evaluateHandle(() => document.querySelector('#emo-stage').closest('section'))).screenshot({ path: path.join(OUT, 'emotes-uikit-fallback.png') });
assert.deepEqual(problems, []);
await page.close();
});
test('in-match mock: bubbles beside the senders\' avatars, wheel above 交流', { skip: !HAS_ART && 'no extracted emote art' }, async () => {
const { page, problems } = await open('/dev/game-mock.html?shot=1&render=fallback&phase=PREP&variant=emote', { w: 1920, h: 1080 });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
await page.waitForSelector('.team__bubble');
await page.waitForSelector('.ewheel__panel');
await sleep(700);
const s = await wheelState(page);
assert.equal(s.items, 6);
assert.equal(s.imgs, 6);
assert.equal(s.panelText, '');
const geo = await page.$$eval('.team__row', (rows) => rows.filter((r) => r.querySelector('.team__bubble')).map((r) => {
const row = r.getBoundingClientRect();
const a = r.querySelector('.pavatar, .team__btn').getBoundingClientRect();
const b = r.querySelector('.team__bubble').getBoundingClientRect();
const bubble = r.querySelector('.team__bubble');
return {
beside: b.left >= a.right && b.left - row.right >= 0 && b.left - row.right <= 24, // just right of the sender's row
centred: Math.abs((b.top + b.bottom) / 2 - (a.top + a.bottom) / 2) <= 14, // at the avatar's height
absolute: getComputedStyle(bubble).position === 'absolute', // does not push the row
text: bubble.textContent.trim(),
img: [...r.querySelectorAll('.team__bubble img')].some((i) => i.complete && i.naturalWidth > 0),
};
}));
assert.ok(geo.length >= 1, 'a teammate bubble is shown');
for (const g of geo) assert.deepEqual(g, { beside: true, centred: true, absolute: true, text: '', img: true });
await page.screenshot({ path: path.join(OUT, 'emotes-mock-1920.png') });
// send from the wheel → own bubble
await page.click('.ewheel__item:nth-child(3)');
await sleep(300);
assert.equal((await wheelState(page)).open, false);
await page.screenshot({ path: path.join(OUT, 'emotes-mock-sent-1920.png') });
assert.deepEqual(problems, []);
await page.close();
});
});