From e1ffcd1b07e02eb18fd44b25c8b8d051929904a7 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 20:20:07 +0800 Subject: [PATCH] feedback5 WX: a browser check of the 39 local-client summon models on the prep board (test/render/token-models.browser.test.js, RENDER_E2E=1): each draws its official model on its idle clip over its tile, Front facing right and mirrored facing left, with no console error or failed request --- test/render/token-models.browser.test.js | 105 +++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 test/render/token-models.browser.test.js diff --git a/test/render/token-models.browser.test.js b/test/render/token-models.browser.test.js new file mode 100644 index 00000000..02e47a3e --- /dev/null +++ b/test/render/token-models.browser.test.js @@ -0,0 +1,105 @@ +// test/render/token-models.browser.test.js — the 39 summon models only the local client has (tools/local-extract/ +// extract.py TOKEN_SPINES → data/assets.json tokens[id].spineLocal, docs/ASSETS.md "Token models from the local client") +// drawn on the prep board of the dev render demo (public/dev/render-demo.html) in headless Chrome: every summon placed on +// a board tile draws its official model (the local entry, not the avatar diamond), on its own idle clip (the mapped ones +// too: 电弧's 戴乌 C_Skill1_Idle, 酒神's 本能的召唤 Loop, 战术锚点 Default), really puts pixels over its tile, and faces +// its deploy direction (Front facing right, mirrored for LEFT) — no console error, no failed request. +// Screenshots → test/e2e/out/token-models--.png. +// +// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/token-models.browser.test.js +// Needs the downloaded assets and the extracted token models (tools/local-extract --only spine/token). Run browser test +// files one at a time. Chrome path: $CHROME_PATH or the macOS default. + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +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 MODELS = JSON.parse(readFileSync(path.join(ROOT, 'tools/assets/local-token-spines.json'), 'utf8')).models; +const IDS = Object.keys(MODELS).sort(); +const extracted = IDS.every((id) => existsSync(path.join(ROOT, 'public/assets/local/spine/token', id))); +const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets')); +const skip = !enabled ? 'set RENDER_E2E=1 (needs Chrome and downloaded assets)' : !extracted ? 'token models not extracted (tools/local-extract --only spine/token)' : false; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +describe('the local-client summon models on the prep board (headless Chrome)', { skip }, () => { + let srv, browser; + before(async () => { + const puppeteer = (await import('puppeteer-core')).default; + const { startServer } = await import('../../server/index.js'); + srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run', '--force-device-scale-factor=1'] }); + mkdirSync(OUT, { recursive: true }); + }); + after(async () => { + await browser?.close(); + await srv?.close(); + }); + + test('every summon draws its official model on its idle clip, over its tile, facing its deploy direction', async (t) => { + const page = await browser.newPage(); + const problems = []; + let least = { drawn: Infinity }; + page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); }); + page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); + page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); }); + await page.setViewport({ width: 1600, height: 900 }); + await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=prep&panel=0&pen=0&board=2d`); + await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 40000 }); + assert.equal(await page.evaluate(() => window.__demo.error || null), null, 'demo boot'); + assert.ok(await page.evaluate(async () => { const { assets } = await import('/js/assets.js'); return !!(await assets.local()); }), 'data/local-assets.json'); + for (let b = 0; b * 10 < IDS.length; b++) { + const group = IDS.slice(b * 10, b * 10 + 10); + for (const dir of ['RIGHT', 'LEFT']) { + await page.evaluate((group, dir) => { + let uid = 900; + const cols = [1, 3, 5, 7, 9]; + const board = group.map((id, i) => ({ uid: uid++, kind: 'token', id, golden: false, tier: 1, items: [], count: 1, row: i < 5 ? 11 : 9, col: cols[i % 5], dir })); + window.__demo.view.setPrep({ hand: Array(10).fill(null), temp: Array(5).fill(null), board, nextEnemies: [] }, { editable: false }); + }, group, dir); + await sleep(3000); + const got = await page.evaluate((group) => { + const R = window.__demo.view.debug, app = R.app, rd = app.renderer, gl = rd.gl, res = rd.resolution, H = rd.view.height; + const out = {}; + for (const v of R.views.values()) { + const id = v?.info?.defId; + if (!group.includes(id)) continue; + const sp = v.actor?.spine; + // share of the pixels over the body that change when the view is hidden (≈ 0: nothing drawn) + const bb = v.bounds(); + const x0 = Math.max(0, Math.floor(bb.x * res)), y0 = Math.max(0, Math.floor(bb.y * res)); + const w = Math.max(1, Math.min(Math.floor(bb.width * res), rd.view.width - x0)), h = Math.max(1, Math.min(Math.floor(bb.height * res), H - y0)); + const read = () => { R.ctx.impostors?.flush?.(); rd.render(app.stage); const px = new Uint8Array(w * h * 4); gl.readPixels(x0, H - y0 - h, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); return px; }; + const a = read(); + v.root.visible = false; + const c = read(); + v.root.visible = true; + let diff = 0; + for (let i = 0; i < a.length; i += 4) if (Math.abs(a[i] - c[i]) + Math.abs(a[i + 1] - c[i + 1]) + Math.abs(a[i + 2] - c[i + 2]) > 40) diff++; + out[id] = { ready: !!(v.actor && v.spineReady), local: !!v.actor?.entry?.local, skel: v.actor?.entry?.skel || null, + anim: sp?.state?.tracks?.[0]?.animation?.name || null, sx: sp ? Math.sign(sp.scale.x) : 0, drawn: diff / (w * h) }; + } + return out; + }, group); + await page.screenshot({ path: path.join(OUT, `token-models-${b + 1}-${dir.toLowerCase()}.png`) }); + for (const id of group) { + const g = got[id]; + assert.ok(g, `${id}: a prep view`); + assert.ok(g.ready && g.local, `${id} (${dir}): the official model (${JSON.stringify(g)})`); + assert.equal(g.skel, `/assets/local/spine/token/${id}/${MODELS[id].skel}`, id); + assert.equal(g.anim, MODELS[id].anims.idle, `${id}: its idle clip`); + assert.equal(g.sx, dir === 'LEFT' ? -1 : 1, `${id}: Front facing right, mirrored facing left`); + assert.ok(g.drawn > 0.02, `${id} (${dir}): draws over its tile (${g.drawn.toFixed(4)})`); + if (g.drawn < least.drawn) least = { id, dir, drawn: g.drawn }; + } + } + } + assert.deepEqual(problems, []); + t.diagnostic(`least drawn: ${least.id} ${least.dir} ${least.drawn.toFixed(4)} of its body rect`); + await page.close(); + }); +});