feat: complete keyboard flows and stabilize camp focus
This commit is contained in:
551
scripts/verify-battle-keyboard-command-flow-browser.mjs
Normal file
551
scripts/verify-battle-keyboard-command-flow-browser.mjs
Normal file
@@ -0,0 +1,551 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
desktopBrowserContextOptions,
|
||||
desktopBrowserViewport
|
||||
} from './desktop-browser-viewport.mjs';
|
||||
|
||||
const battleId = 'second-battle-yellow-turban-pursuit';
|
||||
const renderer = process.env.VERIFY_BATTLE_KEYBOARD_RENDERER;
|
||||
if (!renderer) {
|
||||
for (const requestedRenderer of ['canvas', 'webgl']) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[fileURLToPath(import.meta.url)],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
VERIFY_BATTLE_KEYBOARD_RENDERER: requestedRenderer
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
assert(
|
||||
renderer === 'canvas' || renderer === 'webgl',
|
||||
`Unsupported renderer: ${renderer}`
|
||||
);
|
||||
const baseUrl =
|
||||
process.env.VERIFY_BATTLE_KEYBOARD_URL ??
|
||||
'http://127.0.0.1:41796/heros_web/';
|
||||
const targetUrl = withDebugOptions(baseUrl, renderer);
|
||||
|
||||
let serverProcess;
|
||||
let browser;
|
||||
|
||||
try {
|
||||
serverProcess = await ensureLocalServer(targetUrl);
|
||||
browser = await chromium.launch({
|
||||
headless: process.env.VERIFY_BATTLE_KEYBOARD_HEADLESS !== '0'
|
||||
});
|
||||
const context = await browser.newContext(desktopBrowserContextOptions);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(targetUrl, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 90000
|
||||
});
|
||||
await page.evaluate(() => window.localStorage.clear());
|
||||
await page.reload({
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 90000
|
||||
});
|
||||
await waitForDebugApi(page);
|
||||
await assertDesktopViewport(page);
|
||||
await assertRendererAndBasePath(page, renderer);
|
||||
|
||||
await page.evaluate((requestedBattleId) => {
|
||||
window.__HEROS_DEBUG__?.goToBattle(requestedBattleId);
|
||||
}, battleId);
|
||||
await page.waitForFunction(
|
||||
(requestedBattleId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.battleId === requestedBattleId &&
|
||||
['deployment', 'idle'].includes(battle.phase) &&
|
||||
battle.mapBackgroundReady === true &&
|
||||
(battle.phase === 'idle' ||
|
||||
['ready', 'degraded'].includes(battle.combatAssets?.status))
|
||||
);
|
||||
},
|
||||
battleId,
|
||||
{ timeout: 90000 }
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
if (scene?.phase === 'deployment') {
|
||||
scene.confirmPreBattleDeployment();
|
||||
}
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(requestedBattleId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return battle?.battleId === requestedBattleId && battle.phase === 'idle';
|
||||
},
|
||||
battleId,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
const fixture = await page.evaluate(async () => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
const units = scene?.debugBattleUnits?.() ?? [];
|
||||
const unit = units.find(
|
||||
(candidate) =>
|
||||
candidate.id === 'liu-bei' &&
|
||||
candidate.faction === 'ally' &&
|
||||
candidate.hp > 0
|
||||
);
|
||||
if (!scene || !unit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
scene.hideBattleEventBanner();
|
||||
scene.hideTurnEndPrompt();
|
||||
scene.hideSaveSlotPanel();
|
||||
scene.hideMapMenu();
|
||||
scene.hideCommandMenu();
|
||||
scene.clearMarkers();
|
||||
scene.activeFaction = 'ally';
|
||||
scene.phase = 'idle';
|
||||
scene.selectedUnit = undefined;
|
||||
scene.pendingMove = undefined;
|
||||
scene.targetingAction = undefined;
|
||||
scene.selectedUsable = undefined;
|
||||
scene.actedUnitIds.clear();
|
||||
units
|
||||
.filter((candidate) => candidate.faction === 'ally' && candidate.hp > 0)
|
||||
.forEach((candidate) => {
|
||||
candidate.hp = candidate.maxHp;
|
||||
});
|
||||
scene.centerCameraOnTile(unit.x, unit.y);
|
||||
scene.selectUnit(unit);
|
||||
await scene.moveSelectedUnit(unit.x, unit.y);
|
||||
|
||||
const recordPointerInput = () => {
|
||||
window.__HEROS_KEYBOARD_COMMAND_QA__.pointerInputCount += 1;
|
||||
};
|
||||
window.__HEROS_KEYBOARD_COMMAND_QA__ = {
|
||||
pointerInputCount: 0,
|
||||
recordPointerInput
|
||||
};
|
||||
window.addEventListener('mousedown', recordPointerInput, true);
|
||||
window.addEventListener('pointerdown', recordPointerInput, true);
|
||||
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return {
|
||||
unitId: unit.id,
|
||||
unitTile: { x: unit.x, y: unit.y },
|
||||
pendingMove: state?.pendingMove ?? null,
|
||||
commandMenu: state?.commandMenuKeyboard ?? null
|
||||
};
|
||||
});
|
||||
|
||||
assert(fixture, 'Expected the keyboard command fixture to select Liu Bei.');
|
||||
assert(
|
||||
fixture.pendingMove?.unitId === fixture.unitId,
|
||||
`Expected a preserved pending stay-in-place move: ${JSON.stringify(fixture)}`
|
||||
);
|
||||
assertCommandMenuFocus(fixture.commandMenu, 'command');
|
||||
assert(
|
||||
fixture.commandMenu.items.some(
|
||||
({ id, available }) => id === 'attack' && available === false
|
||||
),
|
||||
`Expected the distant attack command to be unavailable for skip QA: ${JSON.stringify(fixture.commandMenu)}`
|
||||
);
|
||||
assert(
|
||||
fixture.commandMenu.items.some(
|
||||
({ id, available }) => id === 'strategy' && available
|
||||
),
|
||||
`Expected Liu Bei to have an available strategy: ${JSON.stringify(fixture.commandMenu)}`
|
||||
);
|
||||
assert(
|
||||
fixture.commandMenu.focusedItem.id === 'strategy',
|
||||
`Default focus must skip unavailable attack and land on strategy: ${JSON.stringify(fixture.commandMenu)}`
|
||||
);
|
||||
|
||||
await page.screenshot({
|
||||
path: `dist/verification-battle-keyboard-command-focus-${renderer}.png`,
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
const initialCommandFocus = fixture.commandMenu.focusedItem.id;
|
||||
const availableCommandIds = fixture.commandMenu.items
|
||||
.filter(({ available }) => available)
|
||||
.map(({ id }) => id);
|
||||
const previousCommandId =
|
||||
availableCommandIds[
|
||||
(availableCommandIds.indexOf(initialCommandFocus) -
|
||||
1 +
|
||||
availableCommandIds.length) %
|
||||
availableCommandIds.length
|
||||
];
|
||||
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
let state = await readBattleState(page);
|
||||
assertCommandMenuFocus(state.commandMenuKeyboard, 'command');
|
||||
assert(
|
||||
state.commandMenuKeyboard.focusedItem.id === previousCommandId,
|
||||
`Shift+Tab must wrap to the previous available command: ${JSON.stringify(state.commandMenuKeyboard)}`
|
||||
);
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
state = await readBattleState(page);
|
||||
assert(
|
||||
state.commandMenuKeyboard.focusedItem.id === initialCommandFocus,
|
||||
`Tab must wrap back to the initial command without visiting a disabled row: ${JSON.stringify(state.commandMenuKeyboard)}`
|
||||
);
|
||||
|
||||
const visitedCommands = [];
|
||||
for (let index = 0; index < availableCommandIds.length; index += 1) {
|
||||
await page.keyboard.press('Tab');
|
||||
state = await readBattleState(page);
|
||||
assertCommandMenuFocus(state.commandMenuKeyboard, 'command');
|
||||
visitedCommands.push(state.commandMenuKeyboard.focusedItem.id);
|
||||
}
|
||||
assert(
|
||||
visitedCommands.at(-1) === initialCommandFocus &&
|
||||
visitedCommands.every((id) => availableCommandIds.includes(id)),
|
||||
`Command focus must wrap and skip every unavailable row: ${JSON.stringify({
|
||||
availableCommandIds,
|
||||
visitedCommands
|
||||
})}`
|
||||
);
|
||||
|
||||
await focusMenuItem(page, 'strategy');
|
||||
const beforeUsable = await readBattleState(page);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => {
|
||||
const keyboard = window.__HEROS_DEBUG__?.battle()?.commandMenuKeyboard;
|
||||
return keyboard?.visible === true && keyboard.mode === 'usable';
|
||||
});
|
||||
|
||||
state = await readBattleState(page);
|
||||
assertCommandMenuFocus(state.commandMenuKeyboard, 'usable');
|
||||
assert(
|
||||
state.selectedUnitId === fixture.unitId &&
|
||||
state.pendingMove?.unitId === fixture.unitId &&
|
||||
state.commandMenuKeyboard.parentCommand === 'strategy',
|
||||
`Entering the usable menu must preserve unit and pending move: ${JSON.stringify(state)}`
|
||||
);
|
||||
assert(
|
||||
state.commandMenuKeyboard.items.some(({ available }) => !available) &&
|
||||
state.commandMenuKeyboard.focusedItem.available === true,
|
||||
`Usable default focus must skip an unavailable full-HP heal: ${JSON.stringify(state.commandMenuKeyboard)}`
|
||||
);
|
||||
assert(
|
||||
!state.commandMenuKeyboard.items.find(
|
||||
({ id }) => id === state.commandMenuKeyboard.focusedItem.id
|
||||
)?.available === false,
|
||||
`Usable focus landed on an unavailable item: ${JSON.stringify(state.commandMenuKeyboard)}`
|
||||
);
|
||||
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
state = await readBattleState(page);
|
||||
assertCommandMenuFocus(state.commandMenuKeyboard, 'usable');
|
||||
assert(
|
||||
state.commandMenuKeyboard.focusedItem.available === true,
|
||||
`Usable Shift+Tab must skip unavailable rows: ${JSON.stringify(state.commandMenuKeyboard)}`
|
||||
);
|
||||
|
||||
await page.screenshot({
|
||||
path: `dist/verification-battle-keyboard-usable-focus-${renderer}.png`,
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => {
|
||||
const keyboard = window.__HEROS_DEBUG__?.battle()?.commandMenuKeyboard;
|
||||
return (
|
||||
keyboard?.visible === true &&
|
||||
keyboard.mode === 'command' &&
|
||||
keyboard.focusedItem?.id === 'strategy'
|
||||
);
|
||||
});
|
||||
state = await readBattleState(page);
|
||||
assertCommandMenuFocus(state.commandMenuKeyboard, 'command');
|
||||
assert(
|
||||
state.selectedUnitId === fixture.unitId &&
|
||||
state.pendingMove?.unitId === fixture.unitId &&
|
||||
state.pendingMove.toX === beforeUsable.pendingMove.toX &&
|
||||
state.pendingMove.toY === beforeUsable.pendingMove.toY,
|
||||
`Esc must return one level without clearing selection or pending move: ${JSON.stringify(state)}`
|
||||
);
|
||||
|
||||
await focusMenuItem(page, 'wait');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
(unitId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.commandMenuKeyboard?.visible === false &&
|
||||
battle?.actedUnitIds?.includes(unitId)
|
||||
);
|
||||
},
|
||||
fixture.unitId
|
||||
);
|
||||
|
||||
const completed = await page.evaluate((unitId) => {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
const qa = window.__HEROS_KEYBOARD_COMMAND_QA__;
|
||||
if (qa?.recordPointerInput) {
|
||||
window.removeEventListener('mousedown', qa.recordPointerInput, true);
|
||||
window.removeEventListener('pointerdown', qa.recordPointerInput, true);
|
||||
}
|
||||
return {
|
||||
selectedUnitId: state?.selectedUnitId ?? null,
|
||||
pendingMove: state?.pendingMove ?? null,
|
||||
acted: state?.actedUnitIds?.includes(unitId) ?? false,
|
||||
commandMenu: state?.commandMenuKeyboard ?? null,
|
||||
pointerInputCount: qa?.pointerInputCount ?? -1
|
||||
};
|
||||
}, fixture.unitId);
|
||||
assert(
|
||||
completed.acted &&
|
||||
completed.pendingMove === null &&
|
||||
completed.commandMenu?.visible === false,
|
||||
`Keyboard Enter on wait must complete exactly one unit action: ${JSON.stringify(completed)}`
|
||||
);
|
||||
assert(
|
||||
completed.pointerInputCount === 0,
|
||||
`The command action cycle emitted mouse/pointer input after setup: ${JSON.stringify(completed)}`
|
||||
);
|
||||
assert(
|
||||
pageErrors.length === 0,
|
||||
`Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}`
|
||||
);
|
||||
assert(
|
||||
consoleErrors.length === 0,
|
||||
`Unexpected console errors: ${JSON.stringify(consoleErrors.slice(-5))}`
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Verified ${renderer} keyboard-only battle command flow at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} DPR1 under /heros_web/: ` +
|
||||
'default focus skips disabled commands, Tab and Shift+Tab wrap available rows, Enter opens strategy, ' +
|
||||
'usable focus skips unavailable choices, Esc preserves the selected unit and pending move, and Enter completes wait with zero mouse input.'
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await stopServerProcess(serverProcess);
|
||||
}
|
||||
|
||||
function withDebugOptions(url, requestedRenderer) {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set('debug', '1');
|
||||
parsed.searchParams.set('renderer', requestedRenderer);
|
||||
parsed.searchParams.set('debugCombatAssetWatchdogMs', '250');
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function focusMenuItem(page, expectedId) {
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const keyboard = await page.evaluate(
|
||||
() => window.__HEROS_DEBUG__?.battle()?.commandMenuKeyboard
|
||||
);
|
||||
assertCommandMenuFocus(keyboard, keyboard?.mode);
|
||||
if (keyboard.focusedItem.id === expectedId) {
|
||||
return;
|
||||
}
|
||||
const target = keyboard.items.find(({ id }) => id === expectedId);
|
||||
assert(
|
||||
target?.available,
|
||||
`Expected ${expectedId} to be keyboard-selectable: ${JSON.stringify(keyboard)}`
|
||||
);
|
||||
await page.keyboard.press('Tab');
|
||||
}
|
||||
throw new Error(`Could not focus command menu item ${expectedId}.`);
|
||||
}
|
||||
|
||||
function assertCommandMenuFocus(keyboard, expectedMode) {
|
||||
assert(
|
||||
keyboard?.visible === true &&
|
||||
keyboard.mode === expectedMode &&
|
||||
keyboard.focusedItem?.available === true &&
|
||||
keyboard.focusedItem.bounds &&
|
||||
keyboard.focusedItem.lineWidth >= 3 &&
|
||||
keyboard.focusMarker?.visible === true &&
|
||||
keyboard.focusMarker.text === '▶' &&
|
||||
keyboard.focusMarker.bounds &&
|
||||
boundsInside(keyboard.focusMarker.bounds, keyboard.focusedItem.bounds) &&
|
||||
keyboard.guidance?.text.includes('↑↓/Tab') &&
|
||||
keyboard.guidance.text.includes('Enter') &&
|
||||
keyboard.guidance.text.includes('Esc') &&
|
||||
boundsInsideViewport(keyboard.guidance.bounds),
|
||||
`Expected one visible non-color keyboard focus and on-screen controls guidance: ${JSON.stringify(keyboard)}`
|
||||
);
|
||||
assert(
|
||||
keyboard.items.filter(({ focused }) => focused).length === 1,
|
||||
`Expected exactly one focused menu item: ${JSON.stringify(keyboard)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function readBattleState(page) {
|
||||
return page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
}
|
||||
|
||||
function boundsInside(inner, outer) {
|
||||
return (
|
||||
inner.x >= outer.x - 1 &&
|
||||
inner.y >= outer.y - 1 &&
|
||||
inner.x + inner.width <= outer.x + outer.width + 1 &&
|
||||
inner.y + inner.height <= outer.y + outer.height + 1
|
||||
);
|
||||
}
|
||||
|
||||
function boundsInsideViewport(bounds) {
|
||||
return (
|
||||
bounds &&
|
||||
bounds.x >= 0 &&
|
||||
bounds.y >= 0 &&
|
||||
bounds.x + bounds.width <= desktopBrowserViewport.width &&
|
||||
bounds.y + bounds.height <= desktopBrowserViewport.height
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForDebugApi(page) {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Boolean(
|
||||
window.__HEROS_DEBUG__?.activeScenes &&
|
||||
window.__HEROS_DEBUG__?.goToBattle
|
||||
),
|
||||
undefined,
|
||||
{ timeout: 90000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function assertDesktopViewport(page) {
|
||||
const viewport = await page.evaluate(() => {
|
||||
const canvas = document.querySelector('canvas');
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
dpr: window.devicePixelRatio,
|
||||
zoom: window.visualViewport?.scale ?? 1,
|
||||
canvas: canvas
|
||||
? { width: canvas.width, height: canvas.height }
|
||||
: null
|
||||
};
|
||||
});
|
||||
assert(
|
||||
viewport.width === desktopBrowserViewport.width &&
|
||||
viewport.height === desktopBrowserViewport.height &&
|
||||
viewport.dpr === 1 &&
|
||||
viewport.zoom === 1 &&
|
||||
viewport.canvas?.width === desktopBrowserViewport.width &&
|
||||
viewport.canvas?.height === desktopBrowserViewport.height,
|
||||
`Expected explicit 1920x1080 CSS viewport, 100% zoom, and DPR1: ${JSON.stringify(viewport)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function assertRendererAndBasePath(page, requestedRenderer) {
|
||||
const runtime = await page.evaluate(() => ({
|
||||
pathname: window.location.pathname,
|
||||
rendererType: window.__HEROS_GAME__?.renderer?.type ?? null,
|
||||
rendererName:
|
||||
window.__HEROS_GAME__?.renderer?.constructor?.name ?? null
|
||||
}));
|
||||
const expectedRendererType = requestedRenderer === 'canvas' ? 1 : 2;
|
||||
assert(
|
||||
runtime.pathname === '/heros_web/' &&
|
||||
runtime.rendererType === expectedRendererType,
|
||||
`Expected ${requestedRenderer} under /heros_web/: ${JSON.stringify(runtime)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureLocalServer(url) {
|
||||
if (await canReach(url)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = new URL(url);
|
||||
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) {
|
||||
throw new Error(`No server responded at ${url}`);
|
||||
}
|
||||
|
||||
const stderr = [];
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'node_modules/vite/bin/vite.js',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
parsed.port || '41796',
|
||||
'--strictPort'
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
);
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
|
||||
child.stdout.on('data', () => {});
|
||||
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
if (await canReach(url)) {
|
||||
return child;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
await stopServerProcess(child);
|
||||
throw new Error(
|
||||
`Vite server did not start at ${url}\n${stderr.join('')}`
|
||||
);
|
||||
}
|
||||
|
||||
async function canReach(url) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(1000)
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function stopServerProcess(child) {
|
||||
if (!child || child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exited = new Promise((resolve) => {
|
||||
child.once('exit', resolve);
|
||||
});
|
||||
child.kill();
|
||||
await Promise.race([exited, delay(5000)]);
|
||||
if (child.exitCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
await Promise.race([exited, delay(2000)]);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -136,9 +136,11 @@ function validateCampInteractionUxGuards() {
|
||||
checkedGuardCount += 1;
|
||||
|
||||
expectCampUx(
|
||||
noticeMethod.includes('delay: this.campNoticeHoldDuration(message)') &&
|
||||
noticeMethod.includes('const holdDuration = this.campNoticeHoldDuration(message)') &&
|
||||
noticeMethod.includes('delay: holdDuration') &&
|
||||
noticeMethod.includes('this.time.delayedCall(holdDuration, removeNotice)') &&
|
||||
noticeMethod.includes('text.height + 28'),
|
||||
'camp notices must size multi-line content and use a message-aware hold duration'
|
||||
'camp notices must size multi-line content, use a message-aware hold duration, and preserve it without fade motion'
|
||||
);
|
||||
checkedGuardCount += 1;
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ const fixtures = [
|
||||
quickAchieved: false,
|
||||
sourceTurn: 34,
|
||||
expectedVariant: 'fort-recovery',
|
||||
expectedTargetDialogueId: 'liu-guan-after-guangzong-road'
|
||||
expectedTargetDialogueId: 'liu-guan-after-guangzong-road',
|
||||
reducedMotion: true
|
||||
},
|
||||
{
|
||||
id: 'canvas-fort-missed-quick-achieved',
|
||||
@@ -139,6 +140,14 @@ async function verifyFixture(browser, baseUrl, fixture) {
|
||||
});
|
||||
await waitForDebugApi(page);
|
||||
await assertFhdViewport(page, fixture);
|
||||
if (fixture.reducedMotion) {
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.setItem(
|
||||
'heros-web:visual-motion-mode',
|
||||
'reduced'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const seeded = await seedThirdVictoryCamp(page, fixture);
|
||||
assert.deepEqual(
|
||||
@@ -160,16 +169,50 @@ async function verifyFixture(browser, baseUrl, fixture) {
|
||||
await page.evaluate(async () => {
|
||||
await window.__HEROS_DEBUG__.goToCamp();
|
||||
});
|
||||
await waitForPriorityReturn(page, fixture, false);
|
||||
await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__.scene.getScene('CampScene');
|
||||
scene.activeTab = 'visit';
|
||||
scene.scene.restart();
|
||||
});
|
||||
let camp = await waitForPriorityReturn(
|
||||
page,
|
||||
fixture,
|
||||
false
|
||||
false,
|
||||
true
|
||||
);
|
||||
assert.equal(camp.campaign.step, 'third-camp');
|
||||
assert.equal(camp.campBattleId, sourceBattleId);
|
||||
assert.equal(
|
||||
camp.activeTab,
|
||||
'dialogue',
|
||||
`${fixture.id}: a pending priority return must reclaim focus after CampScene reuse.`
|
||||
);
|
||||
if (fixture.reducedMotion) {
|
||||
assert.equal(
|
||||
camp.visualMotionReduced,
|
||||
true,
|
||||
`${fixture.id}: CampScene must honor the persisted reduced-motion preference.`
|
||||
);
|
||||
assert.equal(
|
||||
camp.campSkin?.transition?.last?.durationMs,
|
||||
0,
|
||||
`${fixture.id}: reduced motion must remove the camp backdrop fade.`
|
||||
);
|
||||
assert.equal(
|
||||
camp.campSkin?.transition?.active,
|
||||
false,
|
||||
`${fixture.id}: reduced motion must not leave a camp transition active.`
|
||||
);
|
||||
assert.equal(
|
||||
camp.campSkin?.transition?.completedRevision,
|
||||
camp.campSkin?.transition?.revision,
|
||||
`${fixture.id}: the zero-duration camp transition must complete synchronously.`
|
||||
);
|
||||
}
|
||||
assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, {
|
||||
completed: false,
|
||||
requireRowBounds: false
|
||||
requireRowBounds: true
|
||||
});
|
||||
|
||||
const dialogueTab = camp.campTabs.find(
|
||||
@@ -201,13 +244,6 @@ async function verifyFixture(browser, baseUrl, fixture) {
|
||||
`dist/verification-third-battle-priority-return-${fixture.id}-tab-badge.png`
|
||||
);
|
||||
|
||||
await clickSceneBounds(page, 'CampScene', dialogueTab.bounds);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__HEROS_DEBUG__?.camp?.()?.activeTab ===
|
||||
'dialogue'
|
||||
);
|
||||
camp = await waitForPriorityReturn(page, fixture, false, true);
|
||||
const priorityReturn = camp.thirdBattlePriorityReturn;
|
||||
assertPriorityReturn(priorityReturn, fixture, {
|
||||
completed: false,
|
||||
|
||||
299
scripts/verify-title-accessibility-browser.mjs
Normal file
299
scripts/verify-title-accessibility-browser.mjs
Normal file
@@ -0,0 +1,299 @@
|
||||
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.TITLE_ACCESSIBILITY_QA_URL;
|
||||
const serverUrl = new URL(explicitUrl ?? 'http://127.0.0.1:41764/heros_web/');
|
||||
let serverProcess;
|
||||
let browser;
|
||||
|
||||
try {
|
||||
if (!explicitUrl) {
|
||||
serverProcess = spawn(
|
||||
process.execPath,
|
||||
['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '41764', '--strictPort'],
|
||||
{ cwd: process.cwd(), stdio: 'ignore', windowsHide: true }
|
||||
);
|
||||
await waitForUrl(serverUrl, 20000);
|
||||
}
|
||||
|
||||
browser = await chromium.launch({ headless: true });
|
||||
for (const renderer of ['canvas', 'webgl']) {
|
||||
await verifyRenderer(browser, renderer);
|
||||
}
|
||||
|
||||
console.info('Verified title keyboard focus, modal return, save-slot skipping, and live reduced motion in Canvas and WebGL.');
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await stopServerProcess(serverProcess);
|
||||
}
|
||||
|
||||
async function verifyRenderer(browserInstance, renderer) {
|
||||
const context = await browserInstance.newContext(desktopBrowserContextOptions);
|
||||
const page = await context.newPage();
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const targetUrl = new URL(serverUrl);
|
||||
targetUrl.searchParams.set('debug', '1');
|
||||
targetUrl.searchParams.set('renderer', renderer);
|
||||
await page.goto(targetUrl.href, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await waitForTitle(page);
|
||||
|
||||
const initial = await titleSnapshot(page);
|
||||
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.equal(initial.title.focus.scope, 'main');
|
||||
assert.equal(initial.title.focus.itemId, 'new-game');
|
||||
assert.equal(focusItem(initial.title, 'continue').enabled, false);
|
||||
assert.equal(focusItem(initial.title, 'new-game').focusIndicatorVisible, true);
|
||||
assert.equal(initial.title.motion.mode, 'reduced');
|
||||
assert.equal(initial.title.motion.reduced, true);
|
||||
assert.equal(initial.title.motion.activeInfiniteTweenCount, 0);
|
||||
|
||||
await page.keyboard.press('ArrowDown');
|
||||
assert.equal((await titleState(page)).focus.itemId, 'settings', 'ArrowDown must skip disabled Continue.');
|
||||
await page.keyboard.press('Enter');
|
||||
let title = await waitForFocus(page, 'settings', 'sound');
|
||||
assert.equal(title.panels.settings, true);
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
for (const [expectedId, activationKey] of [
|
||||
['music', 'Enter'],
|
||||
['effects', 'Space'],
|
||||
['ambience', 'Enter'],
|
||||
['combat-presentation', 'Space']
|
||||
]) {
|
||||
await page.keyboard.press('Tab');
|
||||
title = await waitForFocus(page, 'settings', expectedId);
|
||||
await page.keyboard.press(activationKey);
|
||||
assert.equal((await titleState(page)).focus.itemId, expectedId);
|
||||
}
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
title = await waitForFocus(page, 'settings', 'visual-motion');
|
||||
assert.equal(title.motion.reduced, true);
|
||||
await page.keyboard.press('Enter');
|
||||
title = await titleState(page);
|
||||
assert.equal(title.motion.mode, 'full');
|
||||
assert.equal(title.motion.reduced, false);
|
||||
assert.equal(title.motion.activeInfiniteTweenCount, 36);
|
||||
await page.keyboard.press('Space');
|
||||
title = await titleState(page);
|
||||
assert.equal(title.motion.mode, 'reduced');
|
||||
assert.equal(title.motion.reduced, true);
|
||||
assert.equal(title.motion.activeInfiniteTweenCount, 0);
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
await waitForFocus(page, 'settings', 'close');
|
||||
await page.keyboard.press('Enter');
|
||||
title = await waitForFocus(page, 'main', 'settings');
|
||||
assert.equal(title.panels.settings, false);
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await waitForFocus(page, 'settings', 'sound');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await waitForFocus(page, 'settings', 'close');
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForFocus(page, 'main', 'settings');
|
||||
|
||||
const newGameBounds = focusItem(await titleState(page), 'new-game').bounds;
|
||||
await page.mouse.move(newGameBounds.x + newGameBounds.width / 2, newGameBounds.y + newGameBounds.height / 2);
|
||||
await waitForFocus(page, 'main', 'new-game');
|
||||
await page.mouse.move(4, 4);
|
||||
title = await titleState(page);
|
||||
assert.equal(title.focus.itemId, 'new-game', 'Pointer-out must preserve the persistent focus.');
|
||||
assert.equal(focusItem(title, 'new-game').focusIndicatorVisible, true);
|
||||
|
||||
await seedMultipleSavesAndRestartTitle(page);
|
||||
title = await waitForFocus(page, 'main', 'continue');
|
||||
assert.equal(title.motion.reduced, true);
|
||||
assert.equal(title.motion.activeInfiniteTweenCount, 0);
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
title = await waitForFocus(page, 'save-slots', 'slot-1');
|
||||
assert.equal(title.panels.saveSlots, true);
|
||||
assert.equal(focusItem(title, 'slot-2').enabled, false);
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await waitForFocus(page, 'save-slots', 'slot-3');
|
||||
await page.keyboard.press('Tab');
|
||||
await waitForFocus(page, 'save-slots', 'close');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await waitForFocus(page, 'save-slots', 'slot-3');
|
||||
await page.screenshot({
|
||||
path: `dist/qa-title-accessibility-${renderer}-1920x1080.png`,
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
const slot1Bounds = focusItem(await titleState(page), 'slot-1').bounds;
|
||||
await page.mouse.move(slot1Bounds.x + slot1Bounds.width / 2, slot1Bounds.y + slot1Bounds.height / 2);
|
||||
await waitForFocus(page, 'save-slots', 'slot-1');
|
||||
await page.mouse.move(4, 4);
|
||||
assert.equal((await titleState(page)).focus.itemId, 'slot-1');
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForFocus(page, 'main', 'continue');
|
||||
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await waitForFocus(page, 'main', 'new-game');
|
||||
await page.keyboard.press('Space');
|
||||
await waitForFocus(page, 'new-game-confirm', 'confirm');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await waitForFocus(page, 'new-game-confirm', 'cancel');
|
||||
await page.keyboard.press('Enter');
|
||||
await waitForFocus(page, 'main', 'new-game');
|
||||
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await waitForFocus(page, 'main', 'continue');
|
||||
await page.keyboard.press('Space');
|
||||
await waitForFocus(page, 'save-slots', 'slot-1');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await waitForFocus(page, 'save-slots', 'slot-3');
|
||||
await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__.scene.getScene('TitleScene');
|
||||
window.__TITLE_QA_ACTIVATED_SLOT__ = undefined;
|
||||
scene.continueGame = (slot) => {
|
||||
window.__TITLE_QA_ACTIVATED_SLOT__ = slot;
|
||||
};
|
||||
});
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(() => window.__TITLE_QA_ACTIVATED_SLOT__ === 3);
|
||||
|
||||
assert.deepEqual(pageErrors, [], `${renderer} page errors: ${pageErrors.join('\n')}`);
|
||||
assert.deepEqual(consoleErrors, [], `${renderer} console errors: ${consoleErrors.join('\n')}`);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function seedMultipleSavesAndRestartTitle(page) {
|
||||
await page.evaluate(() => {
|
||||
const makeSave = (slot, updatedAt) => ({
|
||||
version: 1,
|
||||
updatedAt,
|
||||
step: 'prologue',
|
||||
activeSaveSlot: slot
|
||||
});
|
||||
const slot1 = makeSave(1, '2026-07-27T08:00:00.000Z');
|
||||
const slot3 = makeSave(3, '2026-07-27T09:00:00.000Z');
|
||||
window.localStorage.setItem('heros-web:campaign-state', JSON.stringify(slot1));
|
||||
window.localStorage.setItem('heros-web:campaign-state:slot-1', JSON.stringify(slot1));
|
||||
window.localStorage.removeItem('heros-web:campaign-state:slot-2');
|
||||
window.localStorage.setItem('heros-web:campaign-state:slot-3', JSON.stringify(slot3));
|
||||
window.__HEROS_GAME__.scene.getScene('TitleScene').scene.restart();
|
||||
});
|
||||
await waitForTitle(page);
|
||||
}
|
||||
|
||||
async function waitForTitle(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const title = window.__HEROS_DEBUG__?.title?.();
|
||||
return title?.scene === 'TitleScene' && title.focus?.items?.length > 0;
|
||||
}, undefined, { timeout: 90000 });
|
||||
}
|
||||
|
||||
async function waitForFocus(page, scope, itemId) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
({ expectedScope, expectedItemId }) => {
|
||||
const focus = window.__HEROS_DEBUG__?.title?.()?.focus;
|
||||
return focus?.scope === expectedScope && focus?.itemId === expectedItemId;
|
||||
},
|
||||
{ expectedScope: scope, expectedItemId: itemId },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
} catch (error) {
|
||||
const actual = await titleState(page);
|
||||
throw new Error(
|
||||
`Timed out waiting for ${scope}/${itemId}; current focus is ${actual?.focus?.scope}/${actual?.focus?.itemId}.`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
return titleState(page);
|
||||
}
|
||||
|
||||
async function titleState(page) {
|
||||
return page.evaluate(() => window.__HEROS_DEBUG__.title());
|
||||
}
|
||||
|
||||
async function titleSnapshot(page) {
|
||||
return page.evaluate(() => {
|
||||
const canvasBounds = document.querySelector('canvas')?.getBoundingClientRect();
|
||||
return {
|
||||
viewport: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
dpr: window.devicePixelRatio,
|
||||
scale: window.visualViewport?.scale ?? 1
|
||||
},
|
||||
canvas: canvasBounds
|
||||
? {
|
||||
x: canvasBounds.x,
|
||||
y: canvasBounds.y,
|
||||
width: canvasBounds.width,
|
||||
height: canvasBounds.height
|
||||
}
|
||||
: null,
|
||||
title: window.__HEROS_DEBUG__.title()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function focusItem(title, id) {
|
||||
const item = title.focus.items.find((candidate) => candidate.id === id);
|
||||
assert(item, `Expected focus item ${id}: ${JSON.stringify(title.focus)}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function waitForUrl(url, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// The local development server is still starting.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${url.href}`);
|
||||
}
|
||||
|
||||
async function stopServerProcess(child) {
|
||||
if (!child || child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exited = new Promise((resolve) => {
|
||||
child.once('exit', resolve);
|
||||
});
|
||||
child.kill();
|
||||
await Promise.race([exited, delay(5000)]);
|
||||
if (child.exitCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
await Promise.race([exited, delay(2000)]);
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -114,6 +114,17 @@ try {
|
||||
titleSource.includes('ui(468)'),
|
||||
'The title settings panel must expose the persisted motion toggle in its expanded layout.'
|
||||
);
|
||||
assert(
|
||||
titleSource.includes('this.visualMotionReduced = isVisualMotionReduced();') &&
|
||||
titleSource.includes('this.applyTitleMotionPreference(this.visualMotionReduced);'),
|
||||
'TitleScene must honor the stored or operating-system motion preference on creation.'
|
||||
);
|
||||
assert(
|
||||
titleSource.includes('this.stopTitleMotionTweens();') &&
|
||||
titleSource.includes("this.applyTitleMotionPreference(nextMode === 'reduced');") &&
|
||||
titleSource.includes('activeInfiniteTweenCount: this.titleMotionTweens.length'),
|
||||
'The title motion toggle must immediately stop or rebuild its tracked infinite atmosphere tweens.'
|
||||
);
|
||||
|
||||
const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8');
|
||||
assert(
|
||||
|
||||
Reference in New Issue
Block a user