fix: paginate sortie portrait roster

This commit is contained in:
2026-07-12 03:54:53 +09:00
parent ea60aa1de6
commit a7925985c7
2 changed files with 466 additions and 103 deletions

View File

@@ -3411,11 +3411,11 @@ try {
);
assert(
midBriefingState?.sortiePortraitRosterView?.enabled === true &&
midBriefingState?.sortiePortraitRosterView?.total > 8 &&
midBriefingState?.sortiePortraitRosterView?.visibleCount === 8 &&
midBriefingState?.sortiePortraitRosterView?.total === 23 &&
midBriefingState?.sortiePortraitRosterView?.visibleCount === 6 &&
midBriefingState?.sortiePortraitRosterView?.start === 1 &&
midBriefingState?.sortiePortraitRosterView?.end === 8,
`Expected the mid-campaign staged formation to expose an eight-card portrait roster window: ${JSON.stringify(midBriefingState?.sortiePortraitRosterView)}`
midBriefingState?.sortiePortraitRosterView?.end === 6,
`Expected the mid-campaign staged formation to expose the first six-card portrait roster page: ${JSON.stringify(midBriefingState?.sortiePortraitRosterView)}`
);
await advanceSortiePrepStep(page, 'formation');
@@ -3574,7 +3574,8 @@ try {
preservedUnitId: midFormationSwapFixture.preservedUnitId
})}`
);
await page.mouse.click(210, 447);
const maDaiCardPoint = await readSortiePortraitRosterCardPoint(page, 'ma-dai');
await page.mouse.click(maDaiCardPoint.x, maDaiCardPoint.y);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieFocusedUnitId === 'ma-dai', undefined, { timeout: 30000 });
const swapFocusState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(
@@ -3583,7 +3584,8 @@ try {
`Expected focusing Ma Dai to preserve the actual sortie roster: ${JSON.stringify(swapFocusState?.selectedSortieUnitIds)}`
);
await page.mouse.move(530, 544);
const guanYuHoverPoint = await readSortiePortraitRosterCardPoint(page, 'guan-yu');
await page.mouse.move(guanYuHoverPoint.x, guanYuHoverPoint.y);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.sortieHoveredUnitId === 'guan-yu' && state?.sortieComparisonPreview?.source === 'hover-swap';
@@ -3688,12 +3690,13 @@ try {
`Expected pointerout to restore the visible Ma Dai focus preview: ${JSON.stringify(afterSwapPreviewProbe)}`
);
await page.mouse.move(530, 544);
const guanYuSwapPoint = await readSortiePortraitRosterCardPoint(page, 'guan-yu');
await page.mouse.move(guanYuSwapPoint.x, guanYuSwapPoint.y);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.sortieHoveredUnitId === 'guan-yu' && state?.sortieComparisonPreview?.source === 'hover-swap';
}, undefined, { timeout: 30000 });
await page.mouse.click(530, 544);
await page.mouse.click(guanYuSwapPoint.x, guanYuSwapPoint.y);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.sortiePinnedSwapCandidateUnitId === 'guan-yu' &&
@@ -4127,19 +4130,121 @@ try {
{ timeout: 30000 }
);
const midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
await page.mouse.click(658, 196);
await page.waitForFunction((previousScroll) => {
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
return view?.scroll > previousScroll;
}, midFormationFirstPage?.scroll ?? 0, { timeout: 30000 });
const midFormationSecondPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
const midPortraitUndoRestoredPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
assert(
midFormationSecondPage?.start > 1 && midFormationSecondPage?.end > 8,
`Expected the portrait roster next-page control to reveal later officers: ${JSON.stringify(midFormationSecondPage)}`
midPortraitUndoRestoredPage?.page === 1 &&
midPortraitUndoRestoredPage.scroll === midPortraitUndoRestoredPage.pageSize &&
midPortraitUndoRestoredPage.previousEnabled === true &&
isFiniteBounds(midPortraitUndoRestoredPage.previousButtonBounds),
`Expected swap undo to restore the second portrait page before the independent paging roundtrip: ${JSON.stringify(midPortraitUndoRestoredPage)}`
);
const midPortraitPagingResetPoint = await readSortiePortraitRosterNavigationPoint(page, 'previous');
await page.mouse.click(midPortraitPagingResetPoint.x, midPortraitPagingResetPoint.y);
await page.waitForFunction(() => {
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
return view?.page === 0 &&
view.scroll === 0 &&
view.previousEnabled === false &&
view.visibleUnitIds?.length === 6 &&
view.cardBounds?.length === 6;
}, undefined, { timeout: 30000 });
const midPortraitPagingBaseline = await page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.camp();
return {
view: state?.sortiePortraitRosterView ?? null,
allUnitIds: state?.sortiePortraitRoster?.map((unit) => unit.id) ?? [],
focusedUnitId: state?.sortieFocusedUnitId ?? null,
selectedUnitIds: state?.selectedSortieUnitIds ?? [],
campaignSelectedUnitIds: state?.campaign?.selectedSortieUnitIds ?? [],
formationAssignments: state?.sortieFormationAssignments ?? {},
itemAssignments: state?.sortieItemAssignments ?? {}
};
});
const midPortraitPagingSaveBefore = await readCampaignSave(page);
assert(
midPortraitPagingBaseline.allUnitIds.length === 23 &&
new Set(midPortraitPagingBaseline.allUnitIds).size === 23,
`Expected the portrait paging fixture to contain exactly 23 unique campaign officers: ${JSON.stringify(midPortraitPagingBaseline)}`
);
const midPortraitForwardUnitIds = [];
let midPortraitCurrentView = midPortraitPagingBaseline.view;
for (let expectedPage = 0; expectedPage < 4; expectedPage += 1) {
const expectedVisibleUnitIds = midPortraitPagingBaseline.allUnitIds.slice(expectedPage * 6, expectedPage * 6 + 6);
assertSortiePortraitRosterPage(midPortraitCurrentView, expectedPage, expectedVisibleUnitIds);
midPortraitForwardUnitIds.push(...midPortraitCurrentView.visibleUnitIds);
if (expectedPage < 3) {
const nextPoint = await readSortiePortraitRosterNavigationPoint(page, 'next');
await page.mouse.click(nextPoint.x, nextPoint.y);
await page.waitForFunction(
({ pageIndex, visibleUnitIds }) => {
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
return view?.page === pageIndex && JSON.stringify(view.visibleUnitIds) === JSON.stringify(visibleUnitIds);
},
{ pageIndex: expectedPage + 1, visibleUnitIds: midPortraitPagingBaseline.allUnitIds.slice((expectedPage + 1) * 6, (expectedPage + 2) * 6) },
{ timeout: 30000 }
);
midPortraitCurrentView = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
}
}
assert(
midPortraitForwardUnitIds.length === 23 &&
new Set(midPortraitForwardUnitIds).size === 23 &&
sameJsonValue(midPortraitForwardUnitIds, midPortraitPagingBaseline.allUnitIds) &&
midPortraitCurrentView.visibleUnitIds.length === 5,
`Expected four portrait pages to expose all 23 officers exactly once and finish with five officers: ${JSON.stringify({
expected: midPortraitPagingBaseline.allUnitIds,
actual: midPortraitForwardUnitIds,
lastPage: midPortraitCurrentView
})}`
);
await page.waitForTimeout(300);
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-roster-last-page.png`, fullPage: true });
await assertCanvasPainted(page, 'mid camp sortie portrait roster last page');
for (let expectedPage = 2; expectedPage >= 0; expectedPage -= 1) {
const previousPoint = await readSortiePortraitRosterNavigationPoint(page, 'previous');
await page.mouse.click(previousPoint.x, previousPoint.y);
const expectedVisibleUnitIds = midPortraitPagingBaseline.allUnitIds.slice(expectedPage * 6, expectedPage * 6 + 6);
await page.waitForFunction(
({ pageIndex, visibleUnitIds }) => {
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
return view?.page === pageIndex && JSON.stringify(view.visibleUnitIds) === JSON.stringify(visibleUnitIds);
},
{ pageIndex: expectedPage, visibleUnitIds: expectedVisibleUnitIds },
{ timeout: 30000 }
);
midPortraitCurrentView = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
assertSortiePortraitRosterPage(midPortraitCurrentView, expectedPage, expectedVisibleUnitIds);
}
const midPortraitPagingAfter = await page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.camp();
return {
focusedUnitId: state?.sortieFocusedUnitId ?? null,
selectedUnitIds: state?.selectedSortieUnitIds ?? [],
campaignSelectedUnitIds: state?.campaign?.selectedSortieUnitIds ?? [],
formationAssignments: state?.sortieFormationAssignments ?? {},
itemAssignments: state?.sortieItemAssignments ?? {}
};
});
const midPortraitPagingSaveAfter = await readCampaignSave(page);
assert(
midPortraitCurrentView?.page === 0 &&
midPortraitPagingAfter.focusedUnitId === midPortraitPagingBaseline.focusedUnitId &&
sameJsonValue(midPortraitPagingAfter.selectedUnitIds, midPortraitPagingBaseline.selectedUnitIds) &&
sameJsonValue(midPortraitPagingAfter.campaignSelectedUnitIds, midPortraitPagingBaseline.campaignSelectedUnitIds) &&
sameJsonValue(midPortraitPagingAfter.formationAssignments, midPortraitPagingBaseline.formationAssignments) &&
sameJsonValue(midPortraitPagingAfter.itemAssignments, midPortraitPagingBaseline.itemAssignments) &&
sameJsonValue(midPortraitPagingSaveAfter, midPortraitPagingSaveBefore),
`Expected portrait paging to return to page one without changing focus, selection, formation, supplies, or saves: ${JSON.stringify({
before: midPortraitPagingBaseline,
after: midPortraitPagingAfter,
saveBefore: midPortraitPagingSaveBefore,
saveAfter: midPortraitPagingSaveAfter
})}`
);
await page.mouse.click(610, 196);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView?.scroll === 0, undefined, { timeout: 30000 });
const fullRosterCandidateProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
@@ -5532,16 +5637,7 @@ async function assertFinalCampRosterPagination(page) {
{ 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 }
);
await navigateCampRosterPage(page, 'previous', 0, selectionTargetUnitId);
const restorePoint = await readCampRosterRowPoint(page, initialSelectedUnitId);
await page.mouse.click(restorePoint.x, restorePoint.y);
await page.waitForFunction(
@@ -5549,16 +5645,7 @@ async function assertFinalCampRosterPagination(page) {
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 }
);
await navigateCampRosterPage(page, 'next', expectedPage, initialSelectedUnitId);
}
if (expectedPage === initialRoster.pageCount - 1) {
@@ -5575,13 +5662,7 @@ async function assertFinalCampRosterPagination(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 }
);
await navigateCampRosterPage(page, 'next', expectedPage + 1, initialSelectedUnitId);
}
assert(
@@ -5595,29 +5676,16 @@ async function assertFinalCampRosterPagination(page) {
);
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 }
);
await navigateCampRosterPage(page, 'previous', expectedPage, initialSelectedUnitId);
}
} catch (error) {
verificationError = error;
} finally {
const currentPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campRoster?.page ?? 0);
const restoreState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const currentPage = restoreState?.campRoster?.page ?? 0;
const restoreSelectedUnitId = restoreState?.selectedUnitId ?? null;
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 }
);
await navigateCampRosterPage(page, 'previous', pageNumber - 1, restoreSelectedUnitId);
}
}
@@ -5739,6 +5807,81 @@ async function readCampRosterNavigationPoint(page, direction) {
return probe;
}
async function navigateCampRosterPage(page, direction, expectedPage, expectedSelectedUnitId) {
const maxAttempts = 3;
const attemptTimeout = 4000;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const before = await page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.camp();
return {
page: state?.campRoster?.page ?? null,
selectedUnitId: state?.selectedUnitId ?? null,
campRoster: state?.campRoster ?? null
};
});
if (before.page === expectedPage && before.selectedUnitId === expectedSelectedUnitId) {
return before;
}
if (before.selectedUnitId !== expectedSelectedUnitId) {
throw new Error(
`Final-camp roster ${direction} navigation changed the selected detail before attempt ${attempt}: ${JSON.stringify({
expectedPage,
expectedSelectedUnitId,
before
})}`
);
}
try {
const point = await readCampRosterNavigationPoint(page, direction);
await page.mouse.click(point.x, point.y);
await page.waitForFunction(
({ pageNumber, selectedUnitId }) => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.campRoster?.page === pageNumber && state?.selectedUnitId === selectedUnitId;
},
{ pageNumber: expectedPage, selectedUnitId: expectedSelectedUnitId },
{ timeout: attemptTimeout }
);
return await page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.camp();
return {
page: state?.campRoster?.page ?? null,
selectedUnitId: state?.selectedUnitId ?? null,
campRoster: state?.campRoster ?? null
};
});
} catch (error) {
lastError = error;
if (attempt < maxAttempts) {
await page.waitForTimeout(120);
}
}
}
const finalState = await page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.camp();
return {
page: state?.campRoster?.page ?? null,
selectedUnitId: state?.selectedUnitId ?? null,
campRoster: state?.campRoster ?? null
};
});
const lastErrorSummary = lastError instanceof Error
? `${lastError.name}: ${lastError.message}`
: String(lastError ?? 'unknown input failure');
throw new Error(
`Failed final-camp roster ${direction} navigation to page ${expectedPage + 1} after ${maxAttempts} attempts: ${JSON.stringify({
expectedPage,
expectedSelectedUnitId,
finalState,
lastError: lastErrorSummary
})}`
);
}
async function readCampRosterRowPoint(page, unitId) {
const probe = await page.evaluate((targetUnitId) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
@@ -6284,6 +6427,149 @@ async function readSortiePresetControlPoint(page, control, presetId) {
return probe;
}
function assertSortiePortraitRosterPage(view, expectedPage, expectedVisibleUnitIds) {
const expectedStart = expectedVisibleUnitIds.length > 0 ? expectedPage * 6 + 1 : 0;
const expectedEnd = expectedPage * 6 + expectedVisibleUnitIds.length;
const hdBounds = { x: 0, y: 0, width: 1280, height: 720 };
assert(
view?.enabled === true &&
view.total === 23 &&
view.page === expectedPage &&
view.pageCount === 4 &&
view.pageSize === 6 &&
view.visibleCount === expectedVisibleUnitIds.length &&
view.start === expectedStart &&
view.end === expectedEnd &&
sameJsonValue(view.visibleUnitIds, expectedVisibleUnitIds) &&
view.previousEnabled === (expectedPage > 0) &&
view.nextEnabled === (expectedPage < 3) &&
isFiniteBounds(view.previousButtonBounds) &&
isFiniteBounds(view.nextButtonBounds) &&
boundsInside(view.previousButtonBounds, hdBounds) &&
boundsInside(view.nextButtonBounds, hdBounds) &&
view.cardBounds?.length === expectedVisibleUnitIds.length,
`Expected exact six-officer portrait page ${expectedPage + 1}/4 metadata and navigation state: ${JSON.stringify({
expectedVisibleUnitIds,
view
})}`
);
view.cardBounds.forEach((bounds, index) => {
assert(
bounds.unitId === expectedVisibleUnitIds[index] &&
isFiniteBounds(bounds) &&
boundsInside(bounds, hdBounds),
`Expected portrait card ${index + 1} on page ${expectedPage + 1} to match its visible officer inside the HD canvas: ${JSON.stringify({
bounds,
expectedUnitId: expectedVisibleUnitIds[index]
})}`
);
});
}
async function readSortiePortraitRosterNavigationPoint(page, direction) {
const probe = await page.evaluate((navigationDirection) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const canvas = document.querySelector('canvas');
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
const bounds = navigationDirection === 'previous' ? view?.previousButtonBounds : view?.nextButtonBounds;
const enabled = navigationDirection === 'previous' ? view?.previousEnabled : view?.nextEnabled;
if (!scene || !canvas || !bounds || !enabled) {
return { ready: false, direction: navigationDirection, enabled: enabled ?? false, bounds: bounds ?? null, view };
}
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 portrait roster ${direction} control with a canvas-scaled click point: ${JSON.stringify(probe)}`
);
return probe;
}
async function readSortiePortraitRosterCardPoint(page, unitId) {
const target = await page.evaluate((targetUnitId) => {
const state = window.__HEROS_DEBUG__?.camp();
const view = state?.sortiePortraitRosterView;
const unitIndex = state?.sortiePortraitRoster?.findIndex((unit) => unit.id === targetUnitId) ?? -1;
return {
ready: Boolean(view?.enabled && unitIndex >= 0 && view.pageSize > 0),
unitId: targetUnitId,
unitIndex,
currentPage: view?.page ?? null,
targetPage: unitIndex >= 0 && view?.pageSize > 0 ? Math.floor(unitIndex / view.pageSize) : null,
pageCount: view?.pageCount ?? null
};
}, unitId);
assert(
target.ready === true &&
Number.isInteger(target.currentPage) &&
Number.isInteger(target.targetPage) &&
target.targetPage >= 0 &&
target.targetPage < target.pageCount,
`Expected ${unitId} to belong to a valid portrait roster page: ${JSON.stringify(target)}`
);
let currentPage = target.currentPage;
while (currentPage !== target.targetPage) {
const direction = currentPage < target.targetPage ? 'next' : 'previous';
const navigationPoint = await readSortiePortraitRosterNavigationPoint(page, direction);
await page.mouse.click(navigationPoint.x, navigationPoint.y);
const expectedPage = currentPage + (direction === 'next' ? 1 : -1);
await page.waitForFunction(
(pageIndex) => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView?.page === pageIndex,
expectedPage,
{ timeout: 30000 }
);
currentPage = expectedPage;
}
await page.waitForFunction(
({ pageIndex, targetUnitId }) => {
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
return view?.page === pageIndex &&
view.visibleUnitIds?.includes(targetUnitId) &&
view.cardBounds?.some((card) => card.unitId === targetUnitId);
},
{ pageIndex: target.targetPage, targetUnitId: unitId },
{ timeout: 30000 }
);
const probe = await page.evaluate((targetUnitId) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const canvas = document.querySelector('canvas');
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
const bounds = view?.cardBounds?.find((card) => card.unitId === targetUnitId);
if (!scene || !canvas || !bounds) {
return { ready: false, unitId: targetUnitId, bounds: bounds ?? null, view };
}
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 portrait roster card for ${unitId} with a canvas-scaled pointer point: ${JSON.stringify(probe)}`
);
return probe;
}
async function readBattleDoctrineProbe(page) {
return page.evaluate(() => {
const state = window.__HEROS_DEBUG__?.battle();