// 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); // The official pictures reach the wheel through a two-link chain (data.js artUrls): the local-client extraction // first (tools/local-extract → data/local-assets.json), then the copies tools/fetch-assets.mjs downloads into // public/assets/ui/emoticon (GitHub #42). A deployment has one link or the other; the assertions below check that // the pictures LOAD, never which link served them, so either one is enough to run the suite. const HAS_ART = existsSync(path.join(ROOT, 'public/assets/local/emoticon/fooldoctor')) || existsSync(path.join(ROOT, 'public/assets/ui/emoticon/fooldoctor')); // The WHEEL's own chrome is a different story: emoji_cell_bkg / emoji_bkg / emoji_btn come from the local client's // ui/battle atlas (ui/emotes.js emoteUiSprite → localAsset), which tools/fetch-assets.mjs does NOT carry — no dump, // no official cell, and the wheel draws its own. One assertion below is about that official chrome, so it asks for it // separately instead of quietly failing on a fetch-assets-only deployment. const HAS_UI = existsSync(path.join(ROOT, 'public/assets/local/ui/battle/emoji_cell_bkg.png')); 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: true, 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 = [], block = null } = {}) { 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 || block) { await page.setRequestInterception(true); page.on('request', (req) => { // `block`: answer 404 for these URLs (e.g. the emote copies setup downloads, GitHub #42 — the second link of the chain) if (block && block.test(req.url())) return req.respond({ status: 404, contentType: 'text/plain', body: 'blocked' }); if (manifest && req.url().endsWith('/data/local-assets.json')) return req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(manifest) }); return 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 official emote art (neither the local extraction nor the fetched copies)' }, 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 official emote art (neither the local extraction nor the fetched copies)' }, 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, })); if (HAS_UI) { assert.deepEqual(hover, { sprite: true, bg: 'rgba(0, 0, 0, 0)', filter: 'brightness(1.35)' }, 'the official emoji_cell_bkg cell'); } else { // no local client dump: the wheel draws its own cell and the hover is a drawn fill — the pictures are what this // test is about on such a deployment, so only pin that it is NOT pretending to be the official sprite cell assert.equal(hover.sprite, false, 'without ui/battle/emoji_cell_bkg the cell is drawn, not the official sprite'); assert.ok(hover.bg !== '' && hover.filter !== '', `the drawn cell is still styled: ${JSON.stringify(hover)}`); } 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 official emote art (neither the local extraction nor the fetched copies)' }, 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`