369 lines
16 KiB
JavaScript
369 lines
16 KiB
JavaScript
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}`);
|
|
}
|