fertig-classic-games/tools/regressionPipePuzzleUnhover...

137 lines
6.5 KiB
JavaScript

// Regression test — Pipe Puzzle: a tile's rotation animation must keep running
// after the pointer leaves the tile (mouse-out) mid-spin.
// node tools/regressionPipePuzzleUnhover.cjs [baseURL]
//
// Background: Phaser 3.90's `tweens.killTweensOf(target, 'prop')` no longer
// supports the prop filter — the argument is silently ignored and EVERY tween
// on the target is destroyed. PipePuzzleGame used it as
// `killTweensOf(tile, 'scaleX')` in the pointerout handler, which killed the
// in-flight rotation tween and froze the tile at a half-rotated angle.
//
// This test drives the same code path the pointerout event hits
// (`_setHover(i, false)`) while a rotation tween is live, and asserts:
// 1. the rotation tween SURVIVES the un-hover and completes to target;
// 2. the hover scale tweens are the only ones replaced;
// 3. mirror case: clicking/re-hovering mid-spin also must not kill the
// in-flight scale tween.
//
// Completion is verified by manually ticking the tween manager
// (`tweens.tick()`), which is deterministic even when headless RAF is
// throttled. Exits non-zero on failure.
const { chromium } = require('/home/brianfertig/.npm/_npx/e41f203b7505f1fb/node_modules/playwright');
const BASE = process.argv[2] || 'http://localhost:8123';
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: '/home/brianfertig/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome',
args: ['--no-sandbox', '--disable-gpu'],
});
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
await page.goto(BASE + '/', { waitUntil: 'load', timeout: 30000 });
await page.waitForFunction(
() => window.game && (window.game.isRunning === true || window.game.isRunning === 'running'),
{ timeout: 25000 },
);
await page.waitForTimeout(600);
// Start the game scene and jump to the Easy board.
await page.evaluate(() => { window.game.scene.start('PipePuzzleGame', {}); });
await page.waitForTimeout(500);
await page.evaluate(() => { window.game.scene.getScene('PipePuzzleGame')._startGame('easy'); });
await page.waitForTimeout(400);
const r = await page.evaluate(() => {
const rel = (a) => ((a % 360) + 360) % 360;
const sc = window.game.scene.getScene('PipePuzzleGame');
const { source, drain } = sc._board;
let i = 0;
while (i === source || i === drain) i++;
const img = sc._cells[i];
const tweensOf = () => sc.tweens.getTweensOf(img);
const hasKey = (key) => tweensOf().some((t) => t.data && t.data.some((d) => d.key === key));
// Advance every live tween on this tile to completion, deterministically.
// (Driven via Tween.update() so the check works even when the headless
// RAF clock is throttled to a crawl. `update` returns true once the
// tween has finished; the manager reaps it on its next step.)
const settle = (maxSteps = 200) => {
for (let g = 0; g < maxSteps; g++) {
let anyRunning = false;
for (const tw of sc.tweens.getTweensOf(img)) {
if (tw.isDestroyed()) continue;
if (!tw.update(16)) anyRunning = true; // advanced, still running
}
if (!anyRunning) break;
}
};
// ── Case 1: rotate, then mouse OUT mid-animation (the reported bug) ──
sc._setHover(i, true); // pointerover
const hoverTween = tweensOf().at(-1); // the hover scale tween
const moves = sc._moves;
sc._rotateCell(i); // pointerdown → 240ms spin starts
const target = img._targetAngle;
const spinAtStart = tweensOf().find((t) => t.data && t.data.some((d) => d.key === 'angle'));
sc._setHover(i, false); // pointerout while spin is live
const spinSurvivesUnhover = tweensOf().some((t) => t === spinAtStart && !t.isDestroyed());
const hoverTweenKilled = hoverTween.isDestroyed();
const restoreTweenPresent = hasKey('scaleX');
settle();
const completedAfterUnhover = rel(img.angle) === rel(target);
// ── Case 2 (mirror): rotate, then mouse IN again mid-animation ──
sc._rotateCell(i); // pre-fix this killed EVERY tween (incl. scale)
const scaleAtStart = tweensOf().find((t) => t.data && t.data.some((d) => d.key === 'scaleX'));
sc._setHover(i, true); // pointerover again mid-spin
const spinSurvivesRehover = tweensOf().some((t) => t.data && t.data.some((d) => d.key === 'angle') && !t.isDestroyed());
const scaleTweenReplaced = scaleAtStart != null && scaleAtStart.isDestroyed() && hasKey('scaleX');
settle();
const completedAfterRehover = rel(img.angle) === rel(img._targetAngle);
// ── Case 3: game state must have advanced cleanly ──
const stateAdvanced =
sc._moves === moves + 2 &&
!sc._won &&
sc._board.sockets.every((s) => Number.isInteger(s));
return {
spinSurvivesUnhover, hoverTweenKilled, restoreTweenPresent, completedAfterUnhover,
spinSurvivesRehover, scaleTweenReplaced, completedAfterRehover, stateAdvanced,
};
});
console.log('tween survival/kill state:', r);
const checks = [
['un-hover does NOT kill the in-flight rotation tween', r.spinSurvivesUnhover],
['un-hover DOES kill the old hover-scale tween', r.hoverTweenKilled],
['un-hover still starts the scale-restore tween', r.restoreTweenPresent],
['rotation COMPLETES to target angle after un-hover', r.completedAfterUnhover],
['re-hover does NOT kill the in-flight rotation tween', r.spinSurvivesRehover],
['re-hover replaces the previous scale tween', r.scaleTweenReplaced],
['rotation COMPLETES to target angle after re-hover', r.completedAfterRehover],
['game state advanced cleanly (2 moves, not won)', r.stateAdvanced],
];
let failures = 0;
for (const [name, ok] of checks) {
console.log(`${ok ? ' ok ' : 'FAIL '}${name}`);
if (!ok) failures++;
}
const relErrs = errors.filter((e) => !/favicon|404|net::ERR|ERR_NAME|Failed to load resource/.test(e));
if (relErrs.length) { console.error('JS errors:'); relErrs.forEach((e) => console.error(' ' + e)); failures++; }
await browser.close();
console.log(failures === 0 ? 'PIPE PUZZLE UNHOVER REGRESSION: PASS' : 'PIPE PUZZLE UNHOVER REGRESSION: FAIL');
process.exit(failures === 0 ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(1); });