Fix win-screen counter guard and add Playwright browser smoke test
- piecesBig counter guard used .parent (always undefined on container children in this Phaser build — they get .parentContainer), so the 'PIECES' count-up never rendered; drop the dead guard. - buttonHit() had the same .parent assumption; use .parentContainer. - Add tools/smokeJigsaw.mjs: headless-Chromium smoke test driving menu -> play -> win showcase (camera settle, counter, confetti, frame/shine) -> Play Again -> Menu -> play again, asserting zero JS errors. Verified pass in browser.
This commit is contained in:
parent
18d89d4553
commit
97adfa642d
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"name": "fertig-classic-games",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fertig-classic-games",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,5 +9,8 @@
|
|||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -514,7 +514,7 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
|
||||
buttonHit(cx, cy) {
|
||||
for (const b of this.buttons) {
|
||||
if (!b.visible || !b.parent || !b.parent.visible) continue;
|
||||
if (!b.visible || (b.parentContainer && !b.parentContainer.visible)) continue;
|
||||
const w = b.options.width, h = b.options.height;
|
||||
if (Math.abs(cx - b.x) <= w / 2 && Math.abs(cy - b.y) <= h / 2) return b;
|
||||
}
|
||||
|
|
@ -1302,7 +1302,7 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
const counter = { v: 0 };
|
||||
this.winTweens.push(this.tweens.add({
|
||||
targets: counter, v: this.total, delay: 760, duration: 950, ease: 'Quad.easeOut',
|
||||
onUpdate: (tw) => { if (this.piecesBig && this.piecesBig.parent) this.piecesBig.setText(String(Math.round(tw.getValue(0)))); },
|
||||
onUpdate: (tw) => { if (this.piecesBig) this.piecesBig.setText(String(Math.round(tw.getValue(0)))); },
|
||||
}));
|
||||
|
||||
// Camera sweep — the finale fires when it lands.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
// Browser smoke test for the Jigsaw game (Playwright + headless Chromium).
|
||||
//
|
||||
// Usage:
|
||||
// npx playwright install chromium # one-time
|
||||
// python3 -m http.server 8000 # serve the repo root (or pass a base URL)
|
||||
// node tools/smokeJigsaw.mjs # defaults to http://127.0.0.1:8000
|
||||
// node tools/smokeJigsaw.mjs http://localhost:9000
|
||||
//
|
||||
// What it checks:
|
||||
// - game loads, menu → Start Puzzle → playing, pieces spawn on the table
|
||||
// - forced win (placed = total; onWin()) runs the full showcase:
|
||||
// * camera sweep lands on WIN_ZOOM framing the board
|
||||
// * stats counter animates up to the total
|
||||
// * celebration emitters fire (burst + curtain) and confetti is alive
|
||||
// * gold frame + shine sweep exist at the right depths
|
||||
// - "Play Again" restarts a fresh board and tears the win layer down
|
||||
// - "Menu" returns to the menu and destroys the win layer
|
||||
// - menu → Start Puzzle works again
|
||||
// - zero JS console/page errors across the whole flow
|
||||
//
|
||||
// NOTE: headless Chromium renders this 1920×1080 WebGL scene at only a few
|
||||
// FPS (SwiftShader), so the scene clock advances slowly in real time. All
|
||||
// waits here are condition-based polling (generous timeouts), never fixed
|
||||
// sleeps. A full run takes roughly 1–3 minutes of wall time.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const BASE = process.argv[2] || 'http://127.0.0.1:8000/__jig_test.html';
|
||||
|
||||
let server = null;
|
||||
if (!process.argv[2]) {
|
||||
try {
|
||||
await fetch(new URL('..', BASE), { method: 'HEAD', signal: AbortSignal.timeout(1500) });
|
||||
} catch {
|
||||
server = spawn('python3', ['-m', 'http.server', '8000'], { cwd: ROOT, stdio: 'ignore', detached: true });
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
console.log('started local static server on :8000');
|
||||
}
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
let failed = false;
|
||||
const ok = (m) => console.log(' ok ' + m);
|
||||
const bad = (m) => { failed = true; console.log(' FAIL ' + m); };
|
||||
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
|
||||
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
|
||||
|
||||
const evalS = (fn, ...a) => page.evaluate(fn, ...a);
|
||||
const waitFor = async (name, cond, timeoutMs) => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (await evalS(cond)) return true;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
bad(`timeout waiting for: ${name}`);
|
||||
return false;
|
||||
};
|
||||
const snap = async (name) => {
|
||||
const dest = path.join(os.tmpdir(), `jigsaw_smoke_${name}.png`);
|
||||
await page.screenshot({ path: dest, fullPage: false });
|
||||
ok(`screenshot ${dest}`);
|
||||
};
|
||||
const click = (x, y) => evalS((pt) => { window.__mouseDown(pt[0], pt[1]); window.__mouseUp(pt[0], pt[1]); }, [x, y]);
|
||||
const settleTimeout = Number(process.env.JIG_SMOKE_SETTLE_MS || 180000);
|
||||
|
||||
try {
|
||||
console.log('— load —');
|
||||
await page.goto(BASE, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => document.getElementById('log').textContent.includes('harness ready'), null, { timeout: 30000 });
|
||||
ok('harness ready');
|
||||
|
||||
console.log('— start puzzle —');
|
||||
await evalS(() => window.__startPuzzle());
|
||||
if (!(await waitFor('state=playing', () => window.__scene().state === 'playing', 60000))) throw new Error('never reached playing');
|
||||
ok('state = playing');
|
||||
const pieces = await evalS(() => window.__pieces());
|
||||
ok(`pieces on table: ${pieces.length} (placed=${pieces.filter((p) => p.placed).length})`);
|
||||
if (pieces.length === 0) bad('no pieces spawned');
|
||||
await snap('1_playing');
|
||||
|
||||
console.log('— force win —');
|
||||
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
|
||||
ok('onWin() called (state=' + (await evalS(() => window.__scene().state)) + ')');
|
||||
|
||||
console.log('— wait for panel settle (camera at WIN_ZOOM, counter done) —');
|
||||
if (await waitFor('camera settled + counter=total', () => {
|
||||
const s = window.__scene();
|
||||
return s.state === 'won' && Math.abs(s.cameras.main.zoom - 1.24) < 0.01
|
||||
&& s.piecesBig && s.piecesBig.text === String(s.total);
|
||||
}, settleTimeout)) {
|
||||
const wi = await evalS(() => {
|
||||
const s = window.__scene();
|
||||
return { zoom: +s.cameras.main.zoom.toFixed(3), sx: Math.round(s.cameras.main.scrollX), sy: Math.round(s.cameras.main.scrollY), counter: s.piecesBig.text };
|
||||
});
|
||||
ok('settled: ' + JSON.stringify(wi));
|
||||
await snap('2_win_panel');
|
||||
}
|
||||
|
||||
console.log('— wait for celebration (emitters created + particles alive) —');
|
||||
if (await waitFor('emitters alive', () => {
|
||||
const s = window.__scene();
|
||||
return (s.winEmitters || []).length === 2 && s.winEmitters.every((e) => e.getAliveParticleCount() > 0);
|
||||
}, settleTimeout)) {
|
||||
const alive = await evalS(() => window.__scene().winEmitters.map((e) => e.getAliveParticleCount()));
|
||||
ok('particles alive: ' + JSON.stringify(alive));
|
||||
await snap('3_confetti');
|
||||
const fx = await evalS(() => {
|
||||
const s = window.__scene();
|
||||
return { frame: !!s.winFrame, shine: !!s.winShine, frameDepth: s.winFrame && s.winFrame.depth, shineDepth: s.winShine && s.winShine.depth };
|
||||
});
|
||||
ok('frame/shine: ' + JSON.stringify(fx));
|
||||
if (!fx.frame || !fx.shine) bad('winFrame or winShine missing');
|
||||
}
|
||||
|
||||
console.log('— Play Again button —');
|
||||
await click(400, 640); // inside "Play Again" (visual 90..476 × 591..653, hit rect 283..669 × 622..684)
|
||||
if (await waitFor('state=playing after Play Again', () => window.__scene().state === 'playing', 120000)) {
|
||||
const placed = await evalS(() => window.__scene().pieces.filter((p) => p.placed).length);
|
||||
const winGone = await evalS(() => !window.__scene().winLayer);
|
||||
ok(`restarted: placed=${placed}, winLayer destroyed=${winGone}`);
|
||||
if (placed !== 0) bad('pieces not reset after Play Again');
|
||||
if (!winGone) bad('winLayer not destroyed after Play Again');
|
||||
await snap('4_restarted');
|
||||
}
|
||||
|
||||
console.log('— force win again, then Menu button —');
|
||||
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
|
||||
if (await waitFor('second showcase emitters', () => (window.__scene().winEmitters || []).length === 2, settleTimeout)) {
|
||||
ok('second win showcase running');
|
||||
await click(400, 720); // inside "Menu"
|
||||
if (await waitFor('state=menu after Menu', () => window.__scene().state === 'menu', 120000)) {
|
||||
const menuVisible = await evalS(() => window.__scene().menu && window.__scene().menu.visible);
|
||||
const winGone = await evalS(() => !window.__scene().winLayer);
|
||||
ok(`back to menu: menu.visible=${menuVisible}, winLayer destroyed=${winGone}`);
|
||||
if (!menuVisible) bad('menu not visible');
|
||||
if (!winGone) bad('winLayer not destroyed');
|
||||
await snap('5_menu');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('— Start Puzzle from menu works —');
|
||||
await evalS(() => { const s = window.__scene(); s.selectedDiff = 'easy'; s.startPuzzle(); });
|
||||
if (await waitFor('state=playing from menu', () => window.__scene().state === 'playing', 60000)) ok('menu → playing OK');
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (server) { try { process.kill(-server.pid); } catch { /* already gone */ } }
|
||||
}
|
||||
|
||||
console.log('\n— JS errors (' + errors.length + ') —');
|
||||
errors.forEach((e) => console.log(' ' + e));
|
||||
if (errors.length) failed = true;
|
||||
|
||||
console.log(failed ? '\nSMOKE TEST: FAILED' : '\nSMOKE TEST: ALL PASS');
|
||||
process.exit(failed ? 1 : 0);
|
||||
Loading…
Reference in New Issue