// 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); });