feat: paginate large camp rosters
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const targetUrl = withDebugParam(process.env.RELEASE_QA_URL ?? 'http://127.0.0.1:4173/');
|
||||
@@ -2001,6 +2001,17 @@ try {
|
||||
}
|
||||
});
|
||||
assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`);
|
||||
assert(
|
||||
firstCampProbe.state?.campRoster?.pageCount === 1 &&
|
||||
firstCampProbe.state.campRoster.totalCount === 3 &&
|
||||
firstCampProbe.state.campRoster.visibleUnitIds.length === 3 &&
|
||||
firstCampProbe.state.campRoster.rowBounds.length === 3 &&
|
||||
firstCampProbe.state.campRoster.rowBounds.every((row) => row.height >= 100) &&
|
||||
firstCampProbe.state.campRoster.navigationBounds === null &&
|
||||
firstCampProbe.state.campRoster.previousEnabled === false &&
|
||||
firstCampProbe.state.campRoster.nextEnabled === false,
|
||||
`Expected the three-officer first camp to retain spacious cards without redundant pagination: ${JSON.stringify(firstCampProbe.state?.campRoster)}`
|
||||
);
|
||||
assert(
|
||||
firstCampProbe.state?.nextSortieBattleId === 'second-battle-yellow-turban-pursuit',
|
||||
`Expected first camp sortie flow to prepare unlocked second battle: ${JSON.stringify(firstCampProbe.state)}`
|
||||
@@ -4670,8 +4681,8 @@ try {
|
||||
Array.isArray(finalCampState.sortieDeploymentPreview) && finalCampState.sortieDeploymentPreview.length === 0,
|
||||
`Expected final camp deployment preview to stay empty: ${JSON.stringify(finalCampState?.sortieDeploymentPreview)}`
|
||||
);
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-final-camp.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'final camp');
|
||||
await captureStableCampScreenshot(page, `${screenshotDir}/rc-final-camp.png`, 0, 'final camp');
|
||||
await assertFinalCampRosterPagination(page);
|
||||
|
||||
await page.mouse.click(1160, 38);
|
||||
const finalTransitionScenes = await waitForStoryOrEnding(page);
|
||||
@@ -5389,6 +5400,404 @@ async function waitForCamp(page) {
|
||||
}, undefined, { timeout: 90000 });
|
||||
}
|
||||
|
||||
async function waitForCampVisualFrame(page, expectedPage) {
|
||||
await page.waitForFunction(
|
||||
(pageNumber) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === pageNumber,
|
||||
expectedPage,
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
}));
|
||||
await page.waitForFunction(() => {
|
||||
const canvas = document.querySelector('canvas');
|
||||
const context = canvas?.getContext('2d', { willReadFrequently: true });
|
||||
if (!canvas || !context) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const colorBuckets = new Set();
|
||||
let litSamples = 0;
|
||||
for (let y = 110; y <= 560; y += 30) {
|
||||
for (let x = 400; x <= 1200; x += 30) {
|
||||
const [r, g, b, a] = context.getImageData(x, y, 1, 1).data;
|
||||
if (a > 8 && r + g + b > 54) {
|
||||
litSamples += 1;
|
||||
}
|
||||
colorBuckets.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 5}`);
|
||||
}
|
||||
}
|
||||
return litSamples >= 18 && colorBuckets.size >= 12;
|
||||
}, undefined, { timeout: 15000 });
|
||||
}
|
||||
|
||||
async function captureStableCampScreenshot(page, path, expectedPage, label) {
|
||||
await waitForCampVisualFrame(page, expectedPage);
|
||||
const capture = await page.evaluate(() => new Promise((resolve) => {
|
||||
const game = window.__HEROS_GAME__;
|
||||
const canvas = document.querySelector('canvas');
|
||||
if (!game?.loop || !game.renderer || !canvas) {
|
||||
resolve({ loopAvailable: false, dataUrl: null });
|
||||
return;
|
||||
}
|
||||
game.renderer.once('postrender', () => {
|
||||
game.loop.sleep();
|
||||
resolve({ loopAvailable: true, dataUrl: canvas.toDataURL('image/png') });
|
||||
});
|
||||
}));
|
||||
try {
|
||||
assert(
|
||||
capture.dataUrl?.startsWith('data:image/png;base64,'),
|
||||
`Expected a complete canvas capture for ${label}: ${JSON.stringify({ loopAvailable: capture.loopAvailable, hasDataUrl: Boolean(capture.dataUrl) })}`
|
||||
);
|
||||
writeFileSync(path, Buffer.from(capture.dataUrl.split(',')[1], 'base64'));
|
||||
await assertCanvasPainted(page, label);
|
||||
} finally {
|
||||
if (capture.loopAvailable) {
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
window.__HEROS_GAME__?.loop?.wake();
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertFinalCampRosterPagination(page) {
|
||||
const initialState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const initialRoster = initialState?.campRoster;
|
||||
const initialSelectedUnitId = initialState?.selectedUnitId ?? null;
|
||||
const initialSave = await readCampaignSave(page);
|
||||
const expectedUnitIds = (initialState?.campaign?.roster ?? []).map((unit) => unit.id);
|
||||
|
||||
assert(
|
||||
initialRoster?.page === 0 &&
|
||||
initialRoster.pageCount === 4 &&
|
||||
initialRoster.rowsPerPage === 6 &&
|
||||
initialRoster.totalCount === 23 &&
|
||||
expectedUnitIds.length === 23 &&
|
||||
new Set(expectedUnitIds).size === 23 &&
|
||||
expectedUnitIds.includes(initialSelectedUnitId),
|
||||
`Expected the final camp roster to open as four six-row pages for 23 unique officers: ${JSON.stringify({
|
||||
campRoster: initialRoster,
|
||||
expectedUnitIds
|
||||
})}`
|
||||
);
|
||||
|
||||
const visitedUnitIds = [];
|
||||
let verificationError;
|
||||
|
||||
try {
|
||||
for (let expectedPage = 0; expectedPage < initialRoster.pageCount; expectedPage += 1) {
|
||||
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const roster = state?.campRoster;
|
||||
assertCampRosterPageLayout(roster, expectedPage, initialRoster.pageCount, initialSelectedUnitId, state?.selectedUnitId);
|
||||
|
||||
const expectedVisibleCount = expectedPage === initialRoster.pageCount - 1
|
||||
? initialRoster.totalCount - initialRoster.rowsPerPage * (initialRoster.pageCount - 1)
|
||||
: initialRoster.rowsPerPage;
|
||||
assert(
|
||||
roster.visibleUnitIds.length === expectedVisibleCount &&
|
||||
roster.rowBounds.length === expectedVisibleCount &&
|
||||
sameJsonValue(roster.rowBounds.map((row) => row.unitId), roster.visibleUnitIds),
|
||||
`Expected final camp roster page ${expectedPage + 1} to expose ${expectedVisibleCount} matching rows: ${JSON.stringify(roster)}`
|
||||
);
|
||||
|
||||
roster.visibleUnitIds.forEach((unitId) => {
|
||||
assert(
|
||||
expectedUnitIds.includes(unitId) && !visitedUnitIds.includes(unitId),
|
||||
`Expected each final camp officer to appear on exactly one roster page: ${JSON.stringify({
|
||||
expectedPage,
|
||||
unitId,
|
||||
visitedUnitIds,
|
||||
expectedUnitIds
|
||||
})}`
|
||||
);
|
||||
visitedUnitIds.push(unitId);
|
||||
});
|
||||
|
||||
if (expectedPage === 1) {
|
||||
const selectionTargetUnitId = roster.visibleUnitIds[0];
|
||||
assert(
|
||||
selectionTargetUnitId && selectionTargetUnitId !== initialSelectedUnitId,
|
||||
`Expected the second roster page to expose a selectable officer other than the initial detail: ${JSON.stringify(roster)}`
|
||||
);
|
||||
const selectionPoint = await readCampRosterRowPoint(page, selectionTargetUnitId);
|
||||
await page.mouse.click(selectionPoint.x, selectionPoint.y);
|
||||
await page.waitForFunction(
|
||||
({ pageNumber, unitId }) => {
|
||||
const state = window.__HEROS_DEBUG__?.camp();
|
||||
return state?.campRoster?.page === pageNumber && state?.selectedUnitId === unitId;
|
||||
},
|
||||
{ pageNumber: expectedPage, unitId: selectionTargetUnitId },
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
const previousPoint = await readCampRosterNavigationPoint(page, 'previous');
|
||||
await page.mouse.click(previousPoint.x, previousPoint.y);
|
||||
await page.waitForFunction(
|
||||
({ pageNumber, unitId }) => {
|
||||
const state = window.__HEROS_DEBUG__?.camp();
|
||||
return state?.campRoster?.page === pageNumber && state?.selectedUnitId === unitId;
|
||||
},
|
||||
{ pageNumber: 0, unitId: selectionTargetUnitId },
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
const restorePoint = await readCampRosterRowPoint(page, initialSelectedUnitId);
|
||||
await page.mouse.click(restorePoint.x, restorePoint.y);
|
||||
await page.waitForFunction(
|
||||
(unitId) => window.__HEROS_DEBUG__?.camp()?.selectedUnitId === unitId,
|
||||
initialSelectedUnitId,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
const nextPoint = await readCampRosterNavigationPoint(page, 'next');
|
||||
await page.mouse.click(nextPoint.x, nextPoint.y);
|
||||
await page.waitForFunction(
|
||||
({ pageNumber, unitId }) => {
|
||||
const state = window.__HEROS_DEBUG__?.camp();
|
||||
return state?.campRoster?.page === pageNumber && state?.selectedUnitId === unitId;
|
||||
},
|
||||
{ pageNumber: expectedPage, unitId: initialSelectedUnitId },
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedPage === initialRoster.pageCount - 1) {
|
||||
assert(
|
||||
roster.visibleUnitIds.length === 5 && roster.previousEnabled === true && roster.nextEnabled === false,
|
||||
`Expected the final roster page to contain five officers with only previous navigation enabled: ${JSON.stringify(roster)}`
|
||||
);
|
||||
await captureStableCampScreenshot(
|
||||
page,
|
||||
`${screenshotDir}/rc-final-camp-roster-last-page.png`,
|
||||
expectedPage,
|
||||
'final camp roster last page'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextPoint = await readCampRosterNavigationPoint(page, 'next');
|
||||
await page.mouse.click(nextPoint.x, nextPoint.y);
|
||||
await page.waitForFunction(
|
||||
(pageNumber) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === pageNumber,
|
||||
expectedPage + 1,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
}
|
||||
|
||||
assert(
|
||||
visitedUnitIds.length === 23 &&
|
||||
new Set(visitedUnitIds).size === 23 &&
|
||||
sameJsonValue([...visitedUnitIds].sort(), [...expectedUnitIds].sort()),
|
||||
`Expected pagination to expose all 23 final-camp officers exactly once: ${JSON.stringify({
|
||||
visitedUnitIds,
|
||||
expectedUnitIds
|
||||
})}`
|
||||
);
|
||||
|
||||
for (let expectedPage = initialRoster.pageCount - 2; expectedPage >= 0; expectedPage -= 1) {
|
||||
const previousPoint = await readCampRosterNavigationPoint(page, 'previous');
|
||||
await page.mouse.click(previousPoint.x, previousPoint.y);
|
||||
await page.waitForFunction(
|
||||
({ pageNumber, selectedUnitId }) => {
|
||||
const state = window.__HEROS_DEBUG__?.camp();
|
||||
return state?.campRoster?.page === pageNumber && state?.selectedUnitId === selectedUnitId;
|
||||
},
|
||||
{ pageNumber: expectedPage, selectedUnitId: initialSelectedUnitId },
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
verificationError = error;
|
||||
} finally {
|
||||
const currentPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campRoster?.page ?? 0);
|
||||
for (let pageNumber = currentPage; pageNumber > 0; pageNumber -= 1) {
|
||||
const previousPoint = await readCampRosterNavigationPoint(page, 'previous');
|
||||
await page.mouse.click(previousPoint.x, previousPoint.y);
|
||||
await page.waitForFunction(
|
||||
(expectedPage) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === expectedPage,
|
||||
pageNumber - 1,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (verificationError) {
|
||||
throw verificationError;
|
||||
}
|
||||
|
||||
const restoredState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const restoredSave = await readCampaignSave(page);
|
||||
assert(
|
||||
restoredState?.campRoster?.page === initialRoster.page &&
|
||||
restoredState?.selectedUnitId === initialSelectedUnitId &&
|
||||
sameJsonValue(restoredSave, initialSave),
|
||||
`Expected the final-camp roster fixture to restore its page, selected detail, and saves: ${JSON.stringify({
|
||||
initialPage: initialRoster.page,
|
||||
restoredPage: restoredState?.campRoster?.page,
|
||||
initialSelectedUnitId,
|
||||
restoredSelectedUnitId: restoredState?.selectedUnitId,
|
||||
saveUnchanged: sameJsonValue(restoredSave, initialSave)
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
function assertCampRosterPageLayout(roster, expectedPage, pageCount, expectedSelectedUnitId, actualSelectedUnitId) {
|
||||
assert(
|
||||
roster?.page === expectedPage &&
|
||||
roster.pageCount === pageCount &&
|
||||
roster.rowsPerPage === 6 &&
|
||||
roster.totalCount === 23 &&
|
||||
actualSelectedUnitId === expectedSelectedUnitId,
|
||||
`Expected stable final-camp roster state on page ${expectedPage + 1}: ${JSON.stringify({
|
||||
roster,
|
||||
expectedSelectedUnitId,
|
||||
actualSelectedUnitId
|
||||
})}`
|
||||
);
|
||||
assert(
|
||||
roster.previousEnabled === (expectedPage > 0) &&
|
||||
roster.nextEnabled === (expectedPage < pageCount - 1),
|
||||
`Expected correct final-camp roster navigation state on page ${expectedPage + 1}: ${JSON.stringify(roster)}`
|
||||
);
|
||||
|
||||
const listBounds = roster.listBounds;
|
||||
assert(isPositiveBounds(listBounds), `Expected final-camp roster list bounds on page ${expectedPage + 1}: ${JSON.stringify(roster)}`);
|
||||
assert(
|
||||
isBoundsInside(listBounds, { x: 0, y: 0, width: 1280, height: 720 }),
|
||||
`Expected final-camp roster list inside the 1280x720 canvas: ${JSON.stringify(roster)}`
|
||||
);
|
||||
assert(
|
||||
isPositiveBounds(roster.navigationBounds?.container) &&
|
||||
isPositiveBounds(roster.navigationBounds?.previous) &&
|
||||
isPositiveBounds(roster.navigationBounds?.label) &&
|
||||
isPositiveBounds(roster.navigationBounds?.next) &&
|
||||
isBoundsInside(roster.navigationBounds.container, { x: 0, y: 0, width: 1280, height: 720 }),
|
||||
`Expected complete final-camp roster navigation bounds inside the HD canvas: ${JSON.stringify(roster)}`
|
||||
);
|
||||
assert(
|
||||
isBoundsInside(roster.navigationBounds.previous, roster.navigationBounds.container) &&
|
||||
isBoundsInside(roster.navigationBounds.label, roster.navigationBounds.container) &&
|
||||
isBoundsInside(roster.navigationBounds.next, roster.navigationBounds.container) &&
|
||||
listBounds.y + listBounds.height <= roster.navigationBounds.container.y,
|
||||
`Expected final-camp roster controls to stay inside their navigation bar and below every row: ${JSON.stringify(roster)}`
|
||||
);
|
||||
|
||||
roster.rowBounds.forEach((row, rowIndex) => {
|
||||
assert(
|
||||
row.unitId === roster.visibleUnitIds[rowIndex] &&
|
||||
isPositiveBounds(row) &&
|
||||
isBoundsInside(row, listBounds) &&
|
||||
isBoundsInside(row, { x: 0, y: 0, width: 1280, height: 720 }) &&
|
||||
row.y + row.height <= roster.navigationBounds.container.y,
|
||||
`Expected final-camp roster row ${rowIndex + 1} to match its visible officer and remain inside the list and HD canvas: ${JSON.stringify({
|
||||
row,
|
||||
listBounds,
|
||||
visibleUnitIds: roster.visibleUnitIds
|
||||
})}`
|
||||
);
|
||||
});
|
||||
|
||||
for (let leftIndex = 0; leftIndex < roster.rowBounds.length; leftIndex += 1) {
|
||||
for (let rightIndex = leftIndex + 1; rightIndex < roster.rowBounds.length; rightIndex += 1) {
|
||||
assert(
|
||||
!boundsOverlap(roster.rowBounds[leftIndex], roster.rowBounds[rightIndex]),
|
||||
`Expected final-camp roster rows on page ${expectedPage + 1} not to overlap: ${JSON.stringify({
|
||||
left: roster.rowBounds[leftIndex],
|
||||
right: roster.rowBounds[rightIndex]
|
||||
})}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readCampRosterNavigationPoint(page, direction) {
|
||||
const probe = await page.evaluate((navigationDirection) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const canvas = document.querySelector('canvas');
|
||||
const roster = window.__HEROS_DEBUG__?.camp()?.campRoster;
|
||||
const bounds = roster?.navigationBounds?.[navigationDirection];
|
||||
const enabled = navigationDirection === 'previous' ? roster?.previousEnabled : roster?.nextEnabled;
|
||||
if (!scene || !canvas || !bounds || !enabled) {
|
||||
return { ready: false, direction: navigationDirection, enabled: enabled ?? false, bounds: bounds ?? null, roster };
|
||||
}
|
||||
const canvasBounds = canvas.getBoundingClientRect();
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
return {
|
||||
ready: true,
|
||||
direction: navigationDirection,
|
||||
enabled,
|
||||
bounds,
|
||||
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
}, direction);
|
||||
assert(
|
||||
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
||||
`Expected an enabled final-camp roster ${direction} control: ${JSON.stringify(probe)}`
|
||||
);
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function readCampRosterRowPoint(page, unitId) {
|
||||
const probe = await page.evaluate((targetUnitId) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const canvas = document.querySelector('canvas');
|
||||
const roster = window.__HEROS_DEBUG__?.camp()?.campRoster;
|
||||
const bounds = roster?.rowBounds?.find((row) => row.unitId === targetUnitId);
|
||||
if (!scene || !canvas || !bounds) {
|
||||
return { ready: false, unitId: targetUnitId, bounds: bounds ?? null, roster };
|
||||
}
|
||||
const canvasBounds = canvas.getBoundingClientRect();
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
return {
|
||||
ready: true,
|
||||
unitId: targetUnitId,
|
||||
bounds,
|
||||
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
}, unitId);
|
||||
assert(
|
||||
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
||||
`Expected a visible final-camp roster row for ${unitId}: ${JSON.stringify(probe)}`
|
||||
);
|
||||
return probe;
|
||||
}
|
||||
|
||||
function isPositiveBounds(bounds) {
|
||||
return Boolean(
|
||||
bounds &&
|
||||
Number.isFinite(bounds.x) &&
|
||||
Number.isFinite(bounds.y) &&
|
||||
Number.isFinite(bounds.width) &&
|
||||
Number.isFinite(bounds.height) &&
|
||||
bounds.width > 0 &&
|
||||
bounds.height > 0
|
||||
);
|
||||
}
|
||||
|
||||
function isBoundsInside(inner, outer, tolerance = 0.01) {
|
||||
return (
|
||||
isPositiveBounds(inner) &&
|
||||
isPositiveBounds(outer) &&
|
||||
inner.x >= outer.x - tolerance &&
|
||||
inner.y >= outer.y - tolerance &&
|
||||
inner.x + inner.width <= outer.x + outer.width + tolerance &&
|
||||
inner.y + inner.height <= outer.y + outer.height + tolerance
|
||||
);
|
||||
}
|
||||
|
||||
function boundsOverlap(left, right, tolerance = 0.01) {
|
||||
return (
|
||||
left.x < right.x + right.width - tolerance &&
|
||||
left.x + left.width > right.x + tolerance &&
|
||||
left.y < right.y + right.height - tolerance &&
|
||||
left.y + left.height > right.y + tolerance
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForSortiePrep(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
||||
|
||||
Reference in New Issue
Block a user