2054 lines
47 KiB
JavaScript
2054 lines
47 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdir } from 'node:fs/promises';
|
|
import { chromium } from 'playwright';
|
|
import {
|
|
desktopBrowserContextOptions,
|
|
desktopBrowserDeviceScaleFactor,
|
|
desktopBrowserViewport
|
|
} from './desktop-browser-viewport.mjs';
|
|
|
|
const battleId = 'first-battle-zhuo-commandery';
|
|
const campaignKey = 'heros-web:campaign-state';
|
|
const campaignSlotKey = (slot) =>
|
|
`${campaignKey}:slot-${slot}`;
|
|
const battleBaseKey = `heros-web:battle:${battleId}`;
|
|
const battleSlotKey = (slot) =>
|
|
`${battleBaseKey}:slot-${slot}`;
|
|
const manualSlotKey = (slot) =>
|
|
`heros-web:manual-save:slot-${slot}`;
|
|
const otherBattleSlotTwoKey =
|
|
'heros-web:battle:second-battle-yellow-turban-pursuit:slot-2';
|
|
const explicitUrl =
|
|
process.env.VERIFY_MANUAL_SAVE_UX_URL;
|
|
const requestedRenderer =
|
|
process.env.VERIFY_MANUAL_SAVE_UX_RENDERER;
|
|
const renderers = requestedRenderer
|
|
? [requestedRenderer]
|
|
: ['canvas', 'webgl'];
|
|
assert(
|
|
renderers.every((renderer) =>
|
|
['canvas', 'webgl'].includes(renderer)
|
|
),
|
|
'VERIFY_MANUAL_SAVE_UX_RENDERER must be canvas or webgl.'
|
|
);
|
|
const serverUrl = new URL(
|
|
explicitUrl ??
|
|
'http://127.0.0.1:41937/heros_web/'
|
|
);
|
|
|
|
let browser;
|
|
let serverProcess;
|
|
|
|
try {
|
|
await mkdir('dist', { recursive: true });
|
|
if (!explicitUrl) {
|
|
serverProcess = spawn(
|
|
process.execPath,
|
|
[
|
|
'node_modules/vite/bin/vite.js',
|
|
'--host',
|
|
'127.0.0.1',
|
|
'--port',
|
|
serverUrl.port,
|
|
'--strictPort'
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
stdio: 'ignore',
|
|
windowsHide: true
|
|
}
|
|
);
|
|
await waitForUrl(serverUrl, 30_000);
|
|
}
|
|
|
|
for (const renderer of renderers) {
|
|
browser = await chromium.launch({
|
|
headless:
|
|
process.env.VERIFY_MANUAL_SAVE_UX_HEADLESS !==
|
|
'0',
|
|
args:
|
|
renderer === 'webgl'
|
|
? [
|
|
'--use-angle=swiftshader',
|
|
'--enable-unsafe-swiftshader'
|
|
]
|
|
: []
|
|
});
|
|
try {
|
|
await verifyRenderer(browser, renderer);
|
|
} finally {
|
|
await browser.close();
|
|
browser = undefined;
|
|
}
|
|
}
|
|
|
|
console.info(
|
|
`Verified separate auto/manual title focus, damaged-record visibility, protected-byte invariance, explicit restore confirmation/cancellation, camp and battle restore routing, exact turn/faction resume, numeric shortcuts, Escape handling, and 1920x1080 ${renderers.join('/')} rendering.`
|
|
);
|
|
} 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'
|
|
});
|
|
page.setDefaultTimeout(30_000);
|
|
|
|
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());
|
|
console.info(
|
|
`[${renderer}] browser console error: ${message.text()}`
|
|
);
|
|
}
|
|
});
|
|
|
|
try {
|
|
const reportStep = (step) =>
|
|
console.info(`[${renderer}] ${step}`);
|
|
const targetUrl = new URL(serverUrl);
|
|
targetUrl.searchParams.set('debug', '1');
|
|
targetUrl.searchParams.set(
|
|
'renderer',
|
|
renderer
|
|
);
|
|
await page.goto(targetUrl.href, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90_000
|
|
});
|
|
await waitForDebugApi(page);
|
|
await page.evaluate(() =>
|
|
window.localStorage.clear()
|
|
);
|
|
await page.reload({
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90_000
|
|
});
|
|
await waitForTitle(page);
|
|
await assertDesktopRuntime(
|
|
page,
|
|
renderer,
|
|
'TitleScene'
|
|
);
|
|
|
|
reportStep('empty title');
|
|
await verifyEmptyTitleState(
|
|
page,
|
|
renderer
|
|
);
|
|
const seededCampaign =
|
|
await seedAutomaticCampaign(page);
|
|
reportStep('automatic-only title');
|
|
await verifyAutoOnlyTitleState(
|
|
page,
|
|
renderer
|
|
);
|
|
|
|
reportStep('camp manual save');
|
|
await openCamp(page);
|
|
await assertDesktopRuntime(
|
|
page,
|
|
renderer,
|
|
'CampScene'
|
|
);
|
|
const campSave =
|
|
await verifyCampManualSaveIsolation(
|
|
page,
|
|
renderer,
|
|
seededCampaign
|
|
);
|
|
|
|
reportStep('title camp restore');
|
|
await verifyTitleCampRestore(
|
|
page,
|
|
renderer,
|
|
campSave
|
|
);
|
|
reportStep('battle manual save');
|
|
await openBattle(page);
|
|
await assertDesktopRuntime(
|
|
page,
|
|
renderer,
|
|
'BattleScene'
|
|
);
|
|
const battleSave =
|
|
await verifyBattleManualSaveIsolation(
|
|
page,
|
|
renderer
|
|
);
|
|
reportStep('title battle restore');
|
|
await verifyTitleBattleRestore(
|
|
page,
|
|
renderer,
|
|
battleSave
|
|
);
|
|
reportStep('complete');
|
|
|
|
assert.deepEqual(
|
|
pageErrors,
|
|
[],
|
|
`${renderer}: page errors:\n${pageErrors.join(
|
|
'\n'
|
|
)}`
|
|
);
|
|
assert.deepEqual(
|
|
consoleErrors,
|
|
[],
|
|
`${renderer}: console errors:\n${consoleErrors.join(
|
|
'\n'
|
|
)}`
|
|
);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
async function verifyEmptyTitleState(
|
|
page,
|
|
renderer
|
|
) {
|
|
const title = await titleState(page);
|
|
assert.equal(title.focus.scope, 'main');
|
|
assert.equal(title.focus.itemId, 'new-game');
|
|
assertTitleMainItems(title, {
|
|
continueEnabled: false,
|
|
manualEnabled: false
|
|
});
|
|
|
|
const texts = await visibleSceneTexts(
|
|
page,
|
|
'TitleScene'
|
|
);
|
|
assert(
|
|
texts.includes('자동 저장 이어하기'),
|
|
`${renderer}: the empty title did not expose the automatic-continue label.`
|
|
);
|
|
assert(
|
|
texts.includes('수동 기록 불러오기'),
|
|
`${renderer}: the empty title did not expose the separate manual-load label.`
|
|
);
|
|
|
|
await page.keyboard.press('ArrowDown');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'settings'
|
|
);
|
|
await page.keyboard.press('ArrowUp');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'new-game'
|
|
);
|
|
|
|
await page.evaluate((key) => {
|
|
window.localStorage.setItem(
|
|
key,
|
|
'{"version":999,"slot":1}'
|
|
);
|
|
window.__HEROS_GAME__.scene
|
|
.getScene('TitleScene')
|
|
.scene.restart();
|
|
}, manualSlotKey(1));
|
|
await page.waitForFunction(() => {
|
|
const title =
|
|
window.__HEROS_DEBUG__?.title?.();
|
|
return (
|
|
title?.focus?.scope === 'main' &&
|
|
title.focus.items.some(
|
|
(item) =>
|
|
item.id === 'manual-load' &&
|
|
item.enabled === true
|
|
)
|
|
);
|
|
});
|
|
await page.keyboard.press('ArrowDown');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'manual-load'
|
|
);
|
|
await page.keyboard.press('Enter');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'save-slots',
|
|
'close'
|
|
);
|
|
await waitForVisibleSceneText(
|
|
page,
|
|
'TitleScene',
|
|
'손상된 수동 기록'
|
|
);
|
|
const damagedLabels =
|
|
await visibleSceneTexts(
|
|
page,
|
|
'TitleScene'
|
|
);
|
|
assert(
|
|
damagedLabels.includes(
|
|
'손상된 수동 기록'
|
|
),
|
|
`${renderer}: a damaged-only manual record cannot be inspected.`
|
|
);
|
|
await page.keyboard.press('Escape');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'manual-load'
|
|
);
|
|
await page.evaluate((key) => {
|
|
window.localStorage.removeItem(key);
|
|
window.__HEROS_GAME__.scene
|
|
.getScene('TitleScene')
|
|
.scene.restart();
|
|
}, manualSlotKey(1));
|
|
await page.waitForFunction(() => {
|
|
const title =
|
|
window.__HEROS_DEBUG__?.title?.();
|
|
return (
|
|
title?.focus?.scope === 'main' &&
|
|
title.focus.items.some(
|
|
(item) =>
|
|
item.id === 'manual-load' &&
|
|
item.enabled === false
|
|
)
|
|
);
|
|
});
|
|
}
|
|
|
|
async function seedAutomaticCampaign(page) {
|
|
const state = await page.evaluate(async () => {
|
|
const campaignModule = await import(
|
|
'/heros_web/src/game/state/campaignState.ts'
|
|
);
|
|
const started =
|
|
campaignModule.startNewCampaign();
|
|
const fixture = structuredClone(started);
|
|
fixture.step = 'first-battle';
|
|
fixture.activeSaveSlot = 1;
|
|
fixture.gold = 431;
|
|
fixture.storyCheckpoint = undefined;
|
|
fixture.explorationCheckpoint = undefined;
|
|
fixture.sortiePreparationCheckpoint =
|
|
undefined;
|
|
fixture.completedTutorialIds = [
|
|
...new Set([
|
|
...fixture.completedTutorialIds,
|
|
'first-battle-basic-controls'
|
|
])
|
|
];
|
|
const saved =
|
|
campaignModule.setCampaignState(fixture);
|
|
window.__HEROS_GAME__.scene
|
|
.getScene('TitleScene')
|
|
.scene.restart();
|
|
return saved;
|
|
});
|
|
await waitForTitle(page);
|
|
await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'continue'
|
|
);
|
|
return state;
|
|
}
|
|
|
|
async function verifyAutoOnlyTitleState(
|
|
page,
|
|
renderer
|
|
) {
|
|
const title = await titleState(page);
|
|
assert.equal(title.focus.itemId, 'continue');
|
|
assertTitleMainItems(title, {
|
|
continueEnabled: true,
|
|
manualEnabled: false
|
|
});
|
|
|
|
await page.keyboard.press('ArrowDown');
|
|
const skipped = await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'settings'
|
|
);
|
|
assert.equal(
|
|
skipped.focus.itemId,
|
|
'settings',
|
|
`${renderer}: the disabled manual-load item was not skipped.`
|
|
);
|
|
await page.keyboard.press('ArrowUp');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'continue'
|
|
);
|
|
}
|
|
|
|
async function openCamp(page) {
|
|
await page.evaluate(async () => {
|
|
await window.__HEROS_DEBUG__.goToCamp();
|
|
});
|
|
await page.waitForFunction(
|
|
() => {
|
|
const state =
|
|
window.__HEROS_DEBUG__?.camp?.();
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene(
|
|
'CampScene'
|
|
);
|
|
return (
|
|
state?.scene === 'CampScene' &&
|
|
state?.campaign?.step ===
|
|
'first-battle' &&
|
|
scene?.scene?.isActive?.() === true &&
|
|
scene.children.list.some(
|
|
(child) =>
|
|
child?.active &&
|
|
child?.visible &&
|
|
child?.text === '수동 저장'
|
|
)
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 90_000 }
|
|
);
|
|
}
|
|
|
|
async function verifyCampManualSaveIsolation(
|
|
page,
|
|
renderer,
|
|
seededCampaign
|
|
) {
|
|
const fixture = await page.evaluate(
|
|
({
|
|
requestedCampaignKey,
|
|
requestedCampaignSlotKey,
|
|
requestedBattleBaseKey,
|
|
requestedBattleSlotKey
|
|
}) => {
|
|
const battleMarker = JSON.stringify({
|
|
kind: 'automatic-battle-sentinel',
|
|
revision: 1
|
|
});
|
|
window.localStorage.setItem(
|
|
requestedBattleBaseKey,
|
|
battleMarker
|
|
);
|
|
window.localStorage.setItem(
|
|
requestedBattleSlotKey,
|
|
battleMarker
|
|
);
|
|
const keys = [
|
|
requestedCampaignKey,
|
|
requestedCampaignSlotKey,
|
|
requestedBattleBaseKey,
|
|
requestedBattleSlotKey
|
|
];
|
|
return {
|
|
keys,
|
|
bytes: Object.fromEntries(
|
|
keys.map((key) => [
|
|
key,
|
|
window.localStorage.getItem(key)
|
|
])
|
|
)
|
|
};
|
|
},
|
|
{
|
|
requestedCampaignKey: campaignKey,
|
|
requestedCampaignSlotKey:
|
|
campaignSlotKey(1),
|
|
requestedBattleBaseKey: battleBaseKey,
|
|
requestedBattleSlotKey:
|
|
battleSlotKey(1)
|
|
}
|
|
);
|
|
fixture.keys.forEach((key) => {
|
|
assert.notEqual(
|
|
fixture.bytes[key],
|
|
null,
|
|
`${renderer}: missing automatic fixture byte ${key}.`
|
|
);
|
|
});
|
|
|
|
await clickSceneText(
|
|
page,
|
|
'CampScene',
|
|
'수동 저장'
|
|
);
|
|
await waitForCampSavePanel(page, true);
|
|
let labels = await visibleSceneTexts(
|
|
page,
|
|
'CampScene'
|
|
);
|
|
assert(
|
|
labels.includes('수동 저장 기록'),
|
|
`${renderer}: the camp manual-save panel title is missing.`
|
|
);
|
|
assert(
|
|
labels.some((label) =>
|
|
label.includes(
|
|
'자동 저장과 별도 보관'
|
|
)
|
|
),
|
|
`${renderer}: the camp panel does not explain automatic/manual separation.`
|
|
);
|
|
await page.screenshot({
|
|
path:
|
|
`dist/qa-manual-save-camp-${renderer}-1920x1080.png`,
|
|
animations: 'disabled',
|
|
timeout: 90_000
|
|
});
|
|
|
|
await page.keyboard.press('Escape');
|
|
await waitForCampSavePanel(page, false);
|
|
|
|
await clickSceneText(
|
|
page,
|
|
'CampScene',
|
|
'수동 저장'
|
|
);
|
|
await waitForCampSavePanel(page, true);
|
|
await page.keyboard.press('2');
|
|
try {
|
|
await page.waitForFunction(
|
|
(key) =>
|
|
window.localStorage.getItem(key) !== null,
|
|
manualSlotKey(2),
|
|
{ timeout: 5_000 }
|
|
);
|
|
} catch (error) {
|
|
const diagnostic = await page.evaluate(
|
|
(key) => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene(
|
|
'CampScene'
|
|
);
|
|
return {
|
|
active:
|
|
scene?.scene?.isActive?.(),
|
|
navigationPending:
|
|
scene?.navigationPending,
|
|
saveSlotObjects:
|
|
scene?.saveSlotObjects?.length,
|
|
saveSlotConfirmObjects:
|
|
scene?.saveSlotConfirmObjects?.length,
|
|
stored:
|
|
window.localStorage.getItem(key),
|
|
texts: scene?.children?.list
|
|
?.filter(
|
|
(child) =>
|
|
child?.active &&
|
|
child?.visible &&
|
|
typeof child?.text === 'string'
|
|
)
|
|
.map((child) => child.text)
|
|
};
|
|
},
|
|
manualSlotKey(2)
|
|
);
|
|
throw new Error(
|
|
`${renderer}: camp numeric shortcut did not save: ${JSON.stringify(
|
|
diagnostic
|
|
)}`,
|
|
{ cause: error }
|
|
);
|
|
}
|
|
await waitForCampSavePanel(page, false);
|
|
|
|
const afterSave = await page.evaluate(
|
|
({ keys, requestedManualKey }) => ({
|
|
bytes: Object.fromEntries(
|
|
keys.map((key) => [
|
|
key,
|
|
window.localStorage.getItem(key)
|
|
])
|
|
),
|
|
manualRaw:
|
|
window.localStorage.getItem(
|
|
requestedManualKey
|
|
)
|
|
}),
|
|
{
|
|
keys: fixture.keys,
|
|
requestedManualKey: manualSlotKey(2)
|
|
}
|
|
);
|
|
assert.deepEqual(
|
|
afterSave.bytes,
|
|
fixture.bytes,
|
|
`${renderer}: camp manual save modified automatic campaign/battle bytes.`
|
|
);
|
|
assert(afterSave.manualRaw);
|
|
const manual = JSON.parse(afterSave.manualRaw);
|
|
assert.equal(manual.version, 1);
|
|
assert.equal(manual.slot, 2);
|
|
assert.equal(manual.context, 'camp');
|
|
assert.equal(manual.battle, undefined);
|
|
assert.equal(
|
|
manual.campaign.step,
|
|
'first-battle'
|
|
);
|
|
assert.equal(
|
|
manual.campaign.gold,
|
|
seededCampaign.gold
|
|
);
|
|
|
|
const autoMutation = await page.evaluate(
|
|
async ({
|
|
requestedManualKey,
|
|
requestedBattleBaseKey,
|
|
requestedBattleSlotKey
|
|
}) => {
|
|
const manualBefore =
|
|
window.localStorage.getItem(
|
|
requestedManualKey
|
|
);
|
|
const campaignModule = await import(
|
|
'/heros_web/src/game/state/campaignState.ts'
|
|
);
|
|
const changed =
|
|
campaignModule.getCampaignState();
|
|
changed.gold += 87;
|
|
const auto =
|
|
campaignModule.saveCampaignState(
|
|
changed,
|
|
1
|
|
);
|
|
const changedBattle = JSON.stringify({
|
|
kind: 'automatic-battle-sentinel',
|
|
revision: 2
|
|
});
|
|
window.localStorage.setItem(
|
|
requestedBattleBaseKey,
|
|
changedBattle
|
|
);
|
|
window.localStorage.setItem(
|
|
requestedBattleSlotKey,
|
|
changedBattle
|
|
);
|
|
return {
|
|
autoGold: auto.gold,
|
|
manualBefore,
|
|
manualAfter:
|
|
window.localStorage.getItem(
|
|
requestedManualKey
|
|
)
|
|
};
|
|
},
|
|
{
|
|
requestedManualKey: manualSlotKey(2),
|
|
requestedBattleBaseKey: battleBaseKey,
|
|
requestedBattleSlotKey:
|
|
battleSlotKey(1)
|
|
}
|
|
);
|
|
assert.equal(
|
|
autoMutation.manualAfter,
|
|
autoMutation.manualBefore,
|
|
`${renderer}: later automatic progress changed the protected camp record bytes.`
|
|
);
|
|
assert.notEqual(
|
|
autoMutation.autoGold,
|
|
manual.campaign.gold
|
|
);
|
|
|
|
return {
|
|
raw: afterSave.manualRaw,
|
|
envelope: manual,
|
|
autoSlotOneRaw:
|
|
await page.evaluate(
|
|
(key) =>
|
|
window.localStorage.getItem(key),
|
|
campaignSlotKey(1)
|
|
)
|
|
};
|
|
}
|
|
|
|
async function verifyTitleCampRestore(
|
|
page,
|
|
renderer,
|
|
campSave
|
|
) {
|
|
await startTitle(page);
|
|
await assertDesktopRuntime(
|
|
page,
|
|
renderer,
|
|
'TitleScene'
|
|
);
|
|
|
|
let title = await titleState(page);
|
|
assert.equal(title.focus.itemId, 'continue');
|
|
assertTitleMainItems(title, {
|
|
continueEnabled: true,
|
|
manualEnabled: true
|
|
});
|
|
await page.keyboard.press('ArrowDown');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'manual-load'
|
|
);
|
|
await page.keyboard.press('ArrowUp');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'continue'
|
|
);
|
|
await page.keyboard.press('ArrowDown');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'manual-load'
|
|
);
|
|
await page.keyboard.press('Enter');
|
|
title = await waitForTitleFocus(
|
|
page,
|
|
'save-slots',
|
|
'slot-2'
|
|
);
|
|
await waitForVisibleSceneText(
|
|
page,
|
|
'TitleScene',
|
|
'수동 기록 불러오기 (1~3)'
|
|
);
|
|
assert.equal(
|
|
title.panels.saveSlotMode,
|
|
'manual'
|
|
);
|
|
assert.equal(
|
|
focusItem(title, 'slot-1').enabled,
|
|
false
|
|
);
|
|
assert.equal(
|
|
focusItem(title, 'slot-2').enabled,
|
|
true
|
|
);
|
|
assert.equal(
|
|
focusItem(title, 'slot-3').enabled,
|
|
false
|
|
);
|
|
|
|
await page.keyboard.press('1');
|
|
title = await titleState(page);
|
|
assert.equal(title.panels.saveSlots, true);
|
|
assert.equal(title.focus.itemId, 'slot-2');
|
|
await page.keyboard.press('3');
|
|
title = await titleState(page);
|
|
assert.equal(title.panels.saveSlots, true);
|
|
assert.equal(title.focus.itemId, 'slot-2');
|
|
|
|
await page.screenshot({
|
|
path:
|
|
`dist/qa-manual-save-title-camp-${renderer}-1920x1080.png`,
|
|
animations: 'disabled',
|
|
timeout: 90_000
|
|
});
|
|
await page.keyboard.press('Escape');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'manual-load'
|
|
);
|
|
|
|
const targetFixture = await page.evaluate(
|
|
({
|
|
requestedCampaignKey,
|
|
requestedTargetCampaignKey,
|
|
requestedTargetBattleKey,
|
|
requestedOtherBattleKey
|
|
}) => {
|
|
const current = JSON.parse(
|
|
window.localStorage.getItem(
|
|
requestedCampaignKey
|
|
)
|
|
);
|
|
const occupied = {
|
|
...current,
|
|
activeSaveSlot: 2,
|
|
gold: 9_999,
|
|
updatedAt:
|
|
'2026-07-29T01:02:03.000Z'
|
|
};
|
|
window.localStorage.setItem(
|
|
requestedTargetCampaignKey,
|
|
JSON.stringify(occupied)
|
|
);
|
|
window.localStorage.setItem(
|
|
requestedTargetBattleKey,
|
|
'{"stale":"target-battle"}'
|
|
);
|
|
window.localStorage.setItem(
|
|
requestedOtherBattleKey,
|
|
'{"stale":"other-battle"}'
|
|
);
|
|
return {
|
|
slotOneRaw:
|
|
window.localStorage.getItem(
|
|
`${requestedCampaignKey}:slot-1`
|
|
),
|
|
targetCampaignRaw:
|
|
window.localStorage.getItem(
|
|
requestedTargetCampaignKey
|
|
),
|
|
targetBattleRaw:
|
|
window.localStorage.getItem(
|
|
requestedTargetBattleKey
|
|
),
|
|
otherBattleRaw:
|
|
window.localStorage.getItem(
|
|
requestedOtherBattleKey
|
|
)
|
|
};
|
|
},
|
|
{
|
|
requestedCampaignKey: campaignKey,
|
|
requestedTargetCampaignKey:
|
|
campaignSlotKey(2),
|
|
requestedTargetBattleKey:
|
|
battleSlotKey(2),
|
|
requestedOtherBattleKey:
|
|
otherBattleSlotTwoKey
|
|
}
|
|
);
|
|
|
|
await page.evaluate(() => {
|
|
const scene =
|
|
window.__HEROS_GAME__.scene.getScene(
|
|
'TitleScene'
|
|
);
|
|
window.__MANUAL_SAVE_QA_CONTINUE_SLOT__ =
|
|
undefined;
|
|
scene.continueGame = (slot) => {
|
|
window.__MANUAL_SAVE_QA_CONTINUE_SLOT__ =
|
|
slot;
|
|
};
|
|
});
|
|
await page.keyboard.press('Enter');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'save-slots',
|
|
'slot-2'
|
|
);
|
|
await page.keyboard.press('2');
|
|
title = await waitForTitleFocus(
|
|
page,
|
|
'manual-load-confirm',
|
|
'cancel'
|
|
);
|
|
assert.equal(
|
|
title.panels.manualLoadConfirm,
|
|
true
|
|
);
|
|
assert.equal(
|
|
title.panels.manualLoadConfirmSlot,
|
|
2
|
|
);
|
|
const beforeConfirmation = await page.evaluate(
|
|
({
|
|
requestedTargetCampaignKey,
|
|
requestedTargetBattleKey,
|
|
requestedOtherBattleKey,
|
|
requestedManualKey
|
|
}) => ({
|
|
targetCampaignRaw:
|
|
window.localStorage.getItem(
|
|
requestedTargetCampaignKey
|
|
),
|
|
targetBattleRaw:
|
|
window.localStorage.getItem(
|
|
requestedTargetBattleKey
|
|
),
|
|
otherBattleRaw:
|
|
window.localStorage.getItem(
|
|
requestedOtherBattleKey
|
|
),
|
|
manualRaw:
|
|
window.localStorage.getItem(
|
|
requestedManualKey
|
|
)
|
|
}),
|
|
{
|
|
requestedTargetCampaignKey:
|
|
campaignSlotKey(2),
|
|
requestedTargetBattleKey:
|
|
battleSlotKey(2),
|
|
requestedOtherBattleKey:
|
|
otherBattleSlotTwoKey,
|
|
requestedManualKey:
|
|
manualSlotKey(2)
|
|
}
|
|
);
|
|
assert.equal(
|
|
beforeConfirmation.targetCampaignRaw,
|
|
targetFixture.targetCampaignRaw,
|
|
`${renderer}: opening the manual-load confirmation changed the target automatic campaign.`
|
|
);
|
|
assert.equal(
|
|
beforeConfirmation.targetBattleRaw,
|
|
targetFixture.targetBattleRaw,
|
|
`${renderer}: opening the manual-load confirmation changed the target battle autosave.`
|
|
);
|
|
assert.equal(
|
|
beforeConfirmation.otherBattleRaw,
|
|
targetFixture.otherBattleRaw,
|
|
`${renderer}: opening the manual-load confirmation changed another battle autosave.`
|
|
);
|
|
assert.equal(
|
|
beforeConfirmation.manualRaw,
|
|
campSave.raw
|
|
);
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((resolve) =>
|
|
window.requestAnimationFrame(() =>
|
|
window.requestAnimationFrame(resolve)
|
|
)
|
|
)
|
|
);
|
|
await page.screenshot({
|
|
path:
|
|
`dist/qa-manual-save-title-confirm-${renderer}-1920x1080.png`,
|
|
animations: 'disabled',
|
|
timeout: 90_000
|
|
});
|
|
await page.keyboard.press('Escape');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'save-slots',
|
|
'slot-2'
|
|
);
|
|
const afterCancel = await page.evaluate(
|
|
({
|
|
requestedTargetCampaignKey,
|
|
requestedTargetBattleKey,
|
|
requestedOtherBattleKey
|
|
}) => ({
|
|
targetCampaignRaw:
|
|
window.localStorage.getItem(
|
|
requestedTargetCampaignKey
|
|
),
|
|
targetBattleRaw:
|
|
window.localStorage.getItem(
|
|
requestedTargetBattleKey
|
|
),
|
|
otherBattleRaw:
|
|
window.localStorage.getItem(
|
|
requestedOtherBattleKey
|
|
)
|
|
}),
|
|
{
|
|
requestedTargetCampaignKey:
|
|
campaignSlotKey(2),
|
|
requestedTargetBattleKey:
|
|
battleSlotKey(2),
|
|
requestedOtherBattleKey:
|
|
otherBattleSlotTwoKey
|
|
}
|
|
);
|
|
assert.deepEqual(
|
|
afterCancel,
|
|
{
|
|
targetCampaignRaw:
|
|
targetFixture.targetCampaignRaw,
|
|
targetBattleRaw:
|
|
targetFixture.targetBattleRaw,
|
|
otherBattleRaw:
|
|
targetFixture.otherBattleRaw
|
|
},
|
|
`${renderer}: cancelling manual restore changed automatic save bytes.`
|
|
);
|
|
await page.keyboard.press('2');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'manual-load-confirm',
|
|
'cancel'
|
|
);
|
|
await page.keyboard.press('ArrowUp');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'manual-load-confirm',
|
|
'confirm'
|
|
);
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__MANUAL_SAVE_QA_CONTINUE_SLOT__ ===
|
|
2
|
|
);
|
|
|
|
const restored = await page.evaluate(
|
|
({
|
|
requestedCampaignKey,
|
|
requestedTargetCampaignKey,
|
|
requestedManualKey,
|
|
requestedTargetBattleKey,
|
|
requestedOtherBattleKey
|
|
}) => ({
|
|
baseRaw:
|
|
window.localStorage.getItem(
|
|
requestedCampaignKey
|
|
),
|
|
targetRaw:
|
|
window.localStorage.getItem(
|
|
requestedTargetCampaignKey
|
|
),
|
|
slotOneRaw:
|
|
window.localStorage.getItem(
|
|
`${requestedCampaignKey}:slot-1`
|
|
),
|
|
manualRaw:
|
|
window.localStorage.getItem(
|
|
requestedManualKey
|
|
),
|
|
targetBattleRaw:
|
|
window.localStorage.getItem(
|
|
requestedTargetBattleKey
|
|
),
|
|
otherBattleRaw:
|
|
window.localStorage.getItem(
|
|
requestedOtherBattleKey
|
|
)
|
|
}),
|
|
{
|
|
requestedCampaignKey: campaignKey,
|
|
requestedTargetCampaignKey:
|
|
campaignSlotKey(2),
|
|
requestedManualKey: manualSlotKey(2),
|
|
requestedTargetBattleKey:
|
|
battleSlotKey(2),
|
|
requestedOtherBattleKey:
|
|
otherBattleSlotTwoKey
|
|
}
|
|
);
|
|
assert.equal(
|
|
restored.manualRaw,
|
|
campSave.raw,
|
|
`${renderer}: title restore modified the protected camp record.`
|
|
);
|
|
assert.equal(
|
|
restored.baseRaw,
|
|
restored.targetRaw,
|
|
`${renderer}: title restore did not make target slot 2 the automatic compatibility lane.`
|
|
);
|
|
assert.equal(
|
|
restored.slotOneRaw,
|
|
targetFixture.slotOneRaw,
|
|
`${renderer}: restoring slot 2 changed automatic slot 1.`
|
|
);
|
|
const restoredCampaign = JSON.parse(
|
|
restored.targetRaw
|
|
);
|
|
assert.equal(
|
|
restoredCampaign.activeSaveSlot,
|
|
2
|
|
);
|
|
assert.equal(
|
|
restoredCampaign.step,
|
|
campSave.envelope.campaign.step
|
|
);
|
|
assert.equal(
|
|
restoredCampaign.gold,
|
|
campSave.envelope.campaign.gold
|
|
);
|
|
assert.equal(restored.targetBattleRaw, null);
|
|
assert.equal(restored.otherBattleRaw, null);
|
|
}
|
|
|
|
async function openBattle(page) {
|
|
await page.evaluate(async (requestedBattleId) => {
|
|
await window.__HEROS_DEBUG__.goToBattle(
|
|
requestedBattleId
|
|
);
|
|
}, battleId);
|
|
await waitForBattleLoaded(page);
|
|
|
|
const phase = await page.evaluate(
|
|
() =>
|
|
window.__HEROS_DEBUG__?.battle?.()?.phase
|
|
);
|
|
if (phase === 'deployment') {
|
|
await page.evaluate(async () => {
|
|
const scene =
|
|
window.__HEROS_GAME__.scene.getScene(
|
|
'BattleScene'
|
|
);
|
|
await scene.confirmPreBattleDeployment();
|
|
});
|
|
}
|
|
await page.waitForFunction(
|
|
(requestedBattleId) => {
|
|
const state =
|
|
window.__HEROS_DEBUG__?.battle?.();
|
|
return (
|
|
state?.battleId ===
|
|
requestedBattleId &&
|
|
state?.phase === 'idle'
|
|
);
|
|
},
|
|
battleId,
|
|
{ timeout: 90_000 }
|
|
);
|
|
}
|
|
|
|
async function verifyBattleManualSaveIsolation(
|
|
page,
|
|
renderer
|
|
) {
|
|
const baseline = await page.evaluate(
|
|
({
|
|
requestedCampaignKey,
|
|
requestedCampaignSlotKey,
|
|
requestedBattleSlotKey
|
|
}) => {
|
|
const scene =
|
|
window.__HEROS_GAME__.scene.getScene(
|
|
'BattleScene'
|
|
);
|
|
scene.hideBattleEventBanner?.();
|
|
scene.completeFirstBattleTutorial?.(
|
|
'skipped'
|
|
);
|
|
scene.hideSaveSlotPanel?.();
|
|
scene.hideTurnEndPrompt?.();
|
|
scene.selectedUnit = undefined;
|
|
scene.pendingMove = undefined;
|
|
scene.targetingAction = undefined;
|
|
scene.selectedUsable = undefined;
|
|
scene.battleOutcome = undefined;
|
|
scene.resultNavigationPending = false;
|
|
scene.phase = 'idle';
|
|
scene.activeFaction = 'ally';
|
|
scene.turnNumber = 4;
|
|
scene.battleAutosaveReady = true;
|
|
const automaticSaved =
|
|
scene.persistBattleAutosave('debug');
|
|
const keys = [
|
|
requestedCampaignKey,
|
|
requestedCampaignSlotKey,
|
|
requestedBattleSlotKey
|
|
];
|
|
return {
|
|
automaticSaved,
|
|
state:
|
|
window.__HEROS_DEBUG__?.battle?.(),
|
|
keys,
|
|
bytes: Object.fromEntries(
|
|
keys.map((key) => [
|
|
key,
|
|
window.localStorage.getItem(key)
|
|
])
|
|
)
|
|
};
|
|
},
|
|
{
|
|
requestedCampaignKey: campaignKey,
|
|
requestedCampaignSlotKey:
|
|
campaignSlotKey(2),
|
|
requestedBattleSlotKey:
|
|
battleSlotKey(2)
|
|
}
|
|
);
|
|
assert.equal(
|
|
baseline.automaticSaved,
|
|
true,
|
|
`${renderer}: could not establish a safe automatic battle checkpoint.`
|
|
);
|
|
assert.equal(baseline.state.turnNumber, 4);
|
|
assert.equal(
|
|
baseline.state.activeFaction,
|
|
'ally'
|
|
);
|
|
baseline.keys.forEach((key) => {
|
|
assert.notEqual(
|
|
baseline.bytes[key],
|
|
null,
|
|
`${renderer}: missing automatic battle baseline ${key}.`
|
|
);
|
|
});
|
|
|
|
await page.evaluate(() => {
|
|
window.__HEROS_GAME__.scene
|
|
.getScene('BattleScene')
|
|
.showSaveSlotPanel('save');
|
|
});
|
|
await waitForBattleSavePanel(
|
|
page,
|
|
'save'
|
|
);
|
|
let labels = await visibleSceneTexts(
|
|
page,
|
|
'BattleScene'
|
|
);
|
|
assert(
|
|
labels.includes('전투 수동 저장'),
|
|
`${renderer}: the battle manual-save title is missing.`
|
|
);
|
|
assert(
|
|
labels.some((label) =>
|
|
label.includes(
|
|
'자동 저장과 별도 보관'
|
|
)
|
|
),
|
|
`${renderer}: the battle panel does not explain save separation.`
|
|
);
|
|
await page.screenshot({
|
|
path:
|
|
`dist/qa-manual-save-battle-save-${renderer}-1920x1080.png`,
|
|
animations: 'disabled',
|
|
timeout: 90_000
|
|
});
|
|
await page.keyboard.press('Escape');
|
|
await waitForBattleSavePanel(page, null);
|
|
|
|
await page.evaluate(() => {
|
|
window.__HEROS_GAME__.scene
|
|
.getScene('BattleScene')
|
|
.showSaveSlotPanel('save');
|
|
});
|
|
await waitForBattleSavePanel(
|
|
page,
|
|
'save'
|
|
);
|
|
await page.keyboard.press('3');
|
|
await page.waitForFunction(
|
|
(key) =>
|
|
window.localStorage.getItem(key) !== null,
|
|
manualSlotKey(3)
|
|
);
|
|
await waitForBattleSavePanel(page, null);
|
|
|
|
const afterSave = await page.evaluate(
|
|
({
|
|
keys,
|
|
requestedManualKey
|
|
}) => ({
|
|
bytes: Object.fromEntries(
|
|
keys.map((key) => [
|
|
key,
|
|
window.localStorage.getItem(key)
|
|
])
|
|
),
|
|
manualRaw:
|
|
window.localStorage.getItem(
|
|
requestedManualKey
|
|
)
|
|
}),
|
|
{
|
|
keys: baseline.keys,
|
|
requestedManualKey: manualSlotKey(3)
|
|
}
|
|
);
|
|
assert.deepEqual(
|
|
afterSave.bytes,
|
|
baseline.bytes,
|
|
`${renderer}: battle manual save modified automatic campaign/battle bytes.`
|
|
);
|
|
assert(afterSave.manualRaw);
|
|
const manual = JSON.parse(afterSave.manualRaw);
|
|
assert.equal(manual.version, 1);
|
|
assert.equal(manual.slot, 3);
|
|
assert.equal(manual.context, 'battle');
|
|
assert.equal(manual.battle.battleId, battleId);
|
|
assert.equal(manual.battle.turnNumber, 4);
|
|
assert.equal(
|
|
manual.battle.activeFaction,
|
|
'ally'
|
|
);
|
|
|
|
const autoMutation = await page.evaluate(
|
|
({
|
|
requestedManualKey,
|
|
requestedAutoBattleKey
|
|
}) => {
|
|
const scene =
|
|
window.__HEROS_GAME__.scene.getScene(
|
|
'BattleScene'
|
|
);
|
|
const manualBefore =
|
|
window.localStorage.getItem(
|
|
requestedManualKey
|
|
);
|
|
scene.turnNumber = 9;
|
|
scene.activeFaction = 'ally';
|
|
scene.phase = 'idle';
|
|
scene.selectedUnit = undefined;
|
|
scene.pendingMove = undefined;
|
|
scene.targetingAction = undefined;
|
|
scene.selectedUsable = undefined;
|
|
scene.battleAutosaveReady = true;
|
|
const automaticSaved =
|
|
scene.persistBattleAutosave('debug');
|
|
const autoBattle = JSON.parse(
|
|
window.localStorage.getItem(
|
|
requestedAutoBattleKey
|
|
)
|
|
);
|
|
scene.turnNumber = 11;
|
|
scene.activeFaction = 'enemy';
|
|
return {
|
|
automaticSaved,
|
|
autoTurnNumber:
|
|
autoBattle.turnNumber,
|
|
manualBefore,
|
|
manualAfter:
|
|
window.localStorage.getItem(
|
|
requestedManualKey
|
|
),
|
|
live:
|
|
window.__HEROS_DEBUG__?.battle?.()
|
|
};
|
|
},
|
|
{
|
|
requestedManualKey: manualSlotKey(3),
|
|
requestedAutoBattleKey:
|
|
battleSlotKey(2)
|
|
}
|
|
);
|
|
assert.equal(autoMutation.automaticSaved, true);
|
|
assert.equal(autoMutation.autoTurnNumber, 9);
|
|
assert.equal(autoMutation.live.turnNumber, 11);
|
|
assert.equal(
|
|
autoMutation.live.activeFaction,
|
|
'enemy'
|
|
);
|
|
assert.equal(
|
|
autoMutation.manualAfter,
|
|
autoMutation.manualBefore,
|
|
`${renderer}: a later battle autosave changed the protected battle record bytes.`
|
|
);
|
|
|
|
await page.evaluate(() => {
|
|
window.__HEROS_GAME__.scene
|
|
.getScene('BattleScene')
|
|
.showSaveSlotPanel('load');
|
|
});
|
|
await waitForBattleSavePanel(
|
|
page,
|
|
'load'
|
|
);
|
|
labels = await visibleSceneTexts(
|
|
page,
|
|
'BattleScene'
|
|
);
|
|
assert(
|
|
labels.includes('수동 기록 불러오기'),
|
|
`${renderer}: the battle manual-load title is missing.`
|
|
);
|
|
assert(
|
|
labels.some((label) =>
|
|
label.includes('4턴 · 아군')
|
|
),
|
|
`${renderer}: battle manual-load rows do not expose turn/faction metadata: ${JSON.stringify(
|
|
labels.filter((label) =>
|
|
label.includes('턴')
|
|
)
|
|
)}`
|
|
);
|
|
await page.keyboard.press('1');
|
|
assert.equal(
|
|
(await battleState(page))
|
|
.saveSlotPanelMode,
|
|
'load'
|
|
);
|
|
await page.keyboard.press('2');
|
|
assert.equal(
|
|
(await battleState(page))
|
|
.saveSlotPanelMode,
|
|
'load'
|
|
);
|
|
await page.screenshot({
|
|
path:
|
|
`dist/qa-manual-save-battle-load-${renderer}-1920x1080.png`,
|
|
animations: 'disabled',
|
|
timeout: 90_000
|
|
});
|
|
await page.keyboard.press('Escape');
|
|
await waitForBattleSavePanel(page, null);
|
|
|
|
return {
|
|
raw: afterSave.manualRaw,
|
|
envelope: manual
|
|
};
|
|
}
|
|
|
|
async function verifyTitleBattleRestore(
|
|
page,
|
|
renderer,
|
|
battleSave
|
|
) {
|
|
await startTitle(page);
|
|
await page.keyboard.press('ArrowDown');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'manual-load'
|
|
);
|
|
await page.keyboard.press('Enter');
|
|
let title = await waitForTitleFocus(
|
|
page,
|
|
'save-slots',
|
|
'slot-2'
|
|
);
|
|
await waitForVisibleSceneText(
|
|
page,
|
|
'TitleScene',
|
|
'수동 기록 불러오기 (1~3)'
|
|
);
|
|
assert.equal(
|
|
title.panels.saveSlotMode,
|
|
'manual'
|
|
);
|
|
assert.equal(
|
|
focusItem(title, 'slot-1').enabled,
|
|
false
|
|
);
|
|
assert.equal(
|
|
focusItem(title, 'slot-2').enabled,
|
|
true
|
|
);
|
|
assert.equal(
|
|
focusItem(title, 'slot-3').enabled,
|
|
true
|
|
);
|
|
|
|
const labels = await visibleSceneTexts(
|
|
page,
|
|
'TitleScene'
|
|
);
|
|
assert(
|
|
labels.some((label) =>
|
|
label.includes('전투 ·') &&
|
|
label.includes('4턴')
|
|
),
|
|
`${renderer}: title manual rows do not identify the battle turn: ${JSON.stringify(
|
|
labels
|
|
)}`
|
|
);
|
|
assert(
|
|
labels.some((label) =>
|
|
label.includes('아군 차례')
|
|
),
|
|
`${renderer}: title manual rows do not identify the saved faction.`
|
|
);
|
|
await page.screenshot({
|
|
path:
|
|
`dist/qa-manual-save-title-battle-${renderer}-1920x1080.png`,
|
|
animations: 'disabled',
|
|
timeout: 90_000
|
|
});
|
|
|
|
await page.keyboard.press('1');
|
|
title = await titleState(page);
|
|
assert.equal(title.panels.saveSlots, true);
|
|
assert.equal(title.focus.itemId, 'slot-2');
|
|
await page.keyboard.press('Escape');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'main',
|
|
'manual-load'
|
|
);
|
|
await page.keyboard.press('Enter');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'save-slots',
|
|
'slot-2'
|
|
);
|
|
await page.keyboard.press('3');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'manual-load-confirm',
|
|
'cancel'
|
|
);
|
|
await page.keyboard.press('ArrowUp');
|
|
await waitForTitleFocus(
|
|
page,
|
|
'manual-load-confirm',
|
|
'confirm'
|
|
);
|
|
await page.keyboard.press('Enter');
|
|
|
|
await page.waitForFunction(
|
|
(expected) => {
|
|
const state =
|
|
window.__HEROS_DEBUG__?.battle?.();
|
|
return (
|
|
state?.battleId ===
|
|
expected.battleId &&
|
|
state?.phase === 'idle' &&
|
|
state?.turnNumber ===
|
|
expected.turnNumber &&
|
|
state?.activeFaction ===
|
|
expected.activeFaction
|
|
);
|
|
},
|
|
{
|
|
battleId,
|
|
turnNumber:
|
|
battleSave.envelope.battle.turnNumber,
|
|
activeFaction:
|
|
battleSave.envelope.battle.activeFaction
|
|
},
|
|
{ timeout: 90_000 }
|
|
);
|
|
await assertDesktopRuntime(
|
|
page,
|
|
renderer,
|
|
'BattleScene'
|
|
);
|
|
|
|
const restored = await page.evaluate(
|
|
({
|
|
requestedCampaignKey,
|
|
requestedCampaignSlotKey,
|
|
requestedBattleSlotKey,
|
|
requestedManualKey
|
|
}) => ({
|
|
battle:
|
|
window.__HEROS_DEBUG__?.battle?.(),
|
|
baseRaw:
|
|
window.localStorage.getItem(
|
|
requestedCampaignKey
|
|
),
|
|
targetCampaignRaw:
|
|
window.localStorage.getItem(
|
|
requestedCampaignSlotKey
|
|
),
|
|
targetBattleRaw:
|
|
window.localStorage.getItem(
|
|
requestedBattleSlotKey
|
|
),
|
|
manualRaw:
|
|
window.localStorage.getItem(
|
|
requestedManualKey
|
|
)
|
|
}),
|
|
{
|
|
requestedCampaignKey: campaignKey,
|
|
requestedCampaignSlotKey:
|
|
campaignSlotKey(3),
|
|
requestedBattleSlotKey:
|
|
battleSlotKey(3),
|
|
requestedManualKey: manualSlotKey(3)
|
|
}
|
|
);
|
|
assert.equal(
|
|
restored.manualRaw,
|
|
battleSave.raw,
|
|
`${renderer}: battle restore modified the protected manual record.`
|
|
);
|
|
assert.equal(
|
|
restored.baseRaw,
|
|
restored.targetCampaignRaw,
|
|
`${renderer}: battle restore did not promote automatic slot 3.`
|
|
);
|
|
const restoredCampaign = JSON.parse(
|
|
restored.targetCampaignRaw
|
|
);
|
|
assert.equal(
|
|
restoredCampaign.activeSaveSlot,
|
|
3
|
|
);
|
|
assert.equal(
|
|
restoredCampaign.step,
|
|
battleSave.envelope.campaign.step
|
|
);
|
|
assert.deepEqual(
|
|
JSON.parse(restored.targetBattleRaw),
|
|
battleSave.envelope.battle,
|
|
`${renderer}: restored automatic battle bytes do not match the protected battle snapshot.`
|
|
);
|
|
assert.equal(
|
|
restored.battle.turnNumber,
|
|
battleSave.envelope.battle.turnNumber
|
|
);
|
|
assert.equal(
|
|
restored.battle.activeFaction,
|
|
battleSave.envelope.battle.activeFaction
|
|
);
|
|
await page.screenshot({
|
|
path:
|
|
`dist/qa-manual-save-battle-restored-${renderer}-1920x1080.png`,
|
|
animations: 'disabled',
|
|
timeout: 90_000
|
|
});
|
|
}
|
|
|
|
async function startTitle(page) {
|
|
await page.reload({
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90_000
|
|
});
|
|
await waitForDebugApi(page);
|
|
await waitForTitle(page);
|
|
}
|
|
|
|
async function waitForDebugApi(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
Boolean(
|
|
window.__HEROS_GAME__ &&
|
|
window.__HEROS_DEBUG__
|
|
),
|
|
undefined,
|
|
{ timeout: 90_000 }
|
|
);
|
|
}
|
|
|
|
async function waitForTitle(page) {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const title =
|
|
window.__HEROS_DEBUG__?.title?.();
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene(
|
|
'TitleScene'
|
|
);
|
|
return (
|
|
title?.scene === 'TitleScene' &&
|
|
scene?.scene?.isActive?.() === true &&
|
|
title?.focus?.scope === 'main' &&
|
|
title?.panels?.saveSlots === false &&
|
|
title?.panels?.manualLoadConfirm ===
|
|
false &&
|
|
title?.panels?.settings === false &&
|
|
title?.panels?.newGameConfirm === false &&
|
|
title?.focus?.items?.length > 0
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 90_000 }
|
|
);
|
|
}
|
|
|
|
async function waitForTitleFocus(
|
|
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: 5_000 }
|
|
);
|
|
} catch (error) {
|
|
const actual = await titleState(page);
|
|
throw new Error(
|
|
`Timed out waiting for title focus ${scope}/${itemId}; actual ${actual?.focus?.scope}/${actual?.focus?.itemId}.`,
|
|
{ cause: error }
|
|
);
|
|
}
|
|
return titleState(page);
|
|
}
|
|
|
|
async function waitForCampSavePanel(
|
|
page,
|
|
visible
|
|
) {
|
|
await page.waitForFunction(
|
|
(expectedVisible) => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene(
|
|
'CampScene'
|
|
);
|
|
return (
|
|
Boolean(
|
|
scene?.saveSlotObjects?.length > 0
|
|
) === expectedVisible
|
|
);
|
|
},
|
|
visible,
|
|
{ timeout: 10_000 }
|
|
);
|
|
}
|
|
|
|
async function waitForBattleLoaded(page) {
|
|
await page.waitForFunction(
|
|
(requestedBattleId) => {
|
|
const state =
|
|
window.__HEROS_DEBUG__?.battle?.();
|
|
return (
|
|
state?.battleId ===
|
|
requestedBattleId &&
|
|
state?.mapBackgroundReady === true &&
|
|
(
|
|
state?.combatAssets?.status ===
|
|
'ready' ||
|
|
state?.combatAssets?.status ===
|
|
'degraded'
|
|
) &&
|
|
['deployment', 'idle'].includes(
|
|
state?.phase
|
|
)
|
|
);
|
|
},
|
|
battleId,
|
|
{ timeout: 90_000 }
|
|
);
|
|
}
|
|
|
|
async function waitForBattleSavePanel(
|
|
page,
|
|
mode
|
|
) {
|
|
await page.waitForFunction(
|
|
(expectedMode) => {
|
|
const state =
|
|
window.__HEROS_DEBUG__?.battle?.();
|
|
return expectedMode === null
|
|
? state?.saveSlotPanelVisible ===
|
|
false &&
|
|
state?.saveSlotPanelMode === null
|
|
: state?.saveSlotPanelVisible ===
|
|
true &&
|
|
state?.saveSlotPanelMode ===
|
|
expectedMode;
|
|
},
|
|
mode,
|
|
{ timeout: 10_000 }
|
|
);
|
|
}
|
|
|
|
async function titleState(page) {
|
|
return page.evaluate(
|
|
() => window.__HEROS_DEBUG__.title()
|
|
);
|
|
}
|
|
|
|
async function battleState(page) {
|
|
return page.evaluate(
|
|
() => window.__HEROS_DEBUG__.battle()
|
|
);
|
|
}
|
|
|
|
function assertTitleMainItems(
|
|
title,
|
|
{
|
|
continueEnabled,
|
|
manualEnabled
|
|
}
|
|
) {
|
|
const ids = title.focus.items.map(
|
|
(item) => item.id
|
|
);
|
|
assert.deepEqual(ids, [
|
|
'new-game',
|
|
'continue',
|
|
'manual-load',
|
|
'settings'
|
|
]);
|
|
assert.equal(
|
|
focusItem(title, 'continue').enabled,
|
|
continueEnabled
|
|
);
|
|
assert.equal(
|
|
focusItem(title, 'manual-load').enabled,
|
|
manualEnabled
|
|
);
|
|
assert.notEqual(
|
|
focusItem(title, 'continue').id,
|
|
focusItem(title, 'manual-load').id
|
|
);
|
|
}
|
|
|
|
function focusItem(title, id) {
|
|
const item = title.focus.items.find(
|
|
(candidate) => candidate.id === id
|
|
);
|
|
assert(
|
|
item,
|
|
`Expected title focus item ${id}: ${JSON.stringify(
|
|
title.focus
|
|
)}`
|
|
);
|
|
return item;
|
|
}
|
|
|
|
async function visibleSceneTexts(
|
|
page,
|
|
sceneKey
|
|
) {
|
|
return page.evaluate((requestedSceneKey) => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene(
|
|
requestedSceneKey
|
|
);
|
|
const texts = [];
|
|
const visit = (object, ancestorsVisible) => {
|
|
if (!object) {
|
|
return;
|
|
}
|
|
const visible =
|
|
ancestorsVisible &&
|
|
object.active !== false &&
|
|
object.visible !== false &&
|
|
object.alpha !== 0;
|
|
if (
|
|
visible &&
|
|
typeof object.text === 'string'
|
|
) {
|
|
texts.push(object.text);
|
|
}
|
|
if (Array.isArray(object.list)) {
|
|
object.list.forEach((child) =>
|
|
visit(child, visible)
|
|
);
|
|
}
|
|
};
|
|
scene?.children?.list?.forEach((child) =>
|
|
visit(child, true)
|
|
);
|
|
return texts;
|
|
}, sceneKey);
|
|
}
|
|
|
|
async function waitForVisibleSceneText(
|
|
page,
|
|
sceneKey,
|
|
expectedFragment
|
|
) {
|
|
await page.waitForFunction(
|
|
({
|
|
requestedSceneKey,
|
|
requestedFragment
|
|
}) => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene(
|
|
requestedSceneKey
|
|
);
|
|
let found = false;
|
|
const visit = (
|
|
object,
|
|
ancestorsVisible
|
|
) => {
|
|
if (!object || found) {
|
|
return;
|
|
}
|
|
const visible =
|
|
ancestorsVisible &&
|
|
object.active !== false &&
|
|
object.visible !== false &&
|
|
object.alpha !== 0;
|
|
if (
|
|
visible &&
|
|
typeof object.text === 'string' &&
|
|
object.text.includes(
|
|
requestedFragment
|
|
)
|
|
) {
|
|
found = true;
|
|
return;
|
|
}
|
|
if (Array.isArray(object.list)) {
|
|
object.list.forEach((child) =>
|
|
visit(child, visible)
|
|
);
|
|
}
|
|
};
|
|
scene?.children?.list?.forEach(
|
|
(object) => visit(object, true)
|
|
);
|
|
return found;
|
|
},
|
|
{
|
|
requestedSceneKey: sceneKey,
|
|
requestedFragment: expectedFragment
|
|
},
|
|
{ timeout: 5_000 }
|
|
);
|
|
}
|
|
|
|
async function clickSceneText(
|
|
page,
|
|
sceneKey,
|
|
text
|
|
) {
|
|
const point = await page.evaluate(
|
|
({
|
|
requestedSceneKey,
|
|
requestedText
|
|
}) => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene(
|
|
requestedSceneKey
|
|
);
|
|
const canvas =
|
|
document.querySelector('canvas');
|
|
const canvasBounds =
|
|
canvas?.getBoundingClientRect();
|
|
const candidate =
|
|
scene?.children?.list?.find(
|
|
(child) =>
|
|
child?.active &&
|
|
child?.visible &&
|
|
child?.text === requestedText &&
|
|
child?.input?.enabled
|
|
);
|
|
const bounds = candidate?.getBounds?.();
|
|
if (
|
|
!scene ||
|
|
!canvasBounds ||
|
|
!bounds
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
x:
|
|
canvasBounds.left +
|
|
bounds.centerX *
|
|
canvasBounds.width /
|
|
scene.scale.width,
|
|
y:
|
|
canvasBounds.top +
|
|
bounds.centerY *
|
|
canvasBounds.height /
|
|
scene.scale.height
|
|
};
|
|
},
|
|
{
|
|
requestedSceneKey: sceneKey,
|
|
requestedText: text
|
|
}
|
|
);
|
|
assert(
|
|
point &&
|
|
Number.isFinite(point.x) &&
|
|
Number.isFinite(point.y),
|
|
`Expected interactive scene text "${text}" in ${sceneKey}.`
|
|
);
|
|
await page.mouse.click(point.x, point.y);
|
|
}
|
|
|
|
async function assertDesktopRuntime(
|
|
page,
|
|
renderer,
|
|
sceneKey
|
|
) {
|
|
const runtime = await page.evaluate(
|
|
(requestedSceneKey) => {
|
|
const canvasBounds =
|
|
document
|
|
.querySelector('canvas')
|
|
?.getBoundingClientRect();
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene(
|
|
requestedSceneKey
|
|
);
|
|
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,
|
|
scene: scene
|
|
? {
|
|
width: scene.scale.width,
|
|
height: scene.scale.height
|
|
}
|
|
: null
|
|
};
|
|
},
|
|
sceneKey
|
|
);
|
|
assert.deepEqual(
|
|
runtime.viewport,
|
|
{
|
|
width: desktopBrowserViewport.width,
|
|
height:
|
|
desktopBrowserViewport.height,
|
|
dpr: desktopBrowserDeviceScaleFactor,
|
|
scale: 1
|
|
},
|
|
`${renderer}/${sceneKey}: browser viewport is not explicit 1920x1080 at 100% DPR1.`
|
|
);
|
|
assert.deepEqual(runtime.canvas, {
|
|
x: 0,
|
|
y: 0,
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height
|
|
});
|
|
assert.deepEqual(runtime.scene, {
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height
|
|
});
|
|
}
|
|
|
|
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 owned Vite server is still starting.
|
|
}
|
|
await delay(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(5_000)]);
|
|
if (child.exitCode === null) {
|
|
child.kill('SIGKILL');
|
|
await Promise.race([
|
|
exited,
|
|
delay(2_000)
|
|
]);
|
|
}
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) =>
|
|
setTimeout(resolve, ms)
|
|
);
|
|
}
|