import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserDeviceScaleFactor, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; const explicitUrl = process.env.AV_FEEDBACK_QA_URL; const targetUrl = new URL(explicitUrl ?? 'http://127.0.0.1:41741/heros_web/'); targetUrl.searchParams.set('debug', '1'); targetUrl.searchParams.set('renderer', 'canvas'); targetUrl.searchParams.set('debugBattleSetup', 'status-ui'); let serverProcess; let browser; try { if (!explicitUrl) { serverProcess = spawn( process.execPath, ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '41741', '--strictPort'], { cwd: process.cwd(), stdio: 'ignore', windowsHide: true } ); await waitForUrl(targetUrl, 20000); } browser = await chromium.launch({ headless: true }); const page = await browser.newPage(desktopBrowserContextOptions); const pageErrors = []; const consoleErrors = []; page.on('pageerror', (error) => pageErrors.push(error.message)); page.on('console', (message) => { if (message.type() === 'error') { consoleErrors.push(message.text()); } }); await page.goto(targetUrl.href, { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.waitForFunction(() => Boolean(window.__HEROS_DEBUG__), undefined, { timeout: 30000 }); await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToBattle('first-battle-zhuo-commandery'); }); await page.waitForFunction(() => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.scene === 'BattleScene' && battle.units?.some((unit) => unit.statusBadges?.length > 0); }, undefined, { timeout: 90000 }); const initial = await page.evaluate(() => { const debug = window.__HEROS_DEBUG__; const scene = debug.scene('BattleScene'); const battle = debug.battle(); const ally = battle.units.find((unit) => unit.statusBadges?.some((badge) => badge.kind === 'burn')); const enemy = battle.units.find((unit) => unit.statusBadges?.some((badge) => badge.kind === 'confusion')); const allyView = ally ? scene.unitViews.get(ally.id) : undefined; const enemyView = enemy ? scene.unitViews.get(enemy.id) : undefined; return { viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio, scale: window.visualViewport?.scale ?? 1 }, canvas: (() => { const bounds = document.querySelector('canvas')?.getBoundingClientRect(); return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null; })(), ally: ally && allyView ? { id: ally.id, hp: ally.hp, spriteX: allyView.sprite.x, badge: ally.statusBadges.find((badge) => badge.kind === 'burn'), roleSealBounds: objectBounds(allyView.roleSeal), badgeBounds: objectBounds(allyView.statusBadges[0]?.container) } : null, enemy: enemy && enemyView ? { id: enemy.id, spriteX: enemyView.sprite.x, badge: enemy.statusBadges.find((badge) => badge.kind === 'confusion'), badgeBounds: objectBounds(enemyView.statusBadges[0]?.container) } : null }; function objectBounds(object) { if (!object?.active) { return null; } const bounds = object.getBounds(); return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; } }); assert.deepEqual(initial.viewport, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, dpr: desktopBrowserDeviceScaleFactor, scale: 1 }); assert.deepEqual(initial.canvas, { x: 0, y: 0, width: 1920, height: 1080 }); assert(initial.ally?.badge?.visible, `Expected a visible allied burn badge: ${JSON.stringify(initial.ally)}`); assert(initial.enemy?.badge?.visible, `Expected a visible enemy confusion badge: ${JSON.stringify(initial.enemy)}`); assert(initial.ally.badge.x > initial.ally.spriteX, 'allied status badges should occupy the side opposite the role seal'); assert(initial.enemy.badge.x < initial.enemy.spriteX, 'enemy status badges should occupy the side opposite the intent marker'); if (initial.ally.roleSealBounds) { assert(!boundsIntersect(initial.ally.roleSealBounds, initial.ally.badgeBounds), 'allied status badge must not overlap the role seal'); } await page.screenshot({ path: 'dist/qa-av-status-badges-1920x1080.png', fullPage: true }); await page.waitForFunction(() => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.phase === 'deployment' && ['ready', 'degraded'].includes(battle.combatAssets?.status) && Boolean(battle.battleHud?.deploymentPanel?.startButtonBounds) ); }, undefined, { timeout: 90000 }); const startPoint = await page.evaluate(() => { const debug = window.__HEROS_DEBUG__; const scene = debug.scene('BattleScene'); const bounds = debug.battle().battleHud.deploymentPanel.startButtonBounds; const canvasBounds = document.querySelector('canvas').getBoundingClientRect(); return { x: canvasBounds.left + (bounds.x + bounds.width / 2) * canvasBounds.width / scene.scale.width, y: canvasBounds.top + (bounds.y + bounds.height / 2) * canvasBounds.height / scene.scale.height }; }); await page.mouse.click(4, 4); await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.started === true, undefined, { timeout: 5000 }); await page.mouse.click(startPoint.x, startPoint.y); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.phase === 'idle', undefined, { timeout: 90000 }); const openingAudio = await page.evaluate(() => window.__HEROS_DEBUG__.battle().audio); assert.equal(openingAudio.started, true, 'the deployment click should unlock audio'); assert.equal(openingAudio.semanticCues.lastPlayed?.group, 'operation'); assert.equal(openingAudio.semanticCues.lastPlayed?.key, 'operation-alert'); assert.equal(openingAudio.lastEffect?.trackKey, 'operation-alert', 'the opening alert must not be overwritten by ui-select'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.activeEffectCount === 0, undefined, { timeout: 5000 }); const pairedBadges = await page.evaluate((unitId) => { const debug = window.__HEROS_DEBUG__; const scene = debug.scene('BattleScene'); const unit = scene.debugUnitById(unitId); scene.applyBattleStatus(unit, 'confusion', 2, 1); const view = scene.unitViews.get(unitId); const state = debug.battle().units.find((candidate) => candidate.id === unitId); return { spriteX: view.sprite.x, badges: state.statusBadges, roleSealBounds: objectBounds(view.roleSeal), badgeBounds: view.statusBadges.map((badge) => objectBounds(badge.container)) }; function objectBounds(object) { if (!object?.active) { return null; } const bounds = object.getBounds(); return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; } }, initial.ally.id); assert.deepEqual(pairedBadges.badges.map((badge) => badge.kind), ['burn', 'confusion']); assert(pairedBadges.badges.every((badge) => badge.visible && badge.x > pairedBadges.spriteX)); assert( pairedBadges.badges[1].x > pairedBadges.badges[0].x, 'the second allied badge should fan outward instead of folding back over the unit' ); pairedBadges.badgeBounds.forEach((bounds) => { assert(!boundsIntersect(pairedBadges.roleSealBounds, bounds), 'paired allied status badges must not overlap the role seal'); }); const tickResult = await page.evaluate(() => { const scene = window.__HEROS_DEBUG__.scene('BattleScene'); scene.hideBattleEventBanner(); return scene.tickBattleStatuses('ally'); }); assert.match(tickResult.message, /화상/); await page.waitForFunction(() => { const audio = window.__HEROS_DEBUG__?.audio(); return audio?.lastEffect?.trackKey === 'burn-tick' && audio.activeEffectCount > 0; }, undefined, { timeout: 3000 }); const afterTick = await page.evaluate((unitId) => { const battle = window.__HEROS_DEBUG__.battle(); return { feedback: battle.mapResultFeedback, audio: battle.audio, unit: battle.units.find((candidate) => candidate.id === unitId) }; }, initial.ally.id); assert(afterTick.feedback.activeCount > 0 && afterTick.feedback.activeCount <= afterTick.feedback.maxActive); assert.deepEqual(afterTick.feedback.last.lines, ['화상 -3', `HP ${initial.ally.hp}→${initial.ally.hp - 3}`]); assert.equal(afterTick.audio.semanticCues.lastPlayed?.group, 'status'); assert.equal(afterTick.audio.semanticCues.lastPlayed?.key, 'burn-tick'); assert(afterTick.audio.activeEffectCount > 0, 'the browser must keep the decoded burn cue active after playback starts'); assert.equal(afterTick.unit.hp, initial.ally.hp - 3); assert.equal(afterTick.unit.statusBadges.find((badge) => badge.kind === 'burn')?.turns, 1); assert.equal(afterTick.unit.statusBadges.find((badge) => badge.kind === 'confusion')?.turns, 1); await page.screenshot({ path: 'dist/qa-av-status-tick-1920x1080.png', fullPage: true }); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.mapResultFeedback?.activeCount === 0, undefined, { timeout: 3000 }); const offscreenTick = await page.evaluate((unitId) => { const debug = window.__HEROS_DEBUG__; const scene = debug.scene('BattleScene'); const unit = scene.debugUnitById(unitId); const camera = scene.getDebugState().camera; const candidates = [ { x: camera.x + camera.visibleColumns, y: unit.y }, { x: camera.x - 1, y: unit.y }, { x: unit.x, y: camera.y + camera.visibleRows }, { x: unit.x, y: camera.y - 1 } ]; const destination = candidates.find( (tile) => tile.x >= 0 && tile.y >= 0 && tile.x < camera.mapWidth && tile.y < camera.mapHeight && !scene.isTileVisible(tile.x, tile.y) ); if (!destination) { throw new Error(`No offscreen tile is available for camera ${JSON.stringify(camera)}`); } unit.x = destination.x; unit.y = destination.y; scene.positionUnitView(unit); const result = scene.tickBattleStatuses('ally'); const battle = debug.battle(); return { message: result.message, tileVisible: scene.isTileVisible(unit.x, unit.y), feedback: battle.mapResultFeedback, soundCaption: battle.soundCaption, expectedDirection: scene.offscreenTileDirection(destination), unit: battle.units.find((candidate) => candidate.id === unitId) }; }, initial.ally.id); assert.match(offscreenTick.message, /화상/); assert.equal(offscreenTick.tileVisible, false); assert.equal(offscreenTick.feedback.activeCount, 0, 'offscreen burn damage must not clamp a popup to the map edge'); assert.equal(offscreenTick.soundCaption.visible, true, 'offscreen burn damage should create one fixed sound caption'); assert.equal(offscreenTick.soundCaption.kind, 'status'); assert.match(offscreenTick.soundCaption.text, /화상 피해/); assert.equal(offscreenTick.soundCaption.direction, offscreenTick.expectedDirection); assert( offscreenTick.soundCaption.bounds.x >= offscreenTick.feedback.mapBounds.x && offscreenTick.soundCaption.bounds.y >= offscreenTick.feedback.mapBounds.y && offscreenTick.soundCaption.bounds.x + offscreenTick.soundCaption.bounds.width <= offscreenTick.feedback.mapBounds.x + offscreenTick.feedback.mapBounds.width && offscreenTick.soundCaption.bounds.y + offscreenTick.soundCaption.bounds.height <= offscreenTick.feedback.mapBounds.y + offscreenTick.feedback.mapBounds.height, 'the fixed sound caption must remain inside the battle map' ); assert.equal(offscreenTick.unit.hp, initial.ally.hp - 6); assert.equal(offscreenTick.unit.statusBadges.length, 0, 'expired status badges must be removed after the offscreen tick'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.activeEffectCount === 0, undefined, { timeout: 3000 }); const prioritizedBurn = await page.evaluate(() => { const debug = window.__HEROS_DEBUG__; const scene = debug.scene('BattleScene'); const recoveryPlayedAt = debug.audio().semanticCues.playedAt.recovery ?? null; const selected = scene.playTurnStartFeedbackCue( 'ally', { burnTicked: true, offscreenBurnSources: [] }, { applied: true, offscreenRecoverySources: [] } ); return { selected, recoveryPlayedAt, audio: debug.audio() }; }); assert.equal(prioritizedBurn.selected, 'status'); assert.equal(prioritizedBurn.audio.semanticCues.lastPlayed.group, 'status'); assert.equal(prioritizedBurn.audio.lastEffect.trackKey, 'burn-tick'); assert.equal(prioritizedBurn.audio.activeEffectCount, 1, 'burn must be the only cue selected when burn and recovery coincide'); assert.equal( prioritizedBurn.audio.semanticCues.playedAt.recovery ?? null, prioritizedBurn.recoveryPlayedAt, 'lower-priority recovery must not be layered under burn' ); await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.activeEffectCount === 0, undefined, { timeout: 3000 }); const operationPriority = await page.evaluate(() => { const debug = window.__HEROS_DEBUG__; const scene = debug.scene('BattleScene'); const before = debug.audio().semanticCues.lastPlayed; const selected = scene.playTurnStartFeedbackCue( 'ally', { burnTicked: true, offscreenBurnSources: [] }, { applied: true, offscreenRecoverySources: [] }, true ); return { before, selected, after: debug.audio().semanticCues.lastPlayed, activeEffectCount: debug.audio().activeEffectCount }; }); assert.equal(operationPriority.selected, 'operation'); assert.deepEqual(operationPriority.after, operationPriority.before, 'an existing operation cue must suppress all additional turn-start cues'); assert.equal(operationPriority.activeEffectCount, 0); const recoveryCue = await page.evaluate(() => { const debug = window.__HEROS_DEBUG__; const scene = debug.scene('BattleScene'); const selected = scene.playTurnStartFeedbackCue( 'ally', { burnTicked: false, offscreenBurnSources: [] }, { applied: true, offscreenRecoverySources: [] } ); return { selected, audio: debug.audio() }; }); assert.equal(recoveryCue.selected, 'recovery'); assert.equal(recoveryCue.audio.semanticCues.lastPlayed.group, 'recovery'); assert.equal(recoveryCue.audio.lastEffect.trackKey, 'recovery-cue'); assert.equal(recoveryCue.audio.activeEffectCount, 1, 'the standalone recovery cue must decode and play in the browser'); const delayedOutcomeCaption = await page.evaluate((unitId) => { const debug = window.__HEROS_DEBUG__; const scene = debug.scene('BattleScene'); const unit = scene.debugUnitById(unitId); scene.completeBattle('defeat', 720); scene.showOffscreenBurnCaption([{ x: unit.x, y: unit.y }]); return debug.battle().soundCaption; }, initial.ally.id); assert.equal(delayedOutcomeCaption.visible, true, 'lethal offscreen burn must remain captioned before the result reveal'); assert.equal(delayedOutcomeCaption.kind, 'status'); assert.match(delayedOutcomeCaption.text, /화상 피해/); assert.deepEqual(pageErrors, [], `Unexpected page errors: ${pageErrors.join('\n')}`); assert.deepEqual(consoleErrors, [], `Unexpected browser console errors: ${consoleErrors.join('\n')}`); console.log( 'Verified 1920x1080 DPR1 paired status badges, offscreen sound captions, decoded and prioritized cues, popup suppression, expiry, and opening isolation.' ); } finally { await browser?.close(); serverProcess?.kill(); } function boundsIntersect(left, right) { if (!left || !right) { return false; } return !( left.x + left.width <= right.x || right.x + right.width <= left.x || left.y + left.height <= right.y || right.y + right.height <= left.y ); } async function waitForUrl(url, timeoutMs) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (serverProcess?.exitCode !== null) { throw new Error(`Preview server exited early with code ${serverProcess.exitCode}`); } try { const response = await fetch(url); if (response.ok) { return; } } catch { // Preview startup is still in progress. } await new Promise((resolve) => setTimeout(resolve, 150)); } throw new Error(`Timed out waiting for ${url.href}`); }