feat: improve early battle clarity and keyboard flow
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
desktopBrowserViewport
|
||||
} from './desktop-browser-viewport.mjs';
|
||||
|
||||
const battleId = 'second-battle-yellow-turban-pursuit';
|
||||
const battleId = 'first-battle-zhuo-commandery';
|
||||
const renderer = process.env.VERIFY_BATTLE_KEYBOARD_RENDERER;
|
||||
if (!renderer) {
|
||||
for (const requestedRenderer of ['canvas', 'webgl']) {
|
||||
@@ -77,24 +77,82 @@ try {
|
||||
await page.waitForFunction(
|
||||
(requestedBattleId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
const activeScenes = window.__HEROS_DEBUG__?.activeScenes?.() ?? [];
|
||||
return (
|
||||
battle?.battleId === requestedBattleId &&
|
||||
['deployment', 'idle'].includes(battle.phase) &&
|
||||
battle.phase === 'deployment' &&
|
||||
battle.mapBackgroundReady === true &&
|
||||
(battle.phase === 'idle' ||
|
||||
['ready', 'degraded'].includes(battle.combatAssets?.status))
|
||||
['ready', 'degraded'].includes(battle.combatAssets?.status) &&
|
||||
activeScenes.length === 1 &&
|
||||
activeScenes[0] === 'BattleScene'
|
||||
);
|
||||
},
|
||||
battleId,
|
||||
{ timeout: 90000 }
|
||||
);
|
||||
|
||||
await installPointerInputRecorder(page);
|
||||
let state = await readBattleState(page);
|
||||
assertFirstBattleObjectiveDefinitions(state.objectiveDefinitions);
|
||||
assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start');
|
||||
assertDeploymentKeyboardOrder(state.deploymentKeyboard);
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
await waitForDeploymentFocus(page, state.deploymentKeyboard.items[0].id);
|
||||
state = await readBattleState(page);
|
||||
assertDeploymentKeyboardFocus(
|
||||
state.deploymentKeyboard,
|
||||
state.deploymentKeyboard.items[0].id
|
||||
);
|
||||
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await waitForDeploymentFocus(page, 'start');
|
||||
state = await readBattleState(page);
|
||||
assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start');
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await waitForDeploymentFocus(page, state.deploymentKeyboard.items[0].id);
|
||||
state = await readBattleState(page);
|
||||
assertDeploymentKeyboardFocus(
|
||||
state.deploymentKeyboard,
|
||||
state.deploymentKeyboard.items[0].id
|
||||
);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await waitForDeploymentFocus(page, 'start');
|
||||
state = await readBattleState(page);
|
||||
assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start');
|
||||
|
||||
const firstDeploymentItemId = state.deploymentKeyboard.items[0].id;
|
||||
const secondDeploymentItemId = state.deploymentKeyboard.items[1].id;
|
||||
await page.keyboard.down('ArrowRight');
|
||||
await waitForDeploymentFocus(page, firstDeploymentItemId);
|
||||
await page.keyboard.down('ArrowRight');
|
||||
await waitForDeploymentFocus(page, secondDeploymentItemId);
|
||||
await page.keyboard.up('ArrowRight');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await waitForDeploymentFocus(page, firstDeploymentItemId);
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await waitForDeploymentFocus(page, 'start');
|
||||
|
||||
await dispatchKeyDownWithoutKeyUp(page, 'Tab', 'Tab');
|
||||
await waitForDeploymentFocus(page, firstDeploymentItemId);
|
||||
await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
if (scene?.phase === 'deployment') {
|
||||
scene.confirmPreBattleDeployment();
|
||||
}
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
await page.keyboard.press('Tab');
|
||||
await waitForDeploymentFocus(page, secondDeploymentItemId);
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await waitForDeploymentFocus(page, firstDeploymentItemId);
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await waitForDeploymentFocus(page, 'start');
|
||||
|
||||
await page.screenshot({
|
||||
path: `dist/verification-battle-keyboard-deployment-focus-${renderer}.png`,
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
(requestedBattleId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
@@ -104,172 +162,301 @@ try {
|
||||
{ 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;
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.phase === 'idle' &&
|
||||
battle.activeBattleEvent === null &&
|
||||
battle.firstBattleTutorial?.active === true &&
|
||||
battle.firstBattleTutorial.step === 'select-unit'
|
||||
);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
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);
|
||||
state = await readBattleState(page);
|
||||
const tutorialPath = state.firstBattleTutorial?.path;
|
||||
assert(
|
||||
tutorialPath?.unitId &&
|
||||
tutorialPath.from &&
|
||||
tutorialPath.moveTile &&
|
||||
tutorialPath.targetId &&
|
||||
tutorialPath.targetTile,
|
||||
`Expected a complete first-battle tutorial path: ${JSON.stringify(state.firstBattleTutorial)}`
|
||||
);
|
||||
|
||||
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
|
||||
};
|
||||
state = await focusBattleCursorAt(page, tutorialPath.from, {
|
||||
expectedMode: 'unit',
|
||||
expectedUnitId: tutorialPath.unitId
|
||||
});
|
||||
|
||||
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)}`
|
||||
);
|
||||
assertIdleUnitKeyboardFocus(state, tutorialPath.unitId, tutorialPath.from);
|
||||
|
||||
await page.screenshot({
|
||||
path: `dist/verification-battle-keyboard-command-focus-${renderer}.png`,
|
||||
path: `dist/verification-battle-keyboard-idle-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('Enter');
|
||||
await page.waitForFunction(
|
||||
(expectedUnitId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.phase === 'moving' &&
|
||||
battle.selectedUnitId === expectedUnitId &&
|
||||
battle.firstBattleTutorial?.step === 'move'
|
||||
);
|
||||
},
|
||||
tutorialPath.unitId
|
||||
);
|
||||
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
let state = await readBattleState(page);
|
||||
state = await focusBattleCursorAt(page, tutorialPath.moveTile, {
|
||||
expectedMode: 'move'
|
||||
});
|
||||
assertBattleCursor(state.keyboardBattleCursor, tutorialPath.moveTile, 'move');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
(expectedUnitId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.phase === 'command' &&
|
||||
battle.selectedUnitId === expectedUnitId &&
|
||||
battle.pendingMove?.unitId === expectedUnitId &&
|
||||
battle.firstBattleTutorial?.step === 'attack-command' &&
|
||||
battle.commandMenuKeyboard?.visible === true
|
||||
);
|
||||
},
|
||||
tutorialPath.unitId,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
await focusMenuItem(page, 'attack');
|
||||
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)}`
|
||||
state.commandMenuKeyboard.focusedItem.id === 'attack',
|
||||
`Expected attack to receive keyboard focus: ${JSON.stringify(state.commandMenuKeyboard)}`
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
(expectedUnitId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.phase === 'targeting' &&
|
||||
battle.selectedUnitId === expectedUnitId &&
|
||||
battle.firstBattleTutorial?.step === 'target-preview'
|
||||
);
|
||||
},
|
||||
tutorialPath.unitId
|
||||
);
|
||||
|
||||
state = await focusBattleCursorAt(page, tutorialPath.targetTile, {
|
||||
expectedMode: 'damage',
|
||||
expectedUnitId: tutorialPath.targetId
|
||||
});
|
||||
assertBattleCursor(
|
||||
state.keyboardBattleCursor,
|
||||
tutorialPath.targetTile,
|
||||
'damage',
|
||||
tutorialPath.targetId
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
(expectedTargetId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.firstBattleTutorial?.targetPreviewLocked === true &&
|
||||
battle.combatExchangePreview?.defenderId === expectedTargetId
|
||||
);
|
||||
},
|
||||
tutorialPath.targetId
|
||||
);
|
||||
|
||||
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)}`
|
||||
state.combatExchangePreview?.attackerId === tutorialPath.unitId &&
|
||||
state.combatExchangePreview.defenderId === tutorialPath.targetId &&
|
||||
Number.isFinite(state.combatExchangePreview.attackDamage) &&
|
||||
Number.isFinite(state.combatExchangePreview.attackHitRate),
|
||||
`Expected a keyboard-selected attack forecast before confirmation: ${JSON.stringify(state.combatExchangePreview)}`
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
await page.screenshot({
|
||||
path: `dist/verification-battle-keyboard-attack-preview-${renderer}.png`,
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
(unitId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.actedUnitIds?.includes(unitId) &&
|
||||
battle.commandMenuKeyboard?.visible === false &&
|
||||
battle.firstBattleTutorial?.active === false
|
||||
);
|
||||
},
|
||||
tutorialPath.unitId,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
const completed = await page.evaluate((unitId) => {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
const qa = window.__HEROS_KEYBOARD_COMMAND_QA__;
|
||||
return {
|
||||
selectedUnitId: state?.selectedUnitId ?? null,
|
||||
pendingMove: state?.pendingMove ?? null,
|
||||
acted: state?.actedUnitIds?.includes(unitId) ?? false,
|
||||
commandMenu: state?.commandMenuKeyboard ?? null,
|
||||
phase: state?.phase ?? null,
|
||||
tutorialActive: state?.firstBattleTutorial?.active ?? null,
|
||||
pointerInputCount: qa?.pointerInputCount ?? -1
|
||||
};
|
||||
}, tutorialPath.unitId);
|
||||
assert(
|
||||
visitedCommands.at(-1) === initialCommandFocus &&
|
||||
visitedCommands.every((id) => availableCommandIds.includes(id)),
|
||||
`Command focus must wrap and skip every unavailable row: ${JSON.stringify({
|
||||
availableCommandIds,
|
||||
visitedCommands
|
||||
completed.acted &&
|
||||
completed.commandMenu?.visible === false &&
|
||||
completed.tutorialActive === false,
|
||||
`Keyboard attack confirmation must complete exactly one first action: ${JSON.stringify(completed)}`
|
||||
);
|
||||
assert(
|
||||
completed.pointerInputCount === 0,
|
||||
`Deployment-to-attack keyboard flow emitted mouse/pointer input: ${JSON.stringify(completed)}`
|
||||
);
|
||||
|
||||
state = await waitForAutoFocusedUnitWithKeyboard(page);
|
||||
const handoffUnit = state.units?.find(
|
||||
(unit) => unit.id === state.selectedUnitId
|
||||
);
|
||||
assert(
|
||||
handoffUnit?.id === 'zhang-fei' &&
|
||||
handoffUnit.faction === 'ally' &&
|
||||
handoffUnit.hp > 0 &&
|
||||
handoffUnit.acted === false,
|
||||
`Expected the first completed action to hand keyboard focus to Zhang Fei: ${JSON.stringify({
|
||||
selectedUnitId: state.selectedUnitId,
|
||||
units: state.units
|
||||
})}`
|
||||
);
|
||||
|
||||
await focusMenuItem(page, 'strategy');
|
||||
const beforeUsable = await readBattleState(page);
|
||||
await focusBattleCursorAt(
|
||||
page,
|
||||
{ x: handoffUnit.x, y: handoffUnit.y },
|
||||
{ expectedMode: 'move' }
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
(unitId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.selectedUnitId === unitId &&
|
||||
battle.pendingMove?.unitId === unitId &&
|
||||
battle.commandMenuKeyboard?.visible === true
|
||||
);
|
||||
},
|
||||
handoffUnit.id
|
||||
);
|
||||
await focusMenuItem(page, 'wait');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
(unitId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.actedUnitIds?.includes(unitId) &&
|
||||
battle.commandMenuKeyboard?.visible === false
|
||||
);
|
||||
},
|
||||
handoffUnit.id
|
||||
);
|
||||
|
||||
state = await waitForAutoFocusedUnitWithKeyboard(page);
|
||||
const menuUnit = state.units?.find(
|
||||
(unit) => unit.id === state.selectedUnitId
|
||||
);
|
||||
assert(
|
||||
menuUnit?.id === 'liu-bei' &&
|
||||
menuUnit.faction === 'ally' &&
|
||||
menuUnit.hp > 0 &&
|
||||
menuUnit.acted === false,
|
||||
`Expected Zhang Fei's wait to hand keyboard focus to Liu Bei: ${JSON.stringify({
|
||||
selectedUnitId: state.selectedUnitId,
|
||||
units: state.units
|
||||
})}`
|
||||
);
|
||||
|
||||
await focusBattleCursorAt(
|
||||
page,
|
||||
{ x: menuUnit.x, y: menuUnit.y },
|
||||
{ expectedMode: 'move' }
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
(unitId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.selectedUnitId === unitId &&
|
||||
battle.pendingMove?.unitId === unitId &&
|
||||
battle.commandMenuKeyboard?.visible === true &&
|
||||
battle.commandMenuKeyboard.mode === 'command'
|
||||
);
|
||||
},
|
||||
menuUnit.id
|
||||
);
|
||||
|
||||
state = await readBattleState(page);
|
||||
assertCommandMenuFocus(state.commandMenuKeyboard, 'command');
|
||||
const commandItems = state.commandMenuKeyboard.items;
|
||||
const attackCommand = commandItems.find(({ id }) => id === 'attack');
|
||||
const strategyCommand = commandItems.find(({ id }) => id === 'strategy');
|
||||
assert(
|
||||
attackCommand?.available === false &&
|
||||
strategyCommand?.available === true &&
|
||||
state.commandMenuKeyboard.focusedItem.id === 'strategy',
|
||||
`Default command focus must skip unavailable attack and land on strategy: ${JSON.stringify(state.commandMenuKeyboard)}`
|
||||
);
|
||||
|
||||
const availableCommandIds = commandItems
|
||||
.filter(({ available }) => available)
|
||||
.map(({ id }) => id);
|
||||
const strategyIndex = availableCommandIds.indexOf('strategy');
|
||||
const previousCommandId =
|
||||
availableCommandIds[
|
||||
(strategyIndex - 1 + availableCommandIds.length) %
|
||||
availableCommandIds.length
|
||||
];
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
state = await readBattleState(page);
|
||||
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 === 'strategy',
|
||||
`Tab must return to strategy without visiting disabled attack: ${JSON.stringify(state.commandMenuKeyboard)}`
|
||||
);
|
||||
|
||||
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)}`
|
||||
`Usable focus must skip unavailable choices: ${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)}`
|
||||
state.commandMenuKeyboard?.focusedItem?.available === true,
|
||||
`Usable Shift+Tab must keep focus on an available choice: ${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;
|
||||
@@ -280,13 +467,10 @@ try {
|
||||
);
|
||||
});
|
||||
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)}`
|
||||
state.selectedUnitId === menuUnit.id &&
|
||||
state.pendingMove?.unitId === menuUnit.id,
|
||||
`Esc must return one menu level without clearing the unit or pending move: ${JSON.stringify(state)}`
|
||||
);
|
||||
|
||||
await focusMenuItem(page, 'wait');
|
||||
@@ -295,37 +479,43 @@ try {
|
||||
(unitId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.commandMenuKeyboard?.visible === false &&
|
||||
battle?.actedUnitIds?.includes(unitId)
|
||||
battle?.actedUnitIds?.includes(unitId) &&
|
||||
battle.commandMenuKeyboard?.visible === false
|
||||
);
|
||||
},
|
||||
fixture.unitId
|
||||
menuUnit.id
|
||||
);
|
||||
|
||||
const completed = await page.evaluate((unitId) => {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
const finalInputState = await page.evaluate(() => {
|
||||
const qa = window.__HEROS_KEYBOARD_COMMAND_QA__;
|
||||
if (qa?.recordPointerInput) {
|
||||
window.removeEventListener('mousedown', qa.recordPointerInput, true);
|
||||
window.removeEventListener('pointerdown', qa.recordPointerInput, true);
|
||||
window.removeEventListener(
|
||||
'mousedown',
|
||||
qa.recordPointerInput,
|
||||
true
|
||||
);
|
||||
window.removeEventListener(
|
||||
'pointerdown',
|
||||
qa.recordPointerInput,
|
||||
true
|
||||
);
|
||||
}
|
||||
if (qa?.recordKeyboardInput) {
|
||||
window.removeEventListener(
|
||||
'keydown',
|
||||
qa.recordKeyboardInput,
|
||||
true
|
||||
);
|
||||
}
|
||||
return {
|
||||
selectedUnitId: state?.selectedUnitId ?? null,
|
||||
pendingMove: state?.pendingMove ?? null,
|
||||
acted: state?.actedUnitIds?.includes(unitId) ?? false,
|
||||
commandMenu: state?.commandMenuKeyboard ?? null,
|
||||
pointerInputCount: qa?.pointerInputCount ?? -1
|
||||
pointerInputCount: qa?.pointerInputCount ?? -1,
|
||||
keyboardInputCount: qa?.keyboardInputs?.length ?? 0
|
||||
};
|
||||
}, 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)}`
|
||||
finalInputState.pointerInputCount === 0 &&
|
||||
finalInputState.keyboardInputCount > 0,
|
||||
`Full keyboard regression flow must complete with zero pointer input: ${JSON.stringify(finalInputState)}`
|
||||
);
|
||||
assert(
|
||||
pageErrors.length === 0,
|
||||
@@ -337,9 +527,10 @@ try {
|
||||
);
|
||||
|
||||
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.'
|
||||
`Verified ${renderer} keyboard-only first-battle flow at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} DPR1 under /heros_web/: ` +
|
||||
'deployment focus cycles with Tab/Shift+Tab and held arrows, focus-loss recovery works without keyup, Enter starts the recommended deployment, ' +
|
||||
'idle ally selection, movement, attack forecast, and confirmation complete one action; disabled command and usable choices are skipped, ' +
|
||||
'submenu Escape preserves the pending move, and a second action completes with zero pointer input.'
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
@@ -354,6 +545,245 @@ function withDebugOptions(url, requestedRenderer) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function dispatchKeyDownWithoutKeyUp(page, key, code) {
|
||||
await page.evaluate(
|
||||
({ requestedKey, requestedCode }) => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: requestedKey,
|
||||
code: requestedCode,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
);
|
||||
},
|
||||
{ requestedKey: key, requestedCode: code }
|
||||
);
|
||||
}
|
||||
|
||||
async function installPointerInputRecorder(page) {
|
||||
await page.evaluate(() => {
|
||||
const recordPointerInput = () => {
|
||||
window.__HEROS_KEYBOARD_COMMAND_QA__.pointerInputCount += 1;
|
||||
};
|
||||
const recordKeyboardInput = (event) => {
|
||||
window.__HEROS_KEYBOARD_COMMAND_QA__.keyboardInputs.push({
|
||||
code: event.code,
|
||||
key: event.key,
|
||||
shiftKey: event.shiftKey,
|
||||
repeat: event.repeat
|
||||
});
|
||||
};
|
||||
window.__HEROS_KEYBOARD_COMMAND_QA__ = {
|
||||
pointerInputCount: 0,
|
||||
recordPointerInput,
|
||||
recordKeyboardInput,
|
||||
keyboardInputs: []
|
||||
};
|
||||
window.addEventListener('mousedown', recordPointerInput, true);
|
||||
window.addEventListener('pointerdown', recordPointerInput, true);
|
||||
window.addEventListener('keydown', recordKeyboardInput, true);
|
||||
});
|
||||
}
|
||||
|
||||
function assertDeploymentKeyboardOrder(keyboard) {
|
||||
const ids = keyboard?.items?.map(({ id }) => id) ?? [];
|
||||
const firstSlotIndex = ids.findIndex((id) => id.startsWith('slot:'));
|
||||
const lastUnitIndex = ids.findLastIndex((id) => id.startsWith('unit:'));
|
||||
assert(
|
||||
ids.length >= 4 &&
|
||||
ids.at(-2) === 'restore' &&
|
||||
ids.at(-1) === 'start' &&
|
||||
lastUnitIndex >= 0 &&
|
||||
firstSlotIndex > lastUnitIndex &&
|
||||
ids.slice(0, firstSlotIndex).every((id) => id.startsWith('unit:')) &&
|
||||
ids
|
||||
.slice(firstSlotIndex, -2)
|
||||
.every((id) => id.startsWith('slot:')),
|
||||
`Expected deployment order roles -> slots -> restore -> start: ${JSON.stringify(keyboard)}`
|
||||
);
|
||||
}
|
||||
|
||||
function assertFirstBattleObjectiveDefinitions(objectives) {
|
||||
assert(
|
||||
Array.isArray(objectives) && objectives.length === 4,
|
||||
`Expected exactly four first-battle objective definitions: ${JSON.stringify(objectives)}`
|
||||
);
|
||||
|
||||
const byId = Object.fromEntries(
|
||||
objectives.map((objective) => [objective.id, objective])
|
||||
);
|
||||
assert(
|
||||
byId.leader?.kind === 'defeat-leader' &&
|
||||
byId.leader.rewardGold === 200,
|
||||
`Expected the commander objective to reward 200 gold: ${JSON.stringify(byId.leader)}`
|
||||
);
|
||||
assert(
|
||||
byId['liu-bei']?.kind === 'keep-unit-alive' &&
|
||||
byId['liu-bei'].rewardGold === 100,
|
||||
`Expected the Liu Bei survival objective to reward 100 gold: ${JSON.stringify(byId['liu-bei'])}`
|
||||
);
|
||||
assert(
|
||||
byId.village?.kind === 'secure-terrain' &&
|
||||
byId.village.terrain === 'village' &&
|
||||
byId.village.targetTile?.radius === 4 &&
|
||||
byId.village.rewardGold === 210,
|
||||
`Expected the village objective to expose radius 4 and reward 210 gold: ${JSON.stringify(byId.village)}`
|
||||
);
|
||||
assert(
|
||||
byId.quick?.kind === 'quick-victory' &&
|
||||
byId.quick.maxTurn === 15 &&
|
||||
byId.quick.rewardGold === 120,
|
||||
`Expected the quick-victory objective to expose 15 turns and reward 120 gold: ${JSON.stringify(byId.quick)}`
|
||||
);
|
||||
}
|
||||
|
||||
function assertDeploymentKeyboardFocus(keyboard, expectedId) {
|
||||
const focusedItem = keyboard?.focusedItem;
|
||||
const matchingItem = keyboard?.items?.find(({ id }) => id === expectedId);
|
||||
const guidanceText = keyboard?.guidance?.text ?? '';
|
||||
assert(
|
||||
keyboard?.visible === true &&
|
||||
focusedItem?.id === expectedId &&
|
||||
focusedItem.available !== false &&
|
||||
matchingItem?.focused === true &&
|
||||
keyboard.items.filter(({ focused }) => focused).length === 1 &&
|
||||
focusedItem.bounds &&
|
||||
boundsInsideViewport(focusedItem.bounds) &&
|
||||
(focusedItem.lineWidth ?? matchingItem.lineWidth ?? 0) >= 3 &&
|
||||
guidanceText.includes('Tab') &&
|
||||
guidanceText.includes('Enter') &&
|
||||
guidanceText.includes('Esc') &&
|
||||
boundsInsideViewport(keyboard.guidance?.bounds),
|
||||
`Expected visible deployment keyboard focus and guidance on ${expectedId}: ${JSON.stringify(keyboard)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForDeploymentFocus(page, expectedId) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(id) =>
|
||||
window.__HEROS_DEBUG__?.battle()?.deploymentKeyboard?.focusedItem
|
||||
?.id === id,
|
||||
expectedId
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostic = await page.evaluate(() => ({
|
||||
deploymentKeyboard: window.__HEROS_DEBUG__?.battle()?.deploymentKeyboard,
|
||||
keyboardInputs: window.__HEROS_KEYBOARD_COMMAND_QA__?.keyboardInputs ?? []
|
||||
}));
|
||||
throw new Error(
|
||||
`Timed out waiting for deployment keyboard focus ${expectedId}: ${JSON.stringify(diagnostic)}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function focusBattleCursorAt(
|
||||
page,
|
||||
expectedTile,
|
||||
{ expectedMode, expectedUnitId } = {}
|
||||
) {
|
||||
for (let attempt = 0; attempt < 96; attempt += 1) {
|
||||
const state = await readBattleState(page);
|
||||
const cursor = state?.keyboardBattleCursor;
|
||||
const cursorUnitId =
|
||||
cursor?.unitId ??
|
||||
cursor?.targetId ??
|
||||
state?.units?.find(
|
||||
(unit) =>
|
||||
unit.hp > 0 &&
|
||||
unit.x === cursor?.x &&
|
||||
unit.y === cursor?.y
|
||||
)?.id;
|
||||
if (
|
||||
cursor?.x === expectedTile.x &&
|
||||
cursor?.y === expectedTile.y &&
|
||||
(!expectedMode || cursor.mode === expectedMode) &&
|
||||
(!expectedUnitId || cursorUnitId === expectedUnitId)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(20);
|
||||
}
|
||||
throw new Error(
|
||||
`Could not focus ${expectedMode ?? 'battle'} cursor at ${expectedTile.x},${expectedTile.y} for ${expectedUnitId ?? 'any unit'}.`
|
||||
);
|
||||
}
|
||||
|
||||
function assertIdleUnitKeyboardFocus(state, expectedUnitId, expectedTile) {
|
||||
const focusedUnit = state?.units?.find(
|
||||
(unit) =>
|
||||
unit.hp > 0 &&
|
||||
unit.x === state.keyboardBattleCursor?.x &&
|
||||
unit.y === state.keyboardBattleCursor?.y
|
||||
);
|
||||
assert(
|
||||
state?.phase === 'idle' &&
|
||||
state.selectedUnitId === null &&
|
||||
state.keyboardBattleCursor?.mode === 'unit' &&
|
||||
focusedUnit?.id === expectedUnitId &&
|
||||
focusedUnit.faction === 'ally' &&
|
||||
focusedUnit.acted === false &&
|
||||
state.keyboardBattleCursor.x === expectedTile.x &&
|
||||
state.keyboardBattleCursor.y === expectedTile.y,
|
||||
`Expected one keyboard-focused actionable ally before selection: ${JSON.stringify({
|
||||
cursor: state?.keyboardBattleCursor,
|
||||
focusedUnit,
|
||||
selectedUnitId: state?.selectedUnitId,
|
||||
phase: state?.phase
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
function assertBattleCursor(cursor, expectedTile, expectedMode, expectedTargetId) {
|
||||
assert(
|
||||
cursor?.mode === expectedMode &&
|
||||
cursor.x === expectedTile.x &&
|
||||
cursor.y === expectedTile.y &&
|
||||
(!expectedTargetId ||
|
||||
cursor.targetId === expectedTargetId ||
|
||||
cursor.unitId === expectedTargetId),
|
||||
`Expected ${expectedMode} keyboard cursor at ${expectedTile.x},${expectedTile.y}: ${JSON.stringify(cursor)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForAutoFocusedUnitWithKeyboard(page) {
|
||||
for (let attempt = 0; attempt < 300; attempt += 1) {
|
||||
const state = await readBattleState(page);
|
||||
const selected = state?.units?.find(
|
||||
(unit) => unit.id === state.selectedUnitId
|
||||
);
|
||||
if (
|
||||
state?.phase === 'moving' &&
|
||||
state.activeFaction === 'ally' &&
|
||||
!state.activeBattleEvent &&
|
||||
selected?.faction === 'ally' &&
|
||||
selected.hp > 0 &&
|
||||
selected.acted === false
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
if (state?.activeBattleEvent) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
const state = await readBattleState(page);
|
||||
throw new Error(
|
||||
`Could not reach the next auto-focused ally after a keyboard action: ${JSON.stringify({
|
||||
phase: state?.phase,
|
||||
activeFaction: state?.activeFaction,
|
||||
activeBattleEvent: state?.activeBattleEvent,
|
||||
combatCutIn: state?.combatCutIn,
|
||||
selectedUnitId: state?.selectedUnitId,
|
||||
keyboardBattleCursor: state?.keyboardBattleCursor,
|
||||
actedUnitIds: state?.actedUnitIds
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
async function focusMenuItem(page, expectedId) {
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const keyboard = await page.evaluate(
|
||||
|
||||
Reference in New Issue
Block a user