Files

96 lines
5.8 KiB
JavaScript

// PR #434: real pixi-spine's final pose/mixing and shared ring textures, beyond the fake-PIXI unit tests.
// RENDER_E2E=1 node --test test/render/held-pose.browser.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const skip = process.env.RENDER_E2E !== '1' || !existsSync(CHROME);
test('real Spine held death matches forced updates; revival and late font rasterisation remain live', { skip, timeout: 90000 }, async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
let browser;
try {
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
page.on('console', m => { if (m.type() === 'error') console.error(m.text()); });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&t=18&paused=1&panel=0`);
await page.waitForFunction(() => window.__demo?.ready && [...window.__demo.view.debug.views.values()].some(v => v.actor?.dieClip()), { timeout: 30000 });
const result = await page.evaluate(async () => {
const { SpineActor } = await import('/js/render/spine.js');
const T = await import('/js/render/textures.js');
const { app, views } = window.__demo.view.debug;
app.stop();
const source = [...views.values()].find(v => v.actor?.dieClip()).actor;
const a = new SpineActor(source.spine.spineData, source.entry);
const b = new SpineActor(source.spine.spineData, source.entry);
const renderer = app.renderer;
const hash = obj => {
const pixels = renderer.extract.pixels(obj);
let value = 2166136261, ink = 0;
for (let i = 0; i < pixels.length; i++) { value = Math.imul(value ^ pixels[i], 16777619); if (i % 4 === 3 && pixels[i]) ink++; }
return { value: value >>> 0, bytes: pixels.length, ink };
};
a.update(0.1);b.update(0.1);a.die();b.die();
const firstHeld = a.poseHeld();
// Exercise a real mix that outlasts the outgoing death clip, not merely the unposed first frame.
for (const actor of [a, b]) {
const track = actor.spine.state.tracks[0];
track.mixDuration = track.animationEnd - track.animationStart + 0.25;
}
let skipped = 0, equal = true, ink = 0, endMixFrames = 0, mixedHeld = false;
for (let i = 0; i < 600; i++) {
const track = a.spine.state.tracks[0];
if (track.mixingFrom && track.trackTime >= track.animationEnd - track.animationStart) {
endMixFrames++;mixedHeld ||= a.poseHeld();
}
if (a.poseHeld()) { a.clock += 1 / 60;skipped++; } else a.update(1 / 60);
b.update(1 / 60);
if (i % 15 === 0) {
const x = hash(a.spine), y = hash(b.spine);
equal &&= x.value === y.value && x.bytes === y.bytes;
ink = Math.max(ink, x.ink);
}
}
const finalA = hash(a.spine), finalB = hash(b.spine);
const finalVisibleEqual = a.poseHeld() && finalA.ink > 0 && finalA.value === finalB.value && finalA.bytes === finalB.bytes;
const clocksEqual = Math.abs(a.clock - b.clock) < 1e-9;
a.revive();a.deploy();a.update(1 / 60);
const revived = !a.poseHeld() && !a.dead;
a.spine.destroy({ children: true });b.spine.destroy({ children: true });
// A label created before Bender loads must keep its texture identity when real glyphs arrive.
await document.fonts.ready;
document.querySelector('link[href="/fonts/fonts.css"]').remove();
await Promise.resolve();
const benderAbsent = ![...document.fonts].some(f => f.family.replace(/["']/g, '') === 'Bender');
const label = T.downLabel('470', '#ffffff', 1);
const sprite = new PIXI.Sprite(label), before = hash(sprite);
const font = new FontFace('Bender', 'url(/fonts/bender-regular.woff2)', { weight: '400' });
await font.load();document.fonts.add(font);T.refreshDownLabels();
const after = hash(sprite);
const sameTexture = T.downLabel('470', '#ffffff', 1) === label;
const separateResolution = T.downLabel('470', '#ffffff', 2) !== label;
sprite.destroy();
return { firstHeld, skipped, equal, ink, finalVisibleEqual, clocksEqual, revived, sameTexture, separateResolution, endMixFrames, mixedHeld,
benderAbsent, fontChanged: before.value !== after.value || before.bytes !== after.bytes, fontInk: after.ink };
});
assert.equal(result.firstHeld, false, 'initial pose / mix must run');
assert.ok(result.skipped > 100, JSON.stringify(result));
assert.ok(result.endMixFrames > 0, 'real runtime mix remains after the death clip ends');
assert.equal(result.mixedHeld, false, 'mixed final poses still advance');
assert.equal(result.equal, true, 'held final frame is pixel-identical to continual updates');
assert.ok(result.ink > 0, 'the comparison renders actual art');
assert.equal(result.finalVisibleEqual, true, 'the held final pose itself is visible and identical');
assert.equal(result.clocksEqual, true);
assert.equal(result.revived, true);
assert.equal(result.sameTexture, true);
assert.equal(result.separateResolution, true);
assert.equal(result.benderAbsent, true, 'CSS font faces are absent before fallback rasterisation');
assert.equal(result.fontChanged, true, 'late Bender glyphs replace the fallback');
assert.ok(result.fontInk > 0);
assert.deepEqual(errors, []);
} catch (error) { console.error(error);throw error; } finally { await browser?.close();await srv.close(); }
});