1433 lines
42 KiB
JavaScript
1433 lines
42 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 renderers = ['canvas', 'webgl'];
|
|
const renderer = process.env.VERIFY_BATTLE_SAVE_GENERATION_RENDERER;
|
|
|
|
if (!renderer) {
|
|
for (const requestedRenderer of renderers) {
|
|
const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url)], {
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
VERIFY_BATTLE_SAVE_GENERATION_RENDERER: requestedRenderer
|
|
},
|
|
stdio: 'inherit'
|
|
});
|
|
if (result.status !== 0) {
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
assert(
|
|
renderers.includes(renderer),
|
|
`Unsupported battle-save generation renderer "${renderer}".`
|
|
);
|
|
|
|
const battleId = 'first-battle-zhuo-commandery';
|
|
const campaignStep = 'first-battle';
|
|
const campaignGeneration = 4;
|
|
const staleGeneration = 3;
|
|
const campaignKey = 'heros-web:campaign-state';
|
|
const campaignSlotKey = `${campaignKey}:slot-1`;
|
|
const battleBaseKey = `heros-web:battle:${battleId}`;
|
|
const battleSlotKey = `${battleBaseKey}:slot-1`;
|
|
const legacyBattleKey = 'heros-web:first-battle-state';
|
|
const battleStorageKeys = [battleSlotKey, battleBaseKey, legacyBattleKey];
|
|
const otherBattleBaseKey =
|
|
'heros-web:battle:second-battle-yellow-turban-pursuit';
|
|
const otherBattleSlotKey = `${otherBattleBaseKey}:slot-1`;
|
|
const otherBattleCheckpoint =
|
|
'{"sentinel":"preserve-other-battle-checkpoint"}';
|
|
const baseUrl =
|
|
process.env.VERIFY_BATTLE_SAVE_GENERATION_URL ??
|
|
`http://127.0.0.1:${renderer === 'canvas' ? 41825 : 41826}/heros_web/`;
|
|
const targetUrl = withDebugOptions(baseUrl, renderer);
|
|
|
|
let browser;
|
|
let serverProcess;
|
|
|
|
try {
|
|
serverProcess = await startVerificationPreview({
|
|
targetUrl,
|
|
explicitUrl: Boolean(
|
|
process.env.VERIFY_BATTLE_SAVE_GENERATION_URL
|
|
),
|
|
label:
|
|
`${renderer} battle save generation verification`
|
|
});
|
|
browser = await chromium.launch({
|
|
headless: process.env.VERIFY_BATTLE_SAVE_GENERATION_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 page.evaluate(async (requestedBattleId) => {
|
|
await window.__HEROS_DEBUG__?.goToBattle(requestedBattleId);
|
|
}, battleId);
|
|
await waitForBattleReady(page);
|
|
|
|
const fixture = await page.evaluate(
|
|
({
|
|
requestedBattleId,
|
|
requestedCampaignStep,
|
|
requestedCampaignGeneration,
|
|
requestedStaleGeneration,
|
|
requestedCampaignKey,
|
|
requestedCampaignSlotKey,
|
|
requestedBattleBaseKey,
|
|
requestedBattleSlotKey,
|
|
requestedLegacyBattleKey,
|
|
requestedBattleStorageKeys
|
|
}) => {
|
|
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
if (
|
|
!scene ||
|
|
typeof scene.createBattleSaveState !== 'function' ||
|
|
typeof scene.saveBattleState !== 'function'
|
|
) {
|
|
throw new Error('BattleScene save internals are unavailable.');
|
|
}
|
|
|
|
// Persist a normalized campaign fixture and a fully valid battle-save
|
|
// shape through the production writer before applying generation markers.
|
|
scene.saveBattleState(1);
|
|
const campaignRaw =
|
|
window.localStorage.getItem(requestedCampaignSlotKey) ??
|
|
window.localStorage.getItem(requestedCampaignKey);
|
|
const battleRaw =
|
|
window.localStorage.getItem(requestedBattleSlotKey) ??
|
|
window.localStorage.getItem(requestedBattleBaseKey);
|
|
if (!campaignRaw || !battleRaw) {
|
|
throw new Error('The production save writer did not create the browser fixture.');
|
|
}
|
|
|
|
const campaign = JSON.parse(campaignRaw);
|
|
campaign.step = requestedCampaignStep;
|
|
campaign.activeSaveSlot = 1;
|
|
campaign.battleResumeGeneration = requestedCampaignGeneration;
|
|
campaign.storyCheckpoint = undefined;
|
|
campaign.explorationCheckpoint = undefined;
|
|
campaign.updatedAt = new Date().toISOString();
|
|
const serializedCampaign = JSON.stringify(campaign);
|
|
window.localStorage.setItem(requestedCampaignSlotKey, serializedCampaign);
|
|
window.localStorage.setItem(requestedCampaignKey, serializedCampaign);
|
|
|
|
const baseline = JSON.parse(battleRaw);
|
|
const markerIndex = baseline.units.findIndex((unit) => unit.id === 'liu-bei');
|
|
if (markerIndex < 0) {
|
|
throw new Error('The first-battle Liu Bei save fixture is unavailable.');
|
|
}
|
|
const baselineMarkerHp = baseline.units[markerIndex].hp;
|
|
const markerMaxHp = baseline.units[markerIndex].maxHp;
|
|
const staleMarkerHp = Math.max(1, Math.min(markerMaxHp, baselineMarkerHp - 7));
|
|
const validMarkerHp = Math.max(1, Math.min(markerMaxHp, baselineMarkerHp - 3));
|
|
if (
|
|
staleMarkerHp === baselineMarkerHp ||
|
|
validMarkerHp === baselineMarkerHp ||
|
|
staleMarkerHp === validMarkerHp
|
|
) {
|
|
throw new Error(
|
|
`Battle marker HP values are not distinguishable: ${JSON.stringify({
|
|
baselineMarkerHp,
|
|
staleMarkerHp,
|
|
validMarkerHp
|
|
})}`
|
|
);
|
|
}
|
|
|
|
const stale = structuredClone(baseline);
|
|
stale.battleId = requestedBattleId;
|
|
stale.campaignStep = requestedCampaignStep;
|
|
stale.battleResumeGeneration = requestedStaleGeneration;
|
|
stale.turnNumber = 8;
|
|
stale.activeFaction = 'ally';
|
|
stale.savedAt = new Date(Date.now() - 60_000).toISOString();
|
|
stale.units[markerIndex].hp = staleMarkerHp;
|
|
|
|
const valid = structuredClone(baseline);
|
|
valid.battleId = requestedBattleId;
|
|
valid.campaignStep = requestedCampaignStep;
|
|
valid.battleResumeGeneration = requestedCampaignGeneration;
|
|
valid.turnNumber = 6;
|
|
valid.activeFaction = 'ally';
|
|
valid.savedAt = new Date().toISOString();
|
|
valid.units[markerIndex].hp = validMarkerHp;
|
|
|
|
window.localStorage.removeItem(requestedBattleBaseKey);
|
|
window.localStorage.removeItem(requestedLegacyBattleKey);
|
|
window.localStorage.setItem(requestedBattleSlotKey, JSON.stringify(stale));
|
|
|
|
const storagePrototype = Object.getPrototypeOf(window.localStorage);
|
|
const originalRemoveItem = storagePrototype.removeItem;
|
|
const blockedKeys = new Set(requestedBattleStorageKeys);
|
|
const removalStats = {
|
|
attempts: 0,
|
|
blocked: 0,
|
|
keys: []
|
|
};
|
|
storagePrototype.removeItem = function removeItem(key) {
|
|
const normalizedKey = String(key);
|
|
if (this === window.localStorage && blockedKeys.has(normalizedKey)) {
|
|
removalStats.attempts += 1;
|
|
removalStats.blocked += 1;
|
|
removalStats.keys.push(normalizedKey);
|
|
throw new DOMException(
|
|
'Injected battle-save removal refusal',
|
|
'InvalidStateError'
|
|
);
|
|
}
|
|
return originalRemoveItem.call(this, key);
|
|
};
|
|
|
|
Object.defineProperty(window, '__HEROS_BATTLE_GENERATION_FIXTURE__', {
|
|
configurable: true,
|
|
value: {
|
|
baselineMarkerHp,
|
|
staleMarkerHp,
|
|
validMarkerHp,
|
|
valid,
|
|
removalStats,
|
|
restoreRemovalInterceptor() {
|
|
storagePrototype.removeItem = originalRemoveItem;
|
|
}
|
|
}
|
|
});
|
|
|
|
scene.scene.restart({
|
|
battleId: requestedBattleId,
|
|
resumeBattleSaveSlot: 1
|
|
});
|
|
|
|
return {
|
|
baselineMarkerHp,
|
|
staleMarkerHp,
|
|
validMarkerHp,
|
|
staleTurnNumber: stale.turnNumber,
|
|
validTurnNumber: valid.turnNumber
|
|
};
|
|
},
|
|
{
|
|
requestedBattleId: battleId,
|
|
requestedCampaignStep: campaignStep,
|
|
requestedCampaignGeneration: campaignGeneration,
|
|
requestedStaleGeneration: staleGeneration,
|
|
requestedCampaignKey: campaignKey,
|
|
requestedCampaignSlotKey: campaignSlotKey,
|
|
requestedBattleBaseKey: battleBaseKey,
|
|
requestedBattleSlotKey: battleSlotKey,
|
|
requestedLegacyBattleKey: legacyBattleKey,
|
|
requestedBattleStorageKeys: battleStorageKeys
|
|
}
|
|
);
|
|
|
|
await page.waitForFunction(
|
|
({ expectedBattleId, expectedBaselineHp }) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle?.();
|
|
const fixtureState = window.__HEROS_BATTLE_GENERATION_FIXTURE__;
|
|
return (
|
|
battle?.battleId === expectedBattleId &&
|
|
battle?.mapBackgroundReady === true &&
|
|
['deployment', 'idle'].includes(battle?.phase) &&
|
|
battle?.turnNumber === 1 &&
|
|
battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp ===
|
|
expectedBaselineHp &&
|
|
fixtureState?.removalStats?.blocked >= 1
|
|
);
|
|
},
|
|
{
|
|
expectedBattleId: battleId,
|
|
expectedBaselineHp: fixture.baselineMarkerHp
|
|
},
|
|
{ timeout: 90000 }
|
|
);
|
|
|
|
const staleProbe = await page.evaluate(
|
|
({
|
|
requestedBattleSlotKey,
|
|
requestedStaleGeneration
|
|
}) => {
|
|
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
if (!scene) {
|
|
throw new Error('BattleScene is unavailable after stale resume rejection.');
|
|
}
|
|
const rejected =
|
|
scene.readBattleSaveState?.(1, true) === undefined;
|
|
const loadDisabled =
|
|
scene.isMapMenuActionDisabled?.('load') === true;
|
|
scene.showSaveSlotPanel?.('load');
|
|
const panelBeforeSelection =
|
|
window.__HEROS_DEBUG__?.battle?.();
|
|
scene.activateSaveSlotPanelSlot?.(1);
|
|
const panelAfterSelection =
|
|
window.__HEROS_DEBUG__?.battle?.();
|
|
scene.hideSaveSlotPanel?.();
|
|
|
|
const persisted = JSON.parse(
|
|
window.localStorage.getItem(requestedBattleSlotKey) ?? 'null'
|
|
);
|
|
const fixtureState =
|
|
window.__HEROS_BATTLE_GENERATION_FIXTURE__;
|
|
return {
|
|
rejected,
|
|
loadDisabled,
|
|
panelMode:
|
|
panelBeforeSelection?.saveSlotPanelMode ?? null,
|
|
panelStayedOpen:
|
|
panelAfterSelection?.saveSlotPanelVisible === true,
|
|
promptMode:
|
|
panelAfterSelection?.turnPromptMode ?? null,
|
|
persistedGeneration:
|
|
persisted?.battleResumeGeneration ?? null,
|
|
removalStats: {
|
|
attempts:
|
|
fixtureState?.removalStats?.attempts ?? 0,
|
|
blocked:
|
|
fixtureState?.removalStats?.blocked ?? 0,
|
|
keys: [
|
|
...(fixtureState?.removalStats?.keys ?? [])
|
|
]
|
|
}
|
|
};
|
|
},
|
|
{
|
|
requestedBattleSlotKey: battleSlotKey,
|
|
requestedStaleGeneration: staleGeneration
|
|
}
|
|
);
|
|
|
|
assert.equal(
|
|
staleProbe.rejected,
|
|
true,
|
|
`${renderer}: BattleScene accepted a stale generation.`
|
|
);
|
|
assert.equal(
|
|
staleProbe.loadDisabled,
|
|
true,
|
|
`${renderer}: manual Load remained enabled for a stale save.`
|
|
);
|
|
assert.equal(staleProbe.panelMode, 'load');
|
|
assert.equal(
|
|
staleProbe.panelStayedOpen,
|
|
true,
|
|
`${renderer}: selecting a stale Load row should be a no-op.`
|
|
);
|
|
assert.equal(
|
|
staleProbe.promptMode,
|
|
null,
|
|
`${renderer}: a stale save reached the Load confirmation prompt.`
|
|
);
|
|
assert.equal(
|
|
staleProbe.persistedGeneration,
|
|
staleGeneration,
|
|
`${renderer}: the removal-refusal fixture was not preserved.`
|
|
);
|
|
assert(
|
|
staleProbe.removalStats.blocked >= 1,
|
|
`${renderer}: no stale-save cleanup refusal was exercised.`
|
|
);
|
|
assert(
|
|
staleProbe.removalStats.keys.every((key) =>
|
|
battleStorageKeys.includes(key)
|
|
),
|
|
`${renderer}: removal refusal escaped the intended battle keys.`
|
|
);
|
|
|
|
const beforeControl = await page.evaluate(
|
|
({
|
|
requestedBattleSlotKey,
|
|
requestedCampaignGeneration
|
|
}) => {
|
|
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
const fixtureState =
|
|
window.__HEROS_BATTLE_GENERATION_FIXTURE__;
|
|
if (!scene || !fixtureState?.valid) {
|
|
throw new Error('The current-generation control fixture is unavailable.');
|
|
}
|
|
|
|
window.localStorage.setItem(
|
|
requestedBattleSlotKey,
|
|
JSON.stringify(fixtureState.valid)
|
|
);
|
|
const accepted = scene.readBattleSaveState?.(1, true);
|
|
const loadDisabled =
|
|
scene.isMapMenuActionDisabled?.('load') === true;
|
|
scene.showSaveSlotPanel?.('load');
|
|
const panel =
|
|
window.__HEROS_DEBUG__?.battle?.();
|
|
scene.activateSaveSlotPanelSlot?.(1);
|
|
const confirmation =
|
|
window.__HEROS_DEBUG__?.battle?.();
|
|
|
|
return {
|
|
acceptedGeneration:
|
|
accepted?.battleResumeGeneration ?? null,
|
|
acceptedTurnNumber:
|
|
accepted?.turnNumber ?? null,
|
|
loadDisabled,
|
|
panelMode: panel?.saveSlotPanelMode ?? null,
|
|
confirmationMode:
|
|
confirmation?.turnPromptMode ?? null,
|
|
confirmationVisible:
|
|
confirmation?.turnPromptVisible === true,
|
|
expectedGeneration: requestedCampaignGeneration
|
|
};
|
|
},
|
|
{
|
|
requestedBattleSlotKey: battleSlotKey,
|
|
requestedCampaignGeneration: campaignGeneration
|
|
}
|
|
);
|
|
|
|
assert.equal(
|
|
beforeControl.acceptedGeneration,
|
|
campaignGeneration,
|
|
`${renderer}: the current-generation control was not accepted.`
|
|
);
|
|
assert.equal(
|
|
beforeControl.acceptedTurnNumber,
|
|
fixture.validTurnNumber
|
|
);
|
|
assert.equal(beforeControl.loadDisabled, false);
|
|
assert.equal(beforeControl.panelMode, 'load');
|
|
assert.equal(beforeControl.confirmationMode, 'load-confirm');
|
|
assert.equal(beforeControl.confirmationVisible, true);
|
|
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
scene?.activateTurnPromptPrimaryAction?.();
|
|
});
|
|
await page.waitForFunction(
|
|
({
|
|
expectedBattleId,
|
|
expectedTurnNumber,
|
|
expectedMarkerHp
|
|
}) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle?.();
|
|
return (
|
|
battle?.battleId === expectedBattleId &&
|
|
battle?.turnNumber === expectedTurnNumber &&
|
|
battle?.activeFaction === 'ally' &&
|
|
battle?.phase === 'idle' &&
|
|
battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp ===
|
|
expectedMarkerHp
|
|
);
|
|
},
|
|
{
|
|
expectedBattleId: battleId,
|
|
expectedTurnNumber: fixture.validTurnNumber,
|
|
expectedMarkerHp: fixture.validMarkerHp
|
|
},
|
|
{ timeout: 90000 }
|
|
);
|
|
|
|
const restored = await page.evaluate(
|
|
({
|
|
requestedCampaignKey,
|
|
requestedCampaignSlotKey
|
|
}) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle?.();
|
|
const current = JSON.parse(
|
|
window.localStorage.getItem(requestedCampaignKey) ?? 'null'
|
|
);
|
|
const slot = JSON.parse(
|
|
window.localStorage.getItem(requestedCampaignSlotKey) ?? 'null'
|
|
);
|
|
return {
|
|
turnNumber: battle?.turnNumber ?? null,
|
|
activeFaction: battle?.activeFaction ?? null,
|
|
markerHp:
|
|
battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp ?? null,
|
|
campaignGeneration:
|
|
current?.battleResumeGeneration ?? null,
|
|
slotGeneration:
|
|
slot?.battleResumeGeneration ?? null,
|
|
campaignStep: current?.step ?? null,
|
|
slotStep: slot?.step ?? null
|
|
};
|
|
},
|
|
{
|
|
requestedCampaignKey: campaignKey,
|
|
requestedCampaignSlotKey: campaignSlotKey
|
|
}
|
|
);
|
|
|
|
assert.equal(restored.turnNumber, fixture.validTurnNumber);
|
|
assert.equal(restored.activeFaction, 'ally');
|
|
assert.equal(restored.markerHp, fixture.validMarkerHp);
|
|
assert.equal(restored.campaignGeneration, campaignGeneration);
|
|
assert.equal(restored.slotGeneration, campaignGeneration);
|
|
assert.equal(restored.campaignStep, campaignStep);
|
|
assert.equal(restored.slotStep, campaignStep);
|
|
|
|
const tutorialPendingEventProbe = await page.evaluate(() => {
|
|
const scene =
|
|
window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
if (
|
|
!scene ||
|
|
typeof scene.scheduleFirstBattleTutorial !==
|
|
'function'
|
|
) {
|
|
throw new Error(
|
|
'The first-battle tutorial resume probe is unavailable.'
|
|
);
|
|
}
|
|
const originalStart =
|
|
scene.startFirstBattleTutorial;
|
|
const originalDelayedCall =
|
|
scene.time.delayedCall;
|
|
const originalState = {
|
|
phase: scene.phase,
|
|
activeFaction: scene.activeFaction,
|
|
turnNumber: scene.turnNumber,
|
|
firstBattleTutorialStep:
|
|
scene.firstBattleTutorialStep,
|
|
activeBattleEvent: scene.activeBattleEvent,
|
|
battleEventQueue: scene.battleEventQueue,
|
|
battleEventObjects: scene.battleEventObjects
|
|
};
|
|
let tutorialStartCalls = 0;
|
|
let retryCalls = 0;
|
|
try {
|
|
scene.phase = 'idle';
|
|
scene.activeFaction = 'ally';
|
|
scene.turnNumber = 1;
|
|
scene.firstBattleTutorialStep = undefined;
|
|
scene.activeBattleEvent = undefined;
|
|
scene.battleEventObjects = [];
|
|
scene.battleEventQueue = [{
|
|
key: 'qa-restored-pending-event',
|
|
title: 'Restored pending event',
|
|
lines: ['This event must display before tutorial input.'],
|
|
priority: 'normal',
|
|
playCue: false
|
|
}];
|
|
scene.startFirstBattleTutorial = () => {
|
|
tutorialStartCalls += 1;
|
|
return true;
|
|
};
|
|
scene.time.delayedCall = () => {
|
|
retryCalls += 1;
|
|
return { remove() {} };
|
|
};
|
|
scene.scheduleFirstBattleTutorial();
|
|
return {
|
|
tutorialStartCalls,
|
|
retryCalls,
|
|
pendingEventCount:
|
|
scene.battleEventQueue.length
|
|
};
|
|
} finally {
|
|
scene.startFirstBattleTutorial = originalStart;
|
|
scene.time.delayedCall = originalDelayedCall;
|
|
scene.phase = originalState.phase;
|
|
scene.activeFaction = originalState.activeFaction;
|
|
scene.turnNumber = originalState.turnNumber;
|
|
scene.firstBattleTutorialStep =
|
|
originalState.firstBattleTutorialStep;
|
|
scene.activeBattleEvent =
|
|
originalState.activeBattleEvent;
|
|
scene.battleEventQueue =
|
|
originalState.battleEventQueue;
|
|
scene.battleEventObjects =
|
|
originalState.battleEventObjects;
|
|
}
|
|
});
|
|
assert.deepEqual(
|
|
tutorialPendingEventProbe,
|
|
{
|
|
tutorialStartCalls: 0,
|
|
retryCalls: 1,
|
|
pendingEventCount: 1
|
|
},
|
|
`${renderer}: a restored pending battle event was not allowed to finish before the first-battle tutorial resumed.`
|
|
);
|
|
|
|
const autosaveContract = await page.evaluate(() => {
|
|
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
const battle = window.__HEROS_DEBUG__?.battle?.();
|
|
const fixtureState =
|
|
window.__HEROS_BATTLE_GENERATION_FIXTURE__;
|
|
fixtureState?.restoreRemovalInterceptor?.();
|
|
return {
|
|
sceneMethod:
|
|
typeof scene?.persistBattleAutosave === 'function',
|
|
debugState:
|
|
battle?.autosave ?? null
|
|
};
|
|
});
|
|
|
|
assert.equal(
|
|
autosaveContract.sceneMethod,
|
|
true,
|
|
`${renderer}: BattleScene.persistBattleAutosave(reason) is unavailable.`
|
|
);
|
|
assertAutosaveDebugState(
|
|
autosaveContract.debugState,
|
|
`${renderer}: initial autosave debug state`
|
|
);
|
|
|
|
const actionBaseline = await readAutosaveCheckpoint(
|
|
page,
|
|
battleSlotKey
|
|
);
|
|
const actionTrigger = await page.evaluate(() => {
|
|
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
const unit = scene?.debugUnitById?.('liu-bei');
|
|
if (
|
|
!scene ||
|
|
!unit ||
|
|
typeof scene.completeUnitAction !== 'function'
|
|
) {
|
|
throw new Error(
|
|
'The ally-action autosave trigger is unavailable.'
|
|
);
|
|
}
|
|
scene.phase = 'command';
|
|
scene.activeFaction = 'ally';
|
|
scene.selectedUnit = unit;
|
|
scene.pendingMove = undefined;
|
|
scene.targetingAction = undefined;
|
|
scene.selectedUsable = undefined;
|
|
scene.completeUnitAction(unit, 'wait');
|
|
return {
|
|
phase:
|
|
window.__HEROS_DEBUG__?.battle?.()?.phase ?? null,
|
|
actedUnitIds: [
|
|
...(window.__HEROS_DEBUG__?.battle?.()?.actedUnitIds ?? [])
|
|
]
|
|
};
|
|
});
|
|
|
|
assert(
|
|
actionTrigger.actedUnitIds.includes('liu-bei'),
|
|
`${renderer}: the ally action did not complete.`
|
|
);
|
|
|
|
await page.waitForFunction(
|
|
({
|
|
requestedBattleSlotKey,
|
|
previousSavedAt,
|
|
previousWriteCount
|
|
}) => {
|
|
const persisted = JSON.parse(
|
|
window.localStorage.getItem(requestedBattleSlotKey) ??
|
|
'null'
|
|
);
|
|
const autosave =
|
|
window.__HEROS_DEBUG__?.battle?.()?.autosave;
|
|
return (
|
|
persisted?.savedAt &&
|
|
persisted.savedAt !== previousSavedAt &&
|
|
persisted.actedUnitIds?.includes('liu-bei') &&
|
|
autosave?.writeCount === previousWriteCount + 1 &&
|
|
autosave?.lastResult === 'saved' &&
|
|
autosave?.lastReason === 'ally-action'
|
|
);
|
|
},
|
|
{
|
|
requestedBattleSlotKey: battleSlotKey,
|
|
previousSavedAt: actionBaseline.state.savedAt,
|
|
previousWriteCount:
|
|
actionBaseline.autosave.writeCount
|
|
}
|
|
);
|
|
|
|
const actionAutosave = await readAutosaveCheckpoint(
|
|
page,
|
|
battleSlotKey
|
|
);
|
|
assertAutosaveWrite(
|
|
actionBaseline,
|
|
actionAutosave,
|
|
{
|
|
reason: 'ally-action',
|
|
expectedTurnNumber: fixture.validTurnNumber,
|
|
expectedActedUnitId: 'liu-bei'
|
|
},
|
|
`${renderer}: ally action autosave`
|
|
);
|
|
|
|
const unsafeAutosaves = [];
|
|
for (const [
|
|
unsafeIndex,
|
|
unsafePhase
|
|
] of ['command', 'animating'].entries()) {
|
|
const beforeUnsafe = await readAutosaveCheckpoint(
|
|
page,
|
|
battleSlotKey
|
|
);
|
|
const unsafeTurnNumber = 41 + unsafeIndex;
|
|
const eventProbe = await page.evaluate(
|
|
({
|
|
requestedPhase,
|
|
requestedTurnNumber
|
|
}) => {
|
|
const scene =
|
|
window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
const unit = scene?.debugUnitById?.('liu-bei');
|
|
if (
|
|
!scene ||
|
|
!unit ||
|
|
typeof scene.persistBattleAutosave !== 'function'
|
|
) {
|
|
throw new Error(
|
|
'The unsafe pagehide autosave probe is unavailable.'
|
|
);
|
|
}
|
|
scene.turnNumber = requestedTurnNumber;
|
|
scene.activeFaction = 'ally';
|
|
scene.phase = requestedPhase;
|
|
scene.selectedUnit =
|
|
requestedPhase === 'command' ? unit : undefined;
|
|
scene.pendingMove = undefined;
|
|
scene.targetingAction = undefined;
|
|
scene.selectedUsable = undefined;
|
|
window.dispatchEvent(new Event('pagehide'));
|
|
const battle =
|
|
window.__HEROS_DEBUG__?.battle?.();
|
|
return {
|
|
phase: battle?.phase ?? null,
|
|
turnNumber: battle?.turnNumber ?? null,
|
|
autosave: battle?.autosave ?? null
|
|
};
|
|
},
|
|
{
|
|
requestedPhase: unsafePhase,
|
|
requestedTurnNumber: unsafeTurnNumber
|
|
}
|
|
);
|
|
const afterUnsafe = await readAutosaveCheckpoint(
|
|
page,
|
|
battleSlotKey
|
|
);
|
|
|
|
assert.equal(eventProbe.phase, unsafePhase);
|
|
assert.equal(
|
|
eventProbe.turnNumber,
|
|
unsafeTurnNumber
|
|
);
|
|
assertAutosaveDebugState(
|
|
eventProbe.autosave,
|
|
`${renderer}: ${unsafePhase} pagehide debug state`
|
|
);
|
|
assert.equal(
|
|
afterUnsafe.raw,
|
|
beforeUnsafe.raw,
|
|
`${renderer}: ${unsafePhase} pagehide overwrote the last safe checkpoint.`
|
|
);
|
|
assert.equal(
|
|
afterUnsafe.autosave.writeCount,
|
|
beforeUnsafe.autosave.writeCount,
|
|
`${renderer}: ${unsafePhase} pagehide incremented the autosave write count.`
|
|
);
|
|
assert.equal(
|
|
afterUnsafe.autosave.skipCount,
|
|
beforeUnsafe.autosave.skipCount + 1,
|
|
`${renderer}: ${unsafePhase} pagehide did not record one safe-state skip.`
|
|
);
|
|
assert.equal(
|
|
afterUnsafe.autosave.failureCount,
|
|
beforeUnsafe.autosave.failureCount,
|
|
`${renderer}: ${unsafePhase} pagehide was reported as a failure instead of a skip.`
|
|
);
|
|
assert.equal(
|
|
afterUnsafe.autosave.lastResult,
|
|
'skipped'
|
|
);
|
|
assert.equal(
|
|
afterUnsafe.autosave.lastReason,
|
|
'pagehide'
|
|
);
|
|
assert.equal(
|
|
afterUnsafe.autosave.lastSavedAt,
|
|
beforeUnsafe.state.savedAt
|
|
);
|
|
unsafeAutosaves.push({
|
|
phase: unsafePhase,
|
|
turnNumber: unsafeTurnNumber,
|
|
writeCount: afterUnsafe.autosave.writeCount,
|
|
skipCount: afterUnsafe.autosave.skipCount,
|
|
checkpointSavedAt: afterUnsafe.state.savedAt
|
|
});
|
|
}
|
|
|
|
const readinessBaseline = await readAutosaveCheckpoint(
|
|
page,
|
|
battleSlotKey
|
|
);
|
|
const readinessTurnNumber = 42;
|
|
const readinessProbe = await page.evaluate(
|
|
(requestedTurnNumber) => {
|
|
const scene =
|
|
window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
if (!scene) {
|
|
throw new Error(
|
|
'The autosave readiness probe is unavailable.'
|
|
);
|
|
}
|
|
scene.turnNumber = requestedTurnNumber;
|
|
scene.activeFaction = 'ally';
|
|
scene.phase = 'idle';
|
|
scene.selectedUnit = undefined;
|
|
scene.pendingMove = undefined;
|
|
scene.targetingAction = undefined;
|
|
scene.selectedUsable = undefined;
|
|
scene.battleAutosaveReady = false;
|
|
window.dispatchEvent(new Event('pagehide'));
|
|
const blocked =
|
|
window.__HEROS_DEBUG__?.battle?.()?.autosave ??
|
|
null;
|
|
scene.battleAutosaveReady = true;
|
|
return blocked;
|
|
},
|
|
readinessTurnNumber
|
|
);
|
|
const readinessAfter = await readAutosaveCheckpoint(
|
|
page,
|
|
battleSlotKey
|
|
);
|
|
assertAutosaveDebugState(
|
|
readinessProbe,
|
|
`${renderer}: pre-initialization pagehide debug state`
|
|
);
|
|
assert.equal(
|
|
readinessAfter.raw,
|
|
readinessBaseline.raw,
|
|
`${renderer}: a pagehide before battle initialization replaced the last safe checkpoint.`
|
|
);
|
|
assert.equal(
|
|
readinessAfter.autosave.writeCount,
|
|
readinessBaseline.autosave.writeCount
|
|
);
|
|
assert.equal(
|
|
readinessAfter.autosave.skipCount,
|
|
readinessBaseline.autosave.skipCount + 1
|
|
);
|
|
assert.equal(
|
|
readinessAfter.autosave.lastResult,
|
|
'skipped'
|
|
);
|
|
assert.equal(
|
|
readinessAfter.autosave.lastReason,
|
|
'pagehide'
|
|
);
|
|
|
|
const idleBaseline = await readAutosaveCheckpoint(
|
|
page,
|
|
battleSlotKey
|
|
);
|
|
const idleTurnNumber = 43;
|
|
const idleMarkerHp = Math.max(
|
|
1,
|
|
fixture.validMarkerHp - 1
|
|
);
|
|
await page.evaluate(
|
|
({
|
|
requestedTurnNumber,
|
|
requestedMarkerHp
|
|
}) => {
|
|
const scene =
|
|
window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
const unit = scene?.debugUnitById?.('liu-bei');
|
|
if (
|
|
!scene ||
|
|
!unit ||
|
|
typeof scene.persistBattleAutosave !== 'function'
|
|
) {
|
|
throw new Error(
|
|
'The idle pagehide autosave probe is unavailable.'
|
|
);
|
|
}
|
|
scene.turnNumber = requestedTurnNumber;
|
|
scene.activeFaction = 'ally';
|
|
scene.phase = 'idle';
|
|
scene.selectedUnit = undefined;
|
|
scene.pendingMove = undefined;
|
|
scene.targetingAction = undefined;
|
|
scene.selectedUsable = undefined;
|
|
unit.hp = requestedMarkerHp;
|
|
window.dispatchEvent(new Event('pagehide'));
|
|
},
|
|
{
|
|
requestedTurnNumber: idleTurnNumber,
|
|
requestedMarkerHp: idleMarkerHp
|
|
}
|
|
);
|
|
|
|
await page.waitForFunction(
|
|
({
|
|
requestedBattleSlotKey,
|
|
previousSavedAt,
|
|
previousWriteCount,
|
|
expectedTurnNumber,
|
|
expectedMarkerHp
|
|
}) => {
|
|
const persisted = JSON.parse(
|
|
window.localStorage.getItem(requestedBattleSlotKey) ??
|
|
'null'
|
|
);
|
|
const autosave =
|
|
window.__HEROS_DEBUG__?.battle?.()?.autosave;
|
|
return (
|
|
persisted?.savedAt &&
|
|
persisted.savedAt !== previousSavedAt &&
|
|
persisted.turnNumber === expectedTurnNumber &&
|
|
persisted.units?.find(
|
|
(unit) => unit.id === 'liu-bei'
|
|
)?.hp === expectedMarkerHp &&
|
|
autosave?.writeCount === previousWriteCount + 1 &&
|
|
autosave?.lastResult === 'saved' &&
|
|
autosave?.lastReason === 'pagehide'
|
|
);
|
|
},
|
|
{
|
|
requestedBattleSlotKey: battleSlotKey,
|
|
previousSavedAt: idleBaseline.state.savedAt,
|
|
previousWriteCount:
|
|
idleBaseline.autosave.writeCount,
|
|
expectedTurnNumber: idleTurnNumber,
|
|
expectedMarkerHp: idleMarkerHp
|
|
}
|
|
);
|
|
|
|
const idleAutosave = await readAutosaveCheckpoint(
|
|
page,
|
|
battleSlotKey
|
|
);
|
|
assertAutosaveWrite(
|
|
idleBaseline,
|
|
idleAutosave,
|
|
{
|
|
reason: 'pagehide',
|
|
expectedTurnNumber: idleTurnNumber,
|
|
expectedActedUnitId: 'liu-bei',
|
|
expectedMarkerHp: idleMarkerHp
|
|
},
|
|
`${renderer}: ally idle pagehide autosave`
|
|
);
|
|
assert.equal(
|
|
idleAutosave.autosave.skipCount,
|
|
idleBaseline.autosave.skipCount,
|
|
`${renderer}: a safe idle pagehide was counted as skipped.`
|
|
);
|
|
|
|
const rollbackBaseline = await readAutosaveCheckpoint(
|
|
page,
|
|
battleSlotKey
|
|
);
|
|
const rollbackBaseRaw = await page.evaluate(
|
|
(requestedBattleBaseKey) =>
|
|
window.localStorage.getItem(requestedBattleBaseKey),
|
|
battleBaseKey
|
|
);
|
|
const rollbackProbe = await page.evaluate(
|
|
({
|
|
requestedBattleSlotKey,
|
|
requestedBattleBaseKey,
|
|
requestedOtherBattleSlotKey,
|
|
requestedOtherBattleBaseKey,
|
|
requestedOtherBattleCheckpoint
|
|
}) => {
|
|
const scene =
|
|
window.__HEROS_DEBUG__?.scene('BattleScene');
|
|
if (
|
|
!scene ||
|
|
typeof scene.persistBattleAutosave !== 'function'
|
|
) {
|
|
throw new Error(
|
|
'The autosave rollback probe is unavailable.'
|
|
);
|
|
}
|
|
scene.battleAutosaveReady = true;
|
|
scene.activeFaction = 'ally';
|
|
scene.phase = 'idle';
|
|
scene.selectedUnit = undefined;
|
|
scene.pendingMove = undefined;
|
|
scene.targetingAction = undefined;
|
|
scene.selectedUsable = undefined;
|
|
scene.turnNumber = 44;
|
|
window.localStorage.setItem(
|
|
requestedOtherBattleSlotKey,
|
|
requestedOtherBattleCheckpoint
|
|
);
|
|
window.localStorage.setItem(
|
|
requestedOtherBattleBaseKey,
|
|
requestedOtherBattleCheckpoint
|
|
);
|
|
|
|
const storagePrototype =
|
|
Object.getPrototypeOf(window.localStorage);
|
|
const originalSetItem = storagePrototype.setItem;
|
|
let injected = false;
|
|
storagePrototype.setItem = function setItem(
|
|
key,
|
|
value
|
|
) {
|
|
if (
|
|
this === window.localStorage &&
|
|
String(key) === requestedBattleSlotKey &&
|
|
!injected
|
|
) {
|
|
injected = true;
|
|
throw new DOMException(
|
|
'Injected autosave write refusal',
|
|
'QuotaExceededError'
|
|
);
|
|
}
|
|
return originalSetItem.call(this, key, value);
|
|
};
|
|
|
|
let saved;
|
|
try {
|
|
saved = scene.persistBattleAutosave('debug');
|
|
} finally {
|
|
storagePrototype.setItem = originalSetItem;
|
|
}
|
|
return {
|
|
saved,
|
|
injected,
|
|
slotRaw: window.localStorage.getItem(
|
|
requestedBattleSlotKey
|
|
),
|
|
baseRaw: window.localStorage.getItem(
|
|
requestedBattleBaseKey
|
|
),
|
|
otherBattleSlotRaw: window.localStorage.getItem(
|
|
requestedOtherBattleSlotKey
|
|
),
|
|
otherBattleBaseRaw: window.localStorage.getItem(
|
|
requestedOtherBattleBaseKey
|
|
),
|
|
autosave:
|
|
window.__HEROS_DEBUG__?.battle?.()?.autosave ??
|
|
null
|
|
};
|
|
},
|
|
{
|
|
requestedBattleSlotKey: battleSlotKey,
|
|
requestedBattleBaseKey: battleBaseKey,
|
|
requestedOtherBattleSlotKey: otherBattleSlotKey,
|
|
requestedOtherBattleBaseKey: otherBattleBaseKey,
|
|
requestedOtherBattleCheckpoint:
|
|
otherBattleCheckpoint
|
|
}
|
|
);
|
|
assert.equal(rollbackProbe.injected, true);
|
|
assert.equal(rollbackProbe.saved, false);
|
|
assert.equal(
|
|
rollbackProbe.slotRaw,
|
|
rollbackBaseline.raw,
|
|
`${renderer}: a failed autosave did not restore the canonical slot checkpoint.`
|
|
);
|
|
assert.equal(
|
|
rollbackProbe.baseRaw,
|
|
rollbackBaseRaw,
|
|
`${renderer}: a failed autosave did not restore the slot-one compatibility checkpoint.`
|
|
);
|
|
assert.equal(
|
|
rollbackProbe.otherBattleSlotRaw,
|
|
otherBattleCheckpoint,
|
|
`${renderer}: a failed autosave discarded another battle's canonical checkpoint in the same campaign slot.`
|
|
);
|
|
assert.equal(
|
|
rollbackProbe.otherBattleBaseRaw,
|
|
otherBattleCheckpoint,
|
|
`${renderer}: a failed autosave discarded another battle's compatibility checkpoint in the same campaign slot.`
|
|
);
|
|
assert.equal(
|
|
rollbackProbe.autosave.writeCount,
|
|
rollbackBaseline.autosave.writeCount
|
|
);
|
|
assert.equal(
|
|
rollbackProbe.autosave.skipCount,
|
|
rollbackBaseline.autosave.skipCount
|
|
);
|
|
assert.equal(
|
|
rollbackProbe.autosave.failureCount,
|
|
rollbackBaseline.autosave.failureCount + 1
|
|
);
|
|
assert.equal(
|
|
rollbackProbe.autosave.lastResult,
|
|
'failed'
|
|
);
|
|
assert.equal(
|
|
rollbackProbe.autosave.lastReason,
|
|
'debug'
|
|
);
|
|
assert.equal(
|
|
rollbackProbe.autosave.lastSavedAt,
|
|
rollbackBaseline.state.savedAt
|
|
);
|
|
assert.equal(pageErrors.length, 0, pageErrors.join('\n'));
|
|
assert.equal(consoleErrors.length, 0, consoleErrors.join('\n'));
|
|
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
renderer,
|
|
viewport: desktopBrowserViewport,
|
|
deviceScaleFactor: desktopBrowserDeviceScaleFactor,
|
|
cssZoom: 1,
|
|
stale: {
|
|
campaignGeneration,
|
|
saveGeneration: staleGeneration,
|
|
requestedTurnNumber: fixture.staleTurnNumber,
|
|
liveTurnNumber: 1,
|
|
loadDisabled: staleProbe.loadDisabled,
|
|
removalRefusal: staleProbe.removalStats
|
|
},
|
|
control: {
|
|
campaignGeneration,
|
|
saveGeneration: beforeControl.acceptedGeneration,
|
|
restoredTurnNumber: restored.turnNumber,
|
|
restoredMarkerHp: restored.markerHp,
|
|
pendingEventBeforeTutorial:
|
|
tutorialPendingEventProbe
|
|
},
|
|
autosave: {
|
|
allyAction: {
|
|
phaseAfterAction:
|
|
actionTrigger.phase,
|
|
turnNumber:
|
|
actionAutosave.state.turnNumber,
|
|
actedUnitIds:
|
|
actionAutosave.state.actedUnitIds,
|
|
savedAt:
|
|
actionAutosave.state.savedAt,
|
|
debug:
|
|
actionAutosave.autosave
|
|
},
|
|
unsafePagehide:
|
|
unsafeAutosaves,
|
|
initializationGuard: {
|
|
turnNumber: readinessTurnNumber,
|
|
debug: readinessAfter.autosave
|
|
},
|
|
idlePagehide: {
|
|
turnNumber:
|
|
idleAutosave.state.turnNumber,
|
|
markerHp:
|
|
idleAutosave.state.units.find(
|
|
(unit) => unit.id === 'liu-bei'
|
|
)?.hp ?? null,
|
|
savedAt:
|
|
idleAutosave.state.savedAt,
|
|
debug:
|
|
idleAutosave.autosave
|
|
},
|
|
failedWriteRollback: {
|
|
slotPreserved:
|
|
rollbackProbe.slotRaw ===
|
|
rollbackBaseline.raw,
|
|
basePreserved:
|
|
rollbackProbe.baseRaw ===
|
|
rollbackBaseRaw,
|
|
otherBattleSlotPreserved:
|
|
rollbackProbe.otherBattleSlotRaw ===
|
|
otherBattleCheckpoint,
|
|
otherBattleBasePreserved:
|
|
rollbackProbe.otherBattleBaseRaw ===
|
|
otherBattleCheckpoint,
|
|
debug: rollbackProbe.autosave
|
|
}
|
|
},
|
|
pageErrors: pageErrors.length,
|
|
consoleErrors: consoleErrors.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 waitForBattleReady(page) {
|
|
await page.waitForFunction(
|
|
(expectedBattleId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle?.();
|
|
return (
|
|
window.__HEROS_DEBUG__?.activeScenes?.().includes('BattleScene') &&
|
|
battle?.scene === 'BattleScene' &&
|
|
battle?.battleId === expectedBattleId &&
|
|
battle?.battleOutcome === null &&
|
|
['deployment', 'idle'].includes(battle?.phase) &&
|
|
battle?.mapBackgroundReady === true &&
|
|
battle?.resultVisible === false
|
|
);
|
|
},
|
|
battleId,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function readAutosaveCheckpoint(
|
|
page,
|
|
requestedBattleSlotKey
|
|
) {
|
|
const checkpoint = await page.evaluate(
|
|
(storageKey) => {
|
|
const raw = window.localStorage.getItem(storageKey);
|
|
const battle =
|
|
window.__HEROS_DEBUG__?.battle?.();
|
|
return {
|
|
raw,
|
|
state: JSON.parse(raw ?? 'null'),
|
|
autosave: battle?.autosave ?? null
|
|
};
|
|
},
|
|
requestedBattleSlotKey
|
|
);
|
|
assert(
|
|
checkpoint.raw,
|
|
`Missing battle autosave checkpoint at ${requestedBattleSlotKey}.`
|
|
);
|
|
assert(
|
|
checkpoint.state,
|
|
`Invalid battle autosave checkpoint at ${requestedBattleSlotKey}.`
|
|
);
|
|
assertAutosaveDebugState(
|
|
checkpoint.autosave,
|
|
`Autosave debug state for ${requestedBattleSlotKey}`
|
|
);
|
|
return checkpoint;
|
|
}
|
|
|
|
function assertAutosaveDebugState(
|
|
autosave,
|
|
label
|
|
) {
|
|
assert(
|
|
autosave && typeof autosave === 'object',
|
|
`${label} is unavailable.`
|
|
);
|
|
for (const field of [
|
|
'writeCount',
|
|
'skipCount',
|
|
'failureCount'
|
|
]) {
|
|
assert(
|
|
Number.isInteger(autosave[field]) &&
|
|
autosave[field] >= 0,
|
|
`${label}.${field} must be a non-negative integer.`
|
|
);
|
|
}
|
|
assert(
|
|
[
|
|
'saved',
|
|
'skipped',
|
|
'failed',
|
|
null
|
|
].includes(autosave.lastResult),
|
|
`${label}.lastResult is invalid.`
|
|
);
|
|
assert(
|
|
autosave.lastReason === null ||
|
|
typeof autosave.lastReason === 'string',
|
|
`${label}.lastReason is invalid.`
|
|
);
|
|
assert(
|
|
autosave.lastSlot === null ||
|
|
autosave.lastSlot === 1,
|
|
`${label}.lastSlot is invalid.`
|
|
);
|
|
assert(
|
|
autosave.lastSavedAt === null ||
|
|
(
|
|
typeof autosave.lastSavedAt === 'string' &&
|
|
Number.isFinite(
|
|
Date.parse(autosave.lastSavedAt)
|
|
)
|
|
),
|
|
`${label}.lastSavedAt is invalid.`
|
|
);
|
|
}
|
|
|
|
function assertAutosaveWrite(
|
|
before,
|
|
after,
|
|
{
|
|
reason,
|
|
expectedTurnNumber,
|
|
expectedActedUnitId,
|
|
expectedMarkerHp
|
|
},
|
|
label
|
|
) {
|
|
assert.notEqual(
|
|
after.raw,
|
|
before.raw,
|
|
`${label} did not update the active battle slot.`
|
|
);
|
|
assert.notEqual(
|
|
after.state.savedAt,
|
|
before.state.savedAt,
|
|
`${label} did not refresh savedAt.`
|
|
);
|
|
assert.equal(
|
|
after.state.turnNumber,
|
|
expectedTurnNumber,
|
|
`${label} saved the wrong turn.`
|
|
);
|
|
assert(
|
|
after.state.actedUnitIds.includes(
|
|
expectedActedUnitId
|
|
),
|
|
`${label} omitted the completed ally action.`
|
|
);
|
|
if (expectedMarkerHp !== undefined) {
|
|
assert.equal(
|
|
after.state.units.find(
|
|
(unit) => unit.id === 'liu-bei'
|
|
)?.hp,
|
|
expectedMarkerHp,
|
|
`${label} did not persist the live ally state.`
|
|
);
|
|
}
|
|
assert.equal(
|
|
after.autosave.writeCount,
|
|
before.autosave.writeCount + 1,
|
|
`${label} did not record exactly one write.`
|
|
);
|
|
assert.equal(
|
|
after.autosave.skipCount,
|
|
before.autosave.skipCount,
|
|
`${label} unexpectedly recorded a skip.`
|
|
);
|
|
assert.equal(
|
|
after.autosave.failureCount,
|
|
before.autosave.failureCount,
|
|
`${label} unexpectedly recorded a failure.`
|
|
);
|
|
assert.equal(
|
|
after.autosave.lastResult,
|
|
'saved'
|
|
);
|
|
assert.equal(
|
|
after.autosave.lastReason,
|
|
reason
|
|
);
|
|
assert.equal(after.autosave.lastSlot, 1);
|
|
assert.equal(
|
|
after.autosave.lastSavedAt,
|
|
after.state.savedAt
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|