1575 lines
40 KiB
JavaScript
1575 lines
40 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { chromium } from 'playwright';
|
|
import {
|
|
desktopBrowserContextOptions,
|
|
desktopBrowserDeviceScaleFactor,
|
|
desktopBrowserViewport
|
|
} from './desktop-browser-viewport.mjs';
|
|
import {
|
|
startVerificationPreview,
|
|
stopVerificationPreview
|
|
} from './verification-preview-server.mjs';
|
|
|
|
const rendererNames = ['canvas', 'webgl'];
|
|
const renderer =
|
|
process.env.VERIFY_BATTLE_RESULT_PERSISTENCE_RENDERER;
|
|
|
|
if (!renderer) {
|
|
for (const requestedRenderer of rendererNames) {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[fileURLToPath(import.meta.url)],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
VERIFY_BATTLE_RESULT_PERSISTENCE_RENDERER:
|
|
requestedRenderer
|
|
},
|
|
stdio: 'inherit'
|
|
}
|
|
);
|
|
if (result.status !== 0) {
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
assert(
|
|
rendererNames.includes(renderer),
|
|
`Unsupported battle result persistence renderer "${renderer}".`
|
|
);
|
|
|
|
const battleId = 'first-battle-zhuo-commandery';
|
|
const firstPursuitAutoEntryTutorialIds = [
|
|
'first-pursuit-camp-auto-enter-scout-tent-scheduled',
|
|
'first-pursuit-camp-auto-enter-scout-tent'
|
|
];
|
|
const canonicalCampaignKey =
|
|
'heros-web:campaign-state:slot-1';
|
|
const currentCampaignKey = 'heros-web:campaign-state';
|
|
const baseUrl =
|
|
process.env.VERIFY_BATTLE_RESULT_PERSISTENCE_URL ??
|
|
`http://127.0.0.1:${
|
|
renderer === 'canvas' ? 41823 : 41824
|
|
}/heros_web/`;
|
|
const targetUrl = withDebugOptions(baseUrl, renderer);
|
|
let serverProcess;
|
|
let browser;
|
|
|
|
try {
|
|
serverProcess = await startVerificationPreview({
|
|
targetUrl,
|
|
explicitUrl: Boolean(
|
|
process.env
|
|
.VERIFY_BATTLE_RESULT_PERSISTENCE_URL
|
|
),
|
|
label:
|
|
`${renderer} battle result persistence verification`
|
|
});
|
|
browser = await chromium.launch({
|
|
headless:
|
|
process.env
|
|
.VERIFY_BATTLE_RESULT_PERSISTENCE_HEADLESS !== '0',
|
|
args:
|
|
renderer === 'webgl'
|
|
? [
|
|
'--use-angle=swiftshader',
|
|
'--enable-unsafe-swiftshader'
|
|
]
|
|
: []
|
|
});
|
|
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 waitForDebugApi(page);
|
|
await page.evaluate(() => window.localStorage.clear());
|
|
await page.reload({
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForTitle(page);
|
|
await assertDesktopRuntime(page, renderer);
|
|
|
|
await openBattle(page, battleId);
|
|
await waitForBattleReady(page, battleId);
|
|
const victoryRecommendation =
|
|
await installRecommendationFixture(
|
|
page,
|
|
battleId
|
|
);
|
|
assert(
|
|
victoryRecommendation.selectedUnitIds.length > 0,
|
|
`${renderer}: victory fixture has no selected units`
|
|
);
|
|
|
|
const beforeFailure = await readCampaignStorage(page);
|
|
assertCampaignMirrorsEqual(
|
|
beforeFailure,
|
|
`${renderer}: battle-entry baseline`
|
|
);
|
|
assert.equal(beforeFailure.current.step, 'first-battle');
|
|
assert.equal(
|
|
beforeFailure.current.firstBattleReport,
|
|
undefined
|
|
);
|
|
assert.equal(
|
|
beforeFailure.current.pendingAftermathBattleId,
|
|
undefined
|
|
);
|
|
assert.equal(
|
|
beforeFailure.current.storyCheckpoint,
|
|
undefined
|
|
);
|
|
|
|
await installOneShotCanonicalFailure(page);
|
|
await page.evaluate(() => {
|
|
window.__HEROS_DEBUG__?.forceBattleOutcome('victory');
|
|
});
|
|
const failedResult = await waitForResultPersistence(
|
|
page,
|
|
'victory',
|
|
'failed'
|
|
);
|
|
|
|
assert.equal(failedResult.battleOutcome, 'victory');
|
|
assert.equal(failedResult.phase, 'resolved');
|
|
assert.equal(failedResult.resultVisible, true);
|
|
assert.equal(
|
|
failedResult.resultSettlement.reportPersistence,
|
|
'failed'
|
|
);
|
|
assert.equal(
|
|
failedResult.resultSettlement.cta.state,
|
|
'persistence-retry'
|
|
);
|
|
assert.equal(
|
|
failedResult.resultSettlement.cta.label,
|
|
'저장 다시 시도'
|
|
);
|
|
assert.equal(
|
|
failedResult.resultSettlement.cta.interactive,
|
|
true
|
|
);
|
|
assertFiniteBounds(
|
|
failedResult.resultSettlement.cta.bounds
|
|
);
|
|
assertBoundsInsideViewport(
|
|
failedResult.resultSettlement.cta.bounds
|
|
);
|
|
|
|
const failureStats = await readFailureStats(page);
|
|
assert.deepEqual(
|
|
failureStats,
|
|
{
|
|
canonicalAttempts: 1,
|
|
injectedFailures: 1
|
|
},
|
|
`${renderer}: the canonical campaign slot must fail exactly once`
|
|
);
|
|
|
|
const afterFailure = await readCampaignStorage(page);
|
|
assert.deepEqual(
|
|
afterFailure.current,
|
|
beforeFailure.current,
|
|
`${renderer}: the compatibility campaign mirror changed after the canonical write failed`
|
|
);
|
|
assert.deepEqual(
|
|
afterFailure.slot,
|
|
beforeFailure.slot,
|
|
`${renderer}: the canonical campaign slot changed after its injected failure`
|
|
);
|
|
assert.deepEqual(
|
|
campaignOutcomeProjection(afterFailure.current),
|
|
campaignOutcomeProjection(beforeFailure.current),
|
|
`${renderer}: failed persistence leaked report, pending aftermath, or story checkpoint state`
|
|
);
|
|
|
|
await triggerResultContinueButton(page);
|
|
const recoveredResult = await waitForResultPersistence(
|
|
page,
|
|
'victory',
|
|
'complete'
|
|
);
|
|
assert.equal(recoveredResult.resultVisible, true);
|
|
assert.equal(
|
|
recoveredResult.resultSettlement.reportPersistence,
|
|
'complete'
|
|
);
|
|
assert.notEqual(
|
|
recoveredResult.resultSettlement.cta.state,
|
|
'persistence-retry'
|
|
);
|
|
assert.notEqual(
|
|
recoveredResult.resultSettlement.cta.label,
|
|
'저장 다시 시도'
|
|
);
|
|
|
|
const recoveredFailureStats = await readFailureStats(page);
|
|
assert.deepEqual(
|
|
recoveredFailureStats,
|
|
{
|
|
canonicalAttempts: 2,
|
|
injectedFailures: 1
|
|
},
|
|
`${renderer}: retry must recover after the one injected failure`
|
|
);
|
|
|
|
const afterRecovery = await readCampaignStorage(page);
|
|
assertCampaignMirrorsEqual(
|
|
afterRecovery,
|
|
`${renderer}: recovered battle result`
|
|
);
|
|
const finalState = afterRecovery.current;
|
|
assert.equal(finalState.step, 'first-camp');
|
|
assert.equal(
|
|
finalState.firstBattleReport?.battleId,
|
|
battleId
|
|
);
|
|
assert.equal(
|
|
finalState.firstBattleReport?.outcome,
|
|
'victory'
|
|
);
|
|
assert.equal(finalState.pendingAftermathBattleId, battleId);
|
|
assert.equal(
|
|
finalState.storyCheckpoint?.phase,
|
|
'reading'
|
|
);
|
|
assert.equal(
|
|
finalState.storyCheckpoint?.campaignStep,
|
|
'first-camp'
|
|
);
|
|
assert.equal(
|
|
typeof finalState.storyCheckpoint?.sequenceId,
|
|
'string'
|
|
);
|
|
assert.equal(
|
|
typeof finalState.storyCheckpoint?.beatId,
|
|
'string'
|
|
);
|
|
assert.equal(
|
|
finalState.storyCheckpointRevision,
|
|
beforeFailure.current.storyCheckpointRevision + 1,
|
|
`${renderer}: the failed attempt must not consume an extra story revision`
|
|
);
|
|
assert.deepEqual(
|
|
campaignOutcomeProjection(afterRecovery.current),
|
|
campaignOutcomeProjection(afterRecovery.slot),
|
|
`${renderer}: report, pending aftermath, and story checkpoint diverged between current and slot`
|
|
);
|
|
|
|
assertSingleSettlement(
|
|
beforeFailure.current,
|
|
finalState,
|
|
battleId,
|
|
renderer
|
|
);
|
|
|
|
await completeVisibleResultSettlement(page);
|
|
const victoryImprovement =
|
|
await openResultImprovementProbe(
|
|
page,
|
|
'victory'
|
|
);
|
|
assert.equal(
|
|
victoryImprovement.available,
|
|
true,
|
|
`${renderer}: victory recommendation fixture did not expose its next-sortie improvement action`
|
|
);
|
|
assert.equal(
|
|
victoryImprovement.interactive,
|
|
true,
|
|
`${renderer}: durable victory result did not enable its improvement action`
|
|
);
|
|
assertFiniteBounds(victoryImprovement.bounds);
|
|
assertBoundsInsideViewport(
|
|
victoryImprovement.bounds
|
|
);
|
|
await triggerResultImprovementButton(page);
|
|
await waitForActiveScene(page, 'StoryScene');
|
|
const victoryIntentBeforeStory =
|
|
await readCampaignStorage(page);
|
|
assertCampaignMirrorsEqual(
|
|
victoryIntentBeforeStory,
|
|
`${renderer}: victory improvement destination`
|
|
);
|
|
assertPreparationCheckpoint(
|
|
victoryIntentBeforeStory.current,
|
|
{
|
|
mode: 'next-sortie',
|
|
sourceBattleId: battleId,
|
|
targetBattleId:
|
|
'second-battle-yellow-turban-pursuit',
|
|
targetCampaignStep: 'second-battle'
|
|
},
|
|
`${renderer}: victory improvement destination`
|
|
);
|
|
assert.equal(
|
|
victoryIntentBeforeStory.current
|
|
.storyCheckpoint
|
|
?.continuationIntent,
|
|
'sortie-improvement',
|
|
`${renderer}: victory improvement and aftermath continuation were not committed together`
|
|
);
|
|
await completeActiveStoryToCamp(page);
|
|
await waitForCamp(page);
|
|
|
|
const autoEntryFixture = await page.evaluate(
|
|
({
|
|
currentKey,
|
|
canonicalKey,
|
|
tutorialIds
|
|
}) => {
|
|
window.__HEROS_GAME__?.loop?.sleep?.();
|
|
const canonicalRaw =
|
|
window.localStorage.getItem(canonicalKey);
|
|
if (!canonicalRaw) {
|
|
throw new Error(
|
|
'Canonical campaign save is unavailable.'
|
|
);
|
|
}
|
|
const campaign = JSON.parse(canonicalRaw);
|
|
campaign.completedTutorialIds = [
|
|
...new Set([
|
|
...(campaign.completedTutorialIds ?? []),
|
|
...tutorialIds
|
|
])
|
|
];
|
|
campaign.updatedAt = new Date().toISOString();
|
|
const serialized = JSON.stringify(campaign);
|
|
window.localStorage.setItem(currentKey, serialized);
|
|
window.localStorage.setItem(canonicalKey, serialized);
|
|
return tutorialIds.every((tutorialId) =>
|
|
campaign.completedTutorialIds.includes(tutorialId)
|
|
);
|
|
},
|
|
{
|
|
currentKey: currentCampaignKey,
|
|
canonicalKey: canonicalCampaignKey,
|
|
tutorialIds: firstPursuitAutoEntryTutorialIds
|
|
}
|
|
);
|
|
assert.equal(
|
|
autoEntryFixture,
|
|
true,
|
|
`${renderer}: the sortie-improvement fixture did not complete the separate automatic scout entry`
|
|
);
|
|
await reloadToTitle(page, renderer);
|
|
await triggerTitleContinue(page);
|
|
const restoredVictoryPreparation =
|
|
await waitForCampImprovement(
|
|
page,
|
|
{
|
|
mode: 'next-sortie',
|
|
targetBattleId:
|
|
'second-battle-yellow-turban-pursuit'
|
|
}
|
|
);
|
|
assert.equal(
|
|
restoredVictoryPreparation
|
|
.retrySortieBattleId,
|
|
null
|
|
);
|
|
assert.equal(
|
|
restoredVictoryPreparation
|
|
.sortieGuidedImprovement
|
|
?.targetBattleId,
|
|
'second-battle-yellow-turban-pursuit',
|
|
`${renderer}: victory reload did not restore the guided next-sortie overlay`
|
|
);
|
|
const victoryIntentAfterReload =
|
|
await readCampaignStorage(page);
|
|
assertPreparationCheckpoint(
|
|
victoryIntentAfterReload.current,
|
|
{
|
|
mode: 'next-sortie',
|
|
sourceBattleId: battleId,
|
|
targetBattleId:
|
|
'second-battle-yellow-turban-pursuit',
|
|
targetCampaignStep: 'second-battle'
|
|
},
|
|
`${renderer}: victory improvement reload`
|
|
);
|
|
|
|
await resetToFreshBattle(page, renderer, battleId);
|
|
const defeatRecommendation =
|
|
await installRecommendationFixture(
|
|
page,
|
|
battleId
|
|
);
|
|
assert(
|
|
defeatRecommendation.selectedUnitIds.length > 0,
|
|
`${renderer}: defeat fixture has no selected units`
|
|
);
|
|
const beforeDefeatFailure =
|
|
await readCampaignStorage(page);
|
|
assertCampaignMirrorsEqual(
|
|
beforeDefeatFailure,
|
|
`${renderer}: defeat battle-entry baseline`
|
|
);
|
|
assert.equal(
|
|
beforeDefeatFailure.current.step,
|
|
'first-battle'
|
|
);
|
|
assert.equal(
|
|
beforeDefeatFailure.current.firstBattleReport,
|
|
undefined
|
|
);
|
|
|
|
await installOneShotCanonicalFailure(page);
|
|
await page.evaluate(() => {
|
|
window.__HEROS_DEBUG__?.forceBattleOutcome('defeat');
|
|
});
|
|
const failedDefeatResult =
|
|
await waitForResultPersistence(
|
|
page,
|
|
'defeat',
|
|
'failed'
|
|
);
|
|
assert.equal(failedDefeatResult.phase, 'resolved');
|
|
assert.equal(failedDefeatResult.resultVisible, true);
|
|
assert.equal(
|
|
failedDefeatResult.resultSettlement.cta.state,
|
|
'persistence-retry'
|
|
);
|
|
assert.equal(
|
|
failedDefeatResult.resultSettlement.cta.label,
|
|
'저장 다시 시도'
|
|
);
|
|
assert.equal(
|
|
failedDefeatResult.resultSettlement.cta.interactive,
|
|
true
|
|
);
|
|
|
|
const failedDefeatActions =
|
|
resultActionMap(failedDefeatResult);
|
|
assert.equal(
|
|
failedDefeatActions.retry?.interactive,
|
|
false,
|
|
`${renderer}: normal retry must be disabled until the defeat report is durable`
|
|
);
|
|
assert.equal(
|
|
failedDefeatActions.title?.interactive,
|
|
false,
|
|
`${renderer}: title navigation must be disabled until the defeat report is durable`
|
|
);
|
|
assertFiniteBounds(failedDefeatActions.retry?.bounds);
|
|
assertFiniteBounds(failedDefeatActions.title?.bounds);
|
|
assertBoundsInsideViewport(
|
|
failedDefeatActions.retry.bounds
|
|
);
|
|
assertBoundsInsideViewport(
|
|
failedDefeatActions.title.bounds
|
|
);
|
|
|
|
const beforeBlockedDefeatNavigation =
|
|
await readDefeatFailureProbe(page);
|
|
await clickBounds(
|
|
page,
|
|
failedDefeatActions.retry.bounds
|
|
);
|
|
await page.waitForTimeout(160);
|
|
await clickBounds(
|
|
page,
|
|
failedDefeatActions.title.bounds
|
|
);
|
|
await page.waitForTimeout(160);
|
|
const afterBlockedResultActions =
|
|
await readDefeatFailureProbe(page);
|
|
assert.deepEqual(
|
|
afterBlockedResultActions,
|
|
beforeBlockedDefeatNavigation,
|
|
`${renderer}: disabled defeat retry/title buttons escaped the failed result`
|
|
);
|
|
|
|
const improvementProbe =
|
|
await openResultImprovementProbe(
|
|
page,
|
|
'defeat'
|
|
);
|
|
assert.equal(
|
|
improvementProbe.available,
|
|
true,
|
|
`${renderer}: defeat recommendation fixture did not expose its improvement action`
|
|
);
|
|
assert.equal(
|
|
improvementProbe.interactive,
|
|
false,
|
|
`${renderer}: formation-improvement navigation must be disabled until the report is durable`
|
|
);
|
|
assertFiniteBounds(improvementProbe.bounds);
|
|
assertBoundsInsideViewport(improvementProbe.bounds);
|
|
await clickBounds(page, improvementProbe.bounds);
|
|
await page.waitForTimeout(180);
|
|
const afterBlockedImprovement =
|
|
await readDefeatFailureProbe(page);
|
|
assert.deepEqual(
|
|
afterBlockedImprovement,
|
|
beforeBlockedDefeatNavigation,
|
|
`${renderer}: disabled formation improvement escaped the failed defeat result`
|
|
);
|
|
|
|
const afterDefeatFailure =
|
|
await readCampaignStorage(page);
|
|
assert.deepEqual(
|
|
afterDefeatFailure.current,
|
|
beforeDefeatFailure.current,
|
|
`${renderer}: current save changed after defeat persistence failed`
|
|
);
|
|
assert.deepEqual(
|
|
afterDefeatFailure.slot,
|
|
beforeDefeatFailure.slot,
|
|
`${renderer}: canonical slot changed after defeat persistence failed`
|
|
);
|
|
assert.deepEqual(
|
|
await readFailureStats(page),
|
|
{
|
|
canonicalAttempts: 1,
|
|
injectedFailures: 1
|
|
},
|
|
`${renderer}: blocked defeat navigation must not attempt another campaign write`
|
|
);
|
|
|
|
await closeResultImprovementProbe(page);
|
|
await triggerResultContinueButton(page);
|
|
const recoveredDefeatResult =
|
|
await waitForResultPersistence(
|
|
page,
|
|
'defeat',
|
|
'complete'
|
|
);
|
|
const recoveredDefeatActions =
|
|
resultActionMap(recoveredDefeatResult);
|
|
assert.equal(
|
|
recoveredDefeatActions.retry?.label,
|
|
'다시 하기'
|
|
);
|
|
assert.equal(
|
|
recoveredDefeatActions.retry?.interactive,
|
|
true,
|
|
`${renderer}: normal retry did not recover after the defeat report became durable`
|
|
);
|
|
assert.equal(
|
|
recoveredDefeatActions.title?.interactive,
|
|
true,
|
|
`${renderer}: title navigation did not recover after the defeat report became durable`
|
|
);
|
|
assertFiniteBounds(recoveredDefeatActions.retry?.bounds);
|
|
assertBoundsInsideViewport(
|
|
recoveredDefeatActions.retry.bounds
|
|
);
|
|
|
|
const afterDefeatRecovery =
|
|
await readCampaignStorage(page);
|
|
assertCampaignMirrorsEqual(
|
|
afterDefeatRecovery,
|
|
`${renderer}: recovered defeat result`
|
|
);
|
|
assert.equal(
|
|
afterDefeatRecovery.current.step,
|
|
'first-battle'
|
|
);
|
|
assert.equal(
|
|
afterDefeatRecovery.current.firstBattleReport?.outcome,
|
|
'defeat'
|
|
);
|
|
assert.equal(
|
|
afterDefeatRecovery.current.pendingAftermathBattleId,
|
|
undefined
|
|
);
|
|
assert.equal(
|
|
afterDefeatRecovery.current.storyCheckpoint,
|
|
undefined
|
|
);
|
|
assert.equal(
|
|
afterDefeatRecovery.current.storyCheckpointRevision,
|
|
beforeDefeatFailure.current.storyCheckpointRevision,
|
|
`${renderer}: defeat persistence retry must not consume a story revision`
|
|
);
|
|
assert.equal(
|
|
afterDefeatRecovery.current.gold,
|
|
beforeDefeatFailure.current.gold,
|
|
`${renderer}: defeat recovery must not grant victory gold`
|
|
);
|
|
assert.deepEqual(
|
|
await readFailureStats(page),
|
|
{
|
|
canonicalAttempts: 2,
|
|
injectedFailures: 1
|
|
},
|
|
`${renderer}: defeat persistence retry must recover after exactly one injected failure`
|
|
);
|
|
|
|
const recoveredDefeatImprovement =
|
|
await openResultImprovementProbe(
|
|
page,
|
|
'defeat'
|
|
);
|
|
assert.equal(
|
|
recoveredDefeatImprovement.available,
|
|
true
|
|
);
|
|
assert.equal(
|
|
recoveredDefeatImprovement.interactive,
|
|
true,
|
|
`${renderer}: recovered defeat did not enable retry improvement`
|
|
);
|
|
const durableBattleSaveKeys =
|
|
await readBattleSaveKeys(page);
|
|
assert(
|
|
durableBattleSaveKeys.length > 0,
|
|
`${renderer}: defeat regression fixture has no older battle save to outrank`
|
|
);
|
|
await triggerResultImprovementButton(page);
|
|
const defeatCamp =
|
|
await waitForCampImprovement(
|
|
page,
|
|
{
|
|
mode: 'retry',
|
|
targetBattleId: battleId
|
|
}
|
|
);
|
|
assert.equal(
|
|
defeatCamp.retrySortieBattleId,
|
|
battleId
|
|
);
|
|
const defeatIntentAtCamp =
|
|
await readCampaignStorage(page);
|
|
assertPreparationCheckpoint(
|
|
defeatIntentAtCamp.current,
|
|
{
|
|
mode: 'retry',
|
|
sourceBattleId: battleId,
|
|
targetBattleId: battleId,
|
|
targetCampaignStep: 'first-battle'
|
|
},
|
|
`${renderer}: defeat improvement camp arrival`
|
|
);
|
|
|
|
await reloadToTitle(page, renderer);
|
|
await triggerTitleContinue(page);
|
|
const restoredDefeatPreparation =
|
|
await waitForCampImprovement(
|
|
page,
|
|
{
|
|
mode: 'retry',
|
|
targetBattleId: battleId
|
|
}
|
|
);
|
|
assert.equal(
|
|
restoredDefeatPreparation
|
|
.retrySortieBattleId,
|
|
battleId
|
|
);
|
|
assert(
|
|
!restoredDefeatPreparation.activeScenes.includes(
|
|
'BattleScene'
|
|
),
|
|
`${renderer}: an older battle save outranked the explicit retry-preparation destination`
|
|
);
|
|
const defeatGenerationBeforeLaunch =
|
|
(
|
|
await readCampaignStorage(page)
|
|
).current.battleResumeGeneration;
|
|
await launchPreparedBattle(page);
|
|
await waitForBattleReady(page, battleId);
|
|
const restartedBattle = await page.evaluate(
|
|
() => window.__HEROS_DEBUG__?.battle?.()
|
|
);
|
|
assert.equal(restartedBattle.battleOutcome, null);
|
|
assert.equal(restartedBattle.resultVisible, false);
|
|
const defeatAfterLaunch =
|
|
await readCampaignStorage(page);
|
|
assert.equal(
|
|
defeatAfterLaunch.current
|
|
.sortiePreparationCheckpoint,
|
|
undefined,
|
|
`${renderer}: retry launch did not consume the durable preparation checkpoint`
|
|
);
|
|
assert.equal(
|
|
defeatAfterLaunch.current
|
|
.battleResumeGeneration,
|
|
defeatGenerationBeforeLaunch + 1,
|
|
`${renderer}: retry launch did not atomically advance its battle generation`
|
|
);
|
|
|
|
assert.equal(pageErrors.length, 0, pageErrors.join('\n'));
|
|
const unexpectedConsoleErrors = consoleErrors.filter(
|
|
(message) =>
|
|
!message.includes(
|
|
'Failed to persist the battle result.'
|
|
) &&
|
|
!message.includes('Injected canonical campaign slot failure')
|
|
);
|
|
assert.equal(
|
|
unexpectedConsoleErrors.length,
|
|
0,
|
|
unexpectedConsoleErrors.join('\n')
|
|
);
|
|
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
renderer,
|
|
viewport: desktopBrowserViewport,
|
|
deviceScaleFactor:
|
|
desktopBrowserDeviceScaleFactor,
|
|
cssZoom: 1,
|
|
canonicalFailure: failureStats,
|
|
recoveredAttempts: recoveredFailureStats,
|
|
failedCta:
|
|
failedResult.resultSettlement.cta.label,
|
|
reportPersistence:
|
|
recoveredResult.resultSettlement
|
|
.reportPersistence,
|
|
campaignStep: finalState.step,
|
|
pendingAftermathBattleId:
|
|
finalState.pendingAftermathBattleId,
|
|
storyCheckpoint:
|
|
finalState.storyCheckpoint,
|
|
rewardGold:
|
|
finalState.firstBattleReport.rewardGold,
|
|
finalGold: finalState.gold,
|
|
defeat: {
|
|
failedCta:
|
|
failedDefeatResult.resultSettlement.cta
|
|
.label,
|
|
blockedActions: {
|
|
retry:
|
|
failedDefeatActions.retry.interactive,
|
|
title:
|
|
failedDefeatActions.title.interactive,
|
|
improvement:
|
|
improvementProbe.interactive
|
|
},
|
|
recoveredRetry:
|
|
recoveredDefeatActions.retry.interactive,
|
|
restoredDestination:
|
|
restoredDefeatPreparation
|
|
.retrySortieBattleId,
|
|
reportOutcome:
|
|
afterDefeatRecovery.current
|
|
.firstBattleReport.outcome,
|
|
finalGold:
|
|
afterDefeatRecovery.current.gold
|
|
},
|
|
pageErrors: pageErrors.length,
|
|
consoleErrors: unexpectedConsoleErrors.length
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
} finally {
|
|
await browser?.close();
|
|
await stopVerificationPreview(serverProcess);
|
|
}
|
|
|
|
function withDebugOptions(url, requestedRenderer) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('debug', '1');
|
|
parsed.searchParams.set('renderer', requestedRenderer);
|
|
parsed.searchParams.set('motion', 'reduced');
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function waitForDebugApi(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
Boolean(
|
|
document.querySelector('canvas') &&
|
|
window.__HEROS_GAME__ &&
|
|
window.__HEROS_DEBUG__
|
|
),
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function waitForTitle(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__HEROS_DEBUG__?.activeScenes?.().includes(
|
|
'TitleScene'
|
|
),
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function openBattle(page, expectedBattleId) {
|
|
const started = await page.evaluate(async (requestedBattleId) => {
|
|
if (!window.__HEROS_DEBUG__) {
|
|
return false;
|
|
}
|
|
await window.__HEROS_DEBUG__.goToBattle(
|
|
requestedBattleId
|
|
);
|
|
return true;
|
|
}, expectedBattleId);
|
|
assert.equal(started, true);
|
|
}
|
|
|
|
async function waitForBattleReady(page, expectedBattleId) {
|
|
await page.waitForFunction(
|
|
(requestedBattleId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle?.();
|
|
return (
|
|
window.__HEROS_DEBUG__?.activeScenes?.().includes(
|
|
'BattleScene'
|
|
) &&
|
|
battle?.scene === 'BattleScene' &&
|
|
battle?.battleId === requestedBattleId &&
|
|
battle?.battleOutcome === null &&
|
|
['deployment', 'idle'].includes(battle?.phase) &&
|
|
battle?.mapBackgroundReady === true &&
|
|
battle?.resultVisible === false
|
|
);
|
|
},
|
|
expectedBattleId,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function installOneShotCanonicalFailure(page) {
|
|
await page.evaluate((canonicalKey) => {
|
|
const storagePrototype = Object.getPrototypeOf(
|
|
window.localStorage
|
|
);
|
|
const originalSetItem =
|
|
storagePrototype.setItem;
|
|
const stats = {
|
|
canonicalAttempts: 0,
|
|
injectedFailures: 0
|
|
};
|
|
Object.defineProperty(
|
|
window,
|
|
'__HEROS_CANONICAL_FAILURE_STATS__',
|
|
{
|
|
configurable: true,
|
|
value: stats
|
|
}
|
|
);
|
|
storagePrototype.setItem = function setItem(
|
|
key,
|
|
value
|
|
) {
|
|
if (
|
|
this === window.localStorage &&
|
|
String(key) === canonicalKey
|
|
) {
|
|
stats.canonicalAttempts += 1;
|
|
if (stats.injectedFailures === 0) {
|
|
stats.injectedFailures += 1;
|
|
throw new DOMException(
|
|
'Injected canonical campaign slot failure',
|
|
'QuotaExceededError'
|
|
);
|
|
}
|
|
}
|
|
return originalSetItem.call(this, key, value);
|
|
};
|
|
}, canonicalCampaignKey);
|
|
}
|
|
|
|
async function readFailureStats(page) {
|
|
return page.evaluate(() => {
|
|
const stats =
|
|
window.__HEROS_CANONICAL_FAILURE_STATS__;
|
|
return stats
|
|
? {
|
|
canonicalAttempts: stats.canonicalAttempts,
|
|
injectedFailures: stats.injectedFailures
|
|
}
|
|
: null;
|
|
});
|
|
}
|
|
|
|
async function waitForResultPersistence(
|
|
page,
|
|
outcome,
|
|
status
|
|
) {
|
|
await page.waitForFunction(
|
|
({ expectedOutcome, expectedStatus }) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle?.();
|
|
return (
|
|
battle?.battleOutcome === expectedOutcome &&
|
|
battle?.phase === 'resolved' &&
|
|
battle?.resultVisible === true &&
|
|
battle?.resultSettlement?.reportPersistence ===
|
|
expectedStatus &&
|
|
(
|
|
expectedStatus !== 'failed' ||
|
|
battle?.resultSettlement?.cta?.interactive ===
|
|
true
|
|
)
|
|
);
|
|
},
|
|
{
|
|
expectedOutcome: outcome,
|
|
expectedStatus: status
|
|
},
|
|
{ timeout: 90000 }
|
|
);
|
|
return page.evaluate(
|
|
() => window.__HEROS_DEBUG__?.battle?.()
|
|
);
|
|
}
|
|
|
|
async function readCampaignStorage(page) {
|
|
return page.evaluate(
|
|
({ currentKey, slotKey }) => {
|
|
const read = (key) => {
|
|
const raw = window.localStorage.getItem(key);
|
|
return raw ? JSON.parse(raw) : null;
|
|
};
|
|
return {
|
|
current: read(currentKey),
|
|
slot: read(slotKey)
|
|
};
|
|
},
|
|
{
|
|
currentKey: currentCampaignKey,
|
|
slotKey: canonicalCampaignKey
|
|
}
|
|
);
|
|
}
|
|
|
|
function assertCampaignMirrorsEqual(save, label) {
|
|
assert(save.current, `${label}: current save is missing.`);
|
|
assert(save.slot, `${label}: slot save is missing.`);
|
|
assert.deepEqual(
|
|
save.current,
|
|
save.slot,
|
|
`${label}: current and canonical slot differ`
|
|
);
|
|
}
|
|
|
|
function campaignOutcomeProjection(state) {
|
|
return {
|
|
firstBattleReport: state?.firstBattleReport ?? null,
|
|
pendingAftermathBattleId:
|
|
state?.pendingAftermathBattleId ?? null,
|
|
storyCheckpoint: state?.storyCheckpoint ?? null,
|
|
storyCheckpointRevision:
|
|
state?.storyCheckpointRevision ?? null
|
|
};
|
|
}
|
|
|
|
function resultActionMap(battle) {
|
|
return Object.fromEntries(
|
|
(battle?.resultActions ?? []).map((action) => [
|
|
action.kind,
|
|
action
|
|
])
|
|
);
|
|
}
|
|
|
|
function assertSingleSettlement(
|
|
baseline,
|
|
finalState,
|
|
expectedBattleId,
|
|
rendererLabel
|
|
) {
|
|
const report = finalState.firstBattleReport;
|
|
assert(report);
|
|
const orderRewardGold = report.sortieOrder?.rewardGranted
|
|
? {
|
|
elite: 180,
|
|
mobile: 160,
|
|
siege: 200
|
|
}[report.sortieOrder.orderId]
|
|
: 0;
|
|
assert.equal(
|
|
finalState.gold,
|
|
baseline.gold + report.rewardGold + orderRewardGold,
|
|
`${rendererLabel}: gold reflects more than one victory settlement`
|
|
);
|
|
assert.deepEqual(
|
|
Object.keys(finalState.battleHistory),
|
|
[expectedBattleId],
|
|
`${rendererLabel}: the retry created duplicate battle-history state`
|
|
);
|
|
assert.equal(
|
|
finalState.battleHistory[expectedBattleId]?.outcome,
|
|
'victory'
|
|
);
|
|
assert.equal(
|
|
new Set(finalState.claimedSortieOrderRewardIds).size,
|
|
finalState.claimedSortieOrderRewardIds.length,
|
|
`${rendererLabel}: sortie-order reward claims contain duplicates`
|
|
);
|
|
assert.equal(
|
|
new Set(finalState.roster.map((unit) => unit.id)).size,
|
|
finalState.roster.length,
|
|
`${rendererLabel}: the retry duplicated roster rewards`
|
|
);
|
|
}
|
|
|
|
async function triggerResultContinueButton(page) {
|
|
const triggered = await page.evaluate(() => {
|
|
const scene =
|
|
window.__HEROS_DEBUG__?.scene(
|
|
'BattleScene'
|
|
);
|
|
const button =
|
|
scene?.resultContinueButton?.background;
|
|
if (
|
|
!button?.active ||
|
|
!button.input?.enabled
|
|
) {
|
|
return false;
|
|
}
|
|
button.emit('pointerdown');
|
|
return true;
|
|
});
|
|
assert.equal(
|
|
triggered,
|
|
true,
|
|
`${renderer}: the visible persistence retry CTA could not be activated.`
|
|
);
|
|
}
|
|
|
|
async function clickBounds(page, bounds) {
|
|
assertFiniteBounds(bounds);
|
|
await page.mouse.click(
|
|
bounds.x + bounds.width / 2,
|
|
bounds.y + bounds.height / 2
|
|
);
|
|
}
|
|
|
|
async function resetToFreshBattle(
|
|
page,
|
|
expectedRenderer,
|
|
expectedBattleId
|
|
) {
|
|
await page.evaluate(() => window.localStorage.clear());
|
|
await page.reload({
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
await waitForTitle(page);
|
|
await assertDesktopRuntime(page, expectedRenderer);
|
|
await openBattle(page, expectedBattleId);
|
|
await waitForBattleReady(page, expectedBattleId);
|
|
}
|
|
|
|
async function installRecommendationFixture(
|
|
page,
|
|
expectedBattleId
|
|
) {
|
|
return page.evaluate((requestedBattleId) => {
|
|
const scene =
|
|
window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
const battle =
|
|
window.__HEROS_DEBUG__?.battle?.();
|
|
if (!scene || battle?.battleId !== requestedBattleId) {
|
|
throw new Error(
|
|
'BattleScene is unavailable for the defeat recommendation fixture.'
|
|
);
|
|
}
|
|
const selectedUnitIds = battle.units
|
|
.filter((unit) => unit.faction === 'ally')
|
|
.map((unit) => unit.id);
|
|
const fallbackRoles = [
|
|
'front',
|
|
'flank',
|
|
'support',
|
|
'reserve'
|
|
];
|
|
const formationAssignments = Object.fromEntries(
|
|
selectedUnitIds.map((unitId, index) => [
|
|
unitId,
|
|
battle.sortieFormationAssignments?.[unitId] ??
|
|
fallbackRoles[index] ??
|
|
'reserve'
|
|
])
|
|
);
|
|
scene.launchSortieUnitIds = [...selectedUnitIds];
|
|
scene.launchSortieFormationAssignments = {
|
|
...formationAssignments
|
|
};
|
|
scene.launchSortieOrderId = 'elite';
|
|
scene.launchSortieRecommendation = {
|
|
version: 1,
|
|
battleId: requestedBattleId,
|
|
planId: 'order',
|
|
label: '저장 실패 회귀 추천안',
|
|
summary:
|
|
'패배 결과의 편성 보완 이동 잠금을 검증합니다.',
|
|
sortieOrderId: 'elite',
|
|
selectedUnitIds: [...selectedUnitIds],
|
|
formationAssignments: {
|
|
...formationAssignments
|
|
},
|
|
unitReasons: Object.fromEntries(
|
|
selectedUnitIds.map((unitId) => [
|
|
unitId,
|
|
'패배 결과 저장 경계조건 검증'
|
|
])
|
|
)
|
|
};
|
|
scene.saveBattleState?.(1);
|
|
return {
|
|
selectedUnitIds,
|
|
formationAssignments
|
|
};
|
|
}, expectedBattleId);
|
|
}
|
|
|
|
async function readDefeatFailureProbe(page) {
|
|
return page.evaluate(() => ({
|
|
activeScenes:
|
|
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
|
|
battleOutcome:
|
|
window.__HEROS_DEBUG__?.battle?.()?.battleOutcome ??
|
|
null,
|
|
reportPersistence:
|
|
window.__HEROS_DEBUG__?.battle?.()
|
|
?.resultSettlement?.reportPersistence ?? null,
|
|
campaignCurrent:
|
|
window.localStorage.getItem(
|
|
'heros-web:campaign-state'
|
|
),
|
|
campaignSlot:
|
|
window.localStorage.getItem(
|
|
'heros-web:campaign-state:slot-1'
|
|
),
|
|
failureStats: {
|
|
canonicalAttempts:
|
|
window.__HEROS_CANONICAL_FAILURE_STATS__
|
|
?.canonicalAttempts ?? null,
|
|
injectedFailures:
|
|
window.__HEROS_CANONICAL_FAILURE_STATS__
|
|
?.injectedFailures ?? null
|
|
}
|
|
}));
|
|
}
|
|
|
|
async function openResultImprovementProbe(
|
|
page,
|
|
outcome
|
|
) {
|
|
return page.evaluate((requestedOutcome) => {
|
|
const scene =
|
|
window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
scene?.showResultFormationReview?.(
|
|
requestedOutcome
|
|
);
|
|
const debug =
|
|
window.__HEROS_DEBUG__?.battle?.();
|
|
const action =
|
|
debug?.sortieEvaluation?.improvementAction;
|
|
const button =
|
|
scene?.resultFormationReviewView
|
|
?.improvementButton;
|
|
const bounds = button?.getBounds?.();
|
|
return {
|
|
available: action?.available === true,
|
|
interactive: Boolean(button?.input?.enabled),
|
|
bounds: bounds
|
|
? {
|
|
x: bounds.x,
|
|
y: bounds.y,
|
|
width: bounds.width,
|
|
height: bounds.height
|
|
}
|
|
: null
|
|
};
|
|
}, outcome);
|
|
}
|
|
|
|
async function closeResultImprovementProbe(page) {
|
|
await page.evaluate(() => {
|
|
window.__HEROS_DEBUG__
|
|
?.scene('BattleScene')
|
|
?.hideResultFormationReview?.();
|
|
});
|
|
}
|
|
|
|
async function triggerResultImprovementButton(page) {
|
|
const triggered = await page.evaluate(() => {
|
|
const scene =
|
|
window.__HEROS_DEBUG__
|
|
?.scene('BattleScene');
|
|
const button =
|
|
scene?.resultFormationReviewView
|
|
?.improvementButton;
|
|
if (
|
|
!button?.active ||
|
|
!button.input?.enabled
|
|
) {
|
|
return false;
|
|
}
|
|
button.emit('pointerdown');
|
|
return true;
|
|
});
|
|
assert.equal(
|
|
triggered,
|
|
true,
|
|
`${renderer}: enabled result improvement action could not be activated`
|
|
);
|
|
}
|
|
|
|
async function completeVisibleResultSettlement(page) {
|
|
await page.evaluate(() => {
|
|
window.__HEROS_DEBUG__
|
|
?.scene('BattleScene')
|
|
?.completeResultSettlement?.(true);
|
|
});
|
|
}
|
|
|
|
async function waitForActiveScene(page, sceneKey) {
|
|
await page.waitForFunction(
|
|
(requestedSceneKey) =>
|
|
window.__HEROS_DEBUG__
|
|
?.activeScenes?.()
|
|
.includes(requestedSceneKey),
|
|
sceneKey,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function completeActiveStoryToCamp(page) {
|
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
const state = await page.evaluate(() => {
|
|
const activeScenes =
|
|
window.__HEROS_DEBUG__
|
|
?.activeScenes?.() ?? [];
|
|
if (activeScenes.includes('CampScene')) {
|
|
return 'camp';
|
|
}
|
|
const story =
|
|
window.__HEROS_DEBUG__
|
|
?.scene('StoryScene');
|
|
if (
|
|
activeScenes.includes('StoryScene') &&
|
|
story &&
|
|
!story.transitioning
|
|
) {
|
|
story.advance?.();
|
|
}
|
|
return activeScenes.includes('StoryScene')
|
|
? 'story'
|
|
: 'other';
|
|
});
|
|
if (state === 'camp') {
|
|
return;
|
|
}
|
|
await page.waitForTimeout(250);
|
|
}
|
|
throw new Error(
|
|
`${renderer}: aftermath story did not reach CampScene`
|
|
);
|
|
}
|
|
|
|
async function waitForCamp(page) {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const activeScenes =
|
|
window.__HEROS_DEBUG__
|
|
?.activeScenes?.() ?? [];
|
|
const camp =
|
|
window.__HEROS_DEBUG__?.camp?.();
|
|
return (
|
|
activeScenes.includes('CampScene') &&
|
|
camp?.scene === 'CampScene'
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function reloadToTitle(page, expectedRenderer) {
|
|
await page.reload({
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
await waitForTitle(page);
|
|
await assertDesktopRuntime(
|
|
page,
|
|
expectedRenderer
|
|
);
|
|
}
|
|
|
|
async function triggerTitleContinue(page) {
|
|
const triggered = await page.evaluate(() => {
|
|
const title =
|
|
window.__HEROS_DEBUG__
|
|
?.scene('TitleScene');
|
|
if (!title) {
|
|
return false;
|
|
}
|
|
title.requestContinueGame?.();
|
|
return true;
|
|
});
|
|
assert.equal(
|
|
triggered,
|
|
true,
|
|
`${renderer}: title Continue could not be activated`
|
|
);
|
|
}
|
|
|
|
async function waitForCampImprovement(
|
|
page,
|
|
{ mode, targetBattleId }
|
|
) {
|
|
await page.waitForFunction(
|
|
({ expectedMode, expectedTargetBattleId }) => {
|
|
const activeScenes =
|
|
window.__HEROS_DEBUG__
|
|
?.activeScenes?.() ?? [];
|
|
const camp =
|
|
window.__HEROS_DEBUG__?.camp?.();
|
|
const checkpoint =
|
|
camp?.campaign
|
|
?.sortiePreparationCheckpoint;
|
|
return (
|
|
activeScenes.includes('CampScene') &&
|
|
!activeScenes.includes('BattleScene') &&
|
|
camp?.scene === 'CampScene' &&
|
|
camp?.sortieVisible === true &&
|
|
checkpoint?.intent ===
|
|
'sortie-improvement' &&
|
|
checkpoint?.mode === expectedMode &&
|
|
checkpoint?.targetBattleId ===
|
|
expectedTargetBattleId
|
|
);
|
|
},
|
|
{
|
|
expectedMode: mode,
|
|
expectedTargetBattleId: targetBattleId
|
|
},
|
|
{ timeout: 90000 }
|
|
);
|
|
return page.evaluate(() => ({
|
|
...window.__HEROS_DEBUG__?.camp?.(),
|
|
activeScenes:
|
|
window.__HEROS_DEBUG__
|
|
?.activeScenes?.() ?? []
|
|
}));
|
|
}
|
|
|
|
async function readBattleSaveKeys(page) {
|
|
return page.evaluate(() =>
|
|
Array.from(
|
|
{ length: window.localStorage.length },
|
|
(_, index) =>
|
|
window.localStorage.key(index)
|
|
)
|
|
.filter(
|
|
(key) =>
|
|
typeof key === 'string' &&
|
|
key.startsWith('heros-web:battle')
|
|
)
|
|
.sort()
|
|
);
|
|
}
|
|
|
|
async function launchPreparedBattle(page) {
|
|
const launched = await page.evaluate(() => {
|
|
const camp =
|
|
window.__HEROS_DEBUG__
|
|
?.scene('CampScene');
|
|
const checkpoint =
|
|
window.__HEROS_DEBUG__
|
|
?.camp?.()?.campaign
|
|
?.sortiePreparationCheckpoint;
|
|
if (!camp || !checkpoint) {
|
|
return false;
|
|
}
|
|
camp.startCampNavigationWithCampaignStep?.(
|
|
checkpoint.targetCampaignStep,
|
|
'BattleScene',
|
|
{
|
|
battleId:
|
|
checkpoint.targetBattleId,
|
|
selectedSortieUnitIds: [
|
|
...(camp.selectedSortieUnitIds ?? [])
|
|
],
|
|
sortieFormationAssignments: {
|
|
...(camp.sortieFormationAssignments ?? {})
|
|
},
|
|
sortieItemAssignments:
|
|
JSON.parse(
|
|
JSON.stringify(
|
|
camp.sortieItemAssignments ?? {}
|
|
)
|
|
),
|
|
sortieOrderId:
|
|
camp.currentSortieOrderId?.(),
|
|
sortieResonanceBondId:
|
|
camp.currentSortieResonanceBondId?.(),
|
|
sortieRecommendation:
|
|
camp.currentSortieRecommendationSelection?.()
|
|
}
|
|
);
|
|
return true;
|
|
});
|
|
assert.equal(
|
|
launched,
|
|
true,
|
|
`${renderer}: prepared battle launch could not start`
|
|
);
|
|
}
|
|
|
|
function assertPreparationCheckpoint(
|
|
state,
|
|
expected,
|
|
label
|
|
) {
|
|
const checkpoint =
|
|
state?.sortiePreparationCheckpoint;
|
|
assert(
|
|
checkpoint,
|
|
`${label}: missing sortie-preparation checkpoint`
|
|
);
|
|
assert.deepEqual(
|
|
{
|
|
intent: checkpoint.intent,
|
|
mode: checkpoint.mode,
|
|
sourceBattleId:
|
|
checkpoint.sourceBattleId,
|
|
targetBattleId:
|
|
checkpoint.targetBattleId,
|
|
targetCampaignStep:
|
|
checkpoint.targetCampaignStep
|
|
},
|
|
{
|
|
intent: 'sortie-improvement',
|
|
...expected
|
|
},
|
|
`${label}: restored the wrong preparation destination`
|
|
);
|
|
}
|
|
|
|
async function assertDesktopRuntime(page, expectedRenderer) {
|
|
const runtime = await page.evaluate(() => {
|
|
const canvas = document.querySelector('canvas');
|
|
const bounds = canvas?.getBoundingClientRect();
|
|
return {
|
|
innerWidth: window.innerWidth,
|
|
innerHeight: window.innerHeight,
|
|
devicePixelRatio: window.devicePixelRatio,
|
|
visualViewportScale:
|
|
window.visualViewport?.scale ?? 1,
|
|
rootCssZoom:
|
|
getComputedStyle(document.documentElement).zoom,
|
|
rendererType: window.__HEROS_GAME__?.renderer.type,
|
|
canvas: canvas
|
|
? {
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
clientWidth: canvas.clientWidth,
|
|
clientHeight: canvas.clientHeight,
|
|
bounds: bounds
|
|
? {
|
|
width: bounds.width,
|
|
height: bounds.height
|
|
}
|
|
: null
|
|
}
|
|
: null
|
|
};
|
|
});
|
|
assert.equal(
|
|
runtime.innerWidth,
|
|
desktopBrowserViewport.width
|
|
);
|
|
assert.equal(
|
|
runtime.innerHeight,
|
|
desktopBrowserViewport.height
|
|
);
|
|
assert.equal(
|
|
runtime.devicePixelRatio,
|
|
desktopBrowserDeviceScaleFactor
|
|
);
|
|
assert.equal(runtime.visualViewportScale, 1);
|
|
assert(
|
|
runtime.rootCssZoom === '1' ||
|
|
runtime.rootCssZoom === 'normal',
|
|
`Unexpected CSS zoom ${runtime.rootCssZoom}.`
|
|
);
|
|
assert.equal(
|
|
runtime.rendererType,
|
|
expectedRenderer === 'canvas' ? 1 : 2
|
|
);
|
|
assert.equal(
|
|
runtime.canvas?.width,
|
|
desktopBrowserViewport.width
|
|
);
|
|
assert.equal(
|
|
runtime.canvas?.height,
|
|
desktopBrowserViewport.height
|
|
);
|
|
assert.equal(
|
|
runtime.canvas?.clientWidth,
|
|
desktopBrowserViewport.width
|
|
);
|
|
assert.equal(
|
|
runtime.canvas?.clientHeight,
|
|
desktopBrowserViewport.height
|
|
);
|
|
assert.equal(
|
|
runtime.canvas?.bounds?.width,
|
|
desktopBrowserViewport.width
|
|
);
|
|
assert.equal(
|
|
runtime.canvas?.bounds?.height,
|
|
desktopBrowserViewport.height
|
|
);
|
|
}
|
|
|
|
function assertFiniteBounds(bounds) {
|
|
assert(bounds, 'Interactive bounds are required.');
|
|
for (const value of [
|
|
bounds.x,
|
|
bounds.y,
|
|
bounds.width,
|
|
bounds.height
|
|
]) {
|
|
assert(Number.isFinite(value));
|
|
}
|
|
assert(bounds.width > 0);
|
|
assert(bounds.height > 0);
|
|
}
|
|
|
|
function assertBoundsInsideViewport(bounds) {
|
|
assert(
|
|
bounds.x >= 0 &&
|
|
bounds.y >= 0 &&
|
|
bounds.x + bounds.width <=
|
|
desktopBrowserViewport.width &&
|
|
bounds.y + bounds.height <=
|
|
desktopBrowserViewport.height,
|
|
`Bounds are outside the FHD viewport: ${JSON.stringify(
|
|
bounds
|
|
)}`
|
|
);
|
|
}
|