Files
aetherbound-guild/prototype/site/verify-r7.js
T

337 lines
21 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const root = path.resolve(__dirname, "../..");
const baseURL = process.env.ABG_PROTOTYPE_URL || "http://127.0.0.1:8766";
const outputDir = process.env.ABG_PROTOTYPE_EVIDENCE || path.join(root, "prototype/gallery/evidence/r7");
const browserPath = process.env.ABG_BROWSER_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const criticalScreens = {
"SYS-004": ".run-ledger-composition",
"SYS-005": ".opening-composition",
"SYS-006": ".resume-composition",
"SYS-007": ".recovery-ledger-composition",
"SYS-008": ".data-notice-composition",
"SHP-001": ".market-composition",
"SHP-002": ".market-review",
"EQP-002": ".equipment-composition",
"PTY-001": ".party-composition",
"BAT-002": ".battle-composition",
"OUT-001": ".result-composition",
"OUT-003": ".reward-composition",
"OUT-004": ".recovery-composition",
"OUT-005": ".run-end-composition",
"OUT-006": ".abandon-composition",
"PRO-001": ".run-growth-composition",
"PRO-002": ".charter-composition",
"PRO-003": ".records-composition",
"PST-001": ".chronicle-composition",
"PST-002": ".oath-composition",
"PST-003": ".oath-result-composition",
"SET-002": ".settings-composition",
};
async function noHorizontalOverflow(page, label) {
const dimensions = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
assert.ok(dimensions.scrollWidth <= dimensions.clientWidth + 1, `${label}: ${JSON.stringify(dimensions)}`);
}
async function stageFitsViewport(page, label, requireVertical = false) {
const metrics = await page.locator(".game-stage").first().evaluate((stage) => {
const box = stage.getBoundingClientRect();
return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, width: box.width, height: box.height, viewportWidth: innerWidth, viewportHeight: innerHeight };
});
assert.ok(metrics.left >= -0.5 && metrics.right <= metrics.viewportWidth + 0.5, `${label} horizontal stage: ${JSON.stringify(metrics)}`);
if (requireVertical) assert.ok(metrics.top >= -0.5 && metrics.bottom <= metrics.viewportHeight + 0.5, `${label} vertical stage: ${JSON.stringify(metrics)}`);
assert.ok(Math.abs(metrics.width / metrics.height - 16 / 9) < 0.02, `${label} aspect ratio: ${JSON.stringify(metrics)}`);
}
async function openView(page, view) {
const desktopButton = page.locator(`[data-view="${view}"]`);
const inViewport = await desktopButton.evaluate((element) => {
const box = element.getBoundingClientRect();
return box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight;
});
if (!inViewport) {
await page.locator("#open-nav").click();
}
await desktopButton.click();
await page.locator("main .view").waitFor({ state: "visible" });
}
async function waitForMobileNavClosed(page) {
await page.waitForFunction(() => {
const sidebar = document.querySelector("#sidebar");
return sidebar && !sidebar.classList.contains("is-open") && sidebar.getBoundingClientRect().right <= 0.5;
});
}
async function selectScreen(page, id) {
await openView(page, "screens");
const button = page.locator(`.screen-index [data-screen-id="${id}"]`);
await button.evaluate((element) => element.click());
await page.locator(`[data-stage-id="${id}"]`).waitFor({ state: "visible" });
}
async function screenshot(page, name) {
const destination = path.join(outputDir, name);
await page.screenshot({ path: destination, fullPage: true });
return destination;
}
async function desktopAudit(browser, evidence) {
const errors = [];
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
page.on("console", (message) => { if (message.type() === "error") errors.push(`console: ${message.text()}`); });
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
await page.goto(`${baseURL}#firstplay`, { waitUntil: "networkidle" });
assert.equal(await page.locator("#loading").count(), 0, "loading state clears");
assert.equal(await page.locator("html").getAttribute("lang"), "zh-CN", "Simplified Chinese is the owner-facing default");
assert.match(await page.locator(".opening-title h2").textContent(), /选择两名队员/, "opening decision is readable in Chinese");
await page.locator('[data-locale="en"]').first().click();
assert.equal(await page.locator("html").getAttribute("lang"), "en", "English remains available");
assert.equal(await page.locator(".nav-item").count(), 7, "gallery view count including P8.1 playtest");
assert.equal(await page.locator(".flow-progress button").count(), 23, "complete first-play state count");
assert.equal(await page.locator(".recruit-offer").count(), 4, "four provisional offers");
assert.equal(await page.locator(".offer-character").count(), 4, "four profession identity anchors");
assert.equal(await page.locator(".offer-loadout").count(), 4, "four offers expose individual level and one starting item");
assert.deepEqual(await page.locator(".offer-character").first().evaluate((image) => [image.complete, image.naturalWidth > 0]), [true, true], "first profession identity loads");
assert.equal(await page.locator(".recruit-offer.is-selected").count(), 0, "opening build is not preselected");
await noHorizontalOverflow(page, "desktop opening");
await stageFitsViewport(page, "desktop opening");
evidence.push(await screenshot(page, "desktop-opening.png"));
await page.locator('[data-offer-id="OFF-01"]').click();
assert.equal(await page.locator(".recruit-offer.is-selected").count(), 1, "first provisional selection");
await page.locator('[data-offer-id="OFF-03"]').click();
assert.equal(await page.locator(".recruit-offer.is-selected").count(), 2, "second provisional selection");
assert.ok(await page.locator(".opening-footer .stage-primary").isEnabled(), "Review Company enabled at 2/2");
await page.locator('[data-flow-index="7"]').click();
assert.equal(await page.locator('[data-stage-id="SHP-001"] .company-meter strong').first().textContent(), "2", "first Market Company count");
assert.equal(await page.locator('[data-stage-id="SHP-001"] .bench-recruit').count(), 4, "Market keeps four Company slots visible");
assert.equal(await page.locator('[data-stage-id="SHP-001"] .market-offer-card').count(), 3, "Market keeps three offers comparable");
assert.equal(await page.locator('[data-stage-id="SHP-001"] .inventory-slot').count(), 12, "Market keeps run inventory visible");
assert.equal(await page.locator('[data-stage-id="SHP-001"] .market-tool-rail button').count(), 4, "Market modes remain visible");
evidence.push(await screenshot(page, "desktop-market.png"));
await page.locator('[data-flow-index="12"]').click();
assert.equal(await page.locator('[data-stage-id="PTY-001"] .party-actor').count(), 4, "party line actor count");
assert.equal(await page.locator('[data-stage-id="PTY-001"] .party-actor > em').count(), 4, "party line exposes four level and one-item summaries");
assert.equal(await page.locator('[data-stage-id="PTY-001"] .party-actor').last().locator("> b").textContent(), "0", "rightmost position is 0");
evidence.push(await screenshot(page, "desktop-formation.png"));
await page.locator('[data-flow-index="16"]').click();
assert.ok(await page.locator('[data-stage-id="BAT-002"] .forecast-arrow').isVisible(), "battle forecast visible");
assert.equal(await page.locator('[data-stage-id="BAT-002"] .allied-line .party-actor').count(), 4, "battle party line count");
assert.equal(await page.locator('[data-stage-id="BAT-002"] .enemy-actor img').count(), 4, "battle enemy group uses real actor art");
assert.equal(await page.locator('[data-stage-id="BAT-002"] .boss-actor > img').count(), 2, "battle boss and attached VFX are visible");
evidence.push(await screenshot(page, "desktop-battle.png"));
await page.locator('[data-flow-index="18"]').click();
assert.ok(await page.locator('[data-stage-id="OUT-001"] .is-divergence').isVisible(), "result divergence visible");
evidence.push(await screenshot(page, "desktop-result.png"));
await page.locator('[data-flow-index="22"]').click();
assert.equal(await page.locator('[data-stage-id="OUT-004"] .recovery-path').count(), 3, "recovery paths visible");
evidence.push(await screenshot(page, "desktop-recovery.png"));
await openView(page, "screens");
assert.equal(await page.locator(".screen-index button").count(), 50, "screen inventory count");
const ids = await page.locator(".screen-index button span").allTextContents();
assert.equal(new Set(ids).size, 50, "screen IDs unique");
for (const id of ids) {
const button = page.locator(`.screen-index [data-screen-id="${id}"]`);
await button.evaluate((element) => element.click());
assert.equal(await page.locator(".game-stage").first().getAttribute("data-stage-id"), id, `${id} owns its composition`);
assert.equal(await page.locator(".prototype-missing").count(), 0, `${id} has no missing placeholder`);
assert.equal(await page.locator(".generic-composition").count(), 0, `${id} has no generic fallback`);
const visualAssetsLoad = await page.locator(`[data-stage-id="${id}"]`).evaluate((stage) =>
[...stage.querySelectorAll("img")].every((image) => image.complete && image.naturalWidth > 0),
);
assert.equal(visualAssetsLoad, true, `${id} visual assets load`);
}
for (const [id, selector] of Object.entries(criticalScreens)) {
await selectScreen(page, id);
assert.ok(await page.locator(`[data-stage-id="${id}"] ${selector}`).isVisible(), `${id} critical composition`);
}
await selectScreen(page, "REC-002");
assert.match(await page.locator(".level-progress").textContent(), /LV 3.*XP 44 \/ 50/s, "Recruit detail exposes individual level and XP");
assert.match(await page.locator(".detail-grid").textContent(), /EQ-014/, "Recruit detail exposes the one equipped item");
evidence.push(await screenshot(page, "desktop-recruit-level.png"));
await selectScreen(page, "EQP-002");
assert.match(await page.locator(".one-slot-badge").textContent(), /ONE EQUIPMENT SLOT|唯一装备槽/, "equipment page names one universal slot");
assert.match(await page.locator(".equipment-delta").textContent(), /EQ-014 returns to inventory|EQ-014 退回本轮库存/, "replacement returns the prior item");
evidence.push(await screenshot(page, "desktop-equipment-one-slot.png"));
await page.locator('[data-screen-variant="error"]').click();
assert.ok(await page.locator(".state-variant-overlay.error").isVisible(), "screen error variant inspectable");
await selectScreen(page, "PRO-002");
assert.equal(await page.locator(".charter-seal").count(), 8, "Guild Charter has eight horizontal Region seals");
assert.equal(await page.locator(".charter-packet").count(), 3, "selected Region exposes three breadth packets");
await page.locator('[data-charter-region="6"]').click();
assert.match(await page.locator(".charter-seal.is-current").textContent(), /RG-07/, "Charter Region selection changes in place");
await page.locator('[data-charter-packet="2"]').click();
assert.match(await page.locator(".no-power-boundary").textContent(), /NO PERMANENT COMBAT POWER/, "Charter claim denies permanent raw power");
assert.match(await page.locator(".charter-review").textContent(), /RG-07/, "Charter prerequisite follows selected Region");
evidence.push(await screenshot(page, "desktop-guild-charter.png"));
await selectScreen(page, "PST-001");
assert.equal(await page.locator(".chronicle-choice").count(), 2, "final boss exposes two explicit choices");
assert.match(await page.locator(".archive-contract").textContent(), /Company archived as Records/, "both final choices archive Company");
await page.locator('[data-chronicle-choice="oath"]').click();
assert.ok(await page.locator('[data-chronicle-choice="oath"].is-selected').isVisible(), "Oath Season choice is reviewable");
assert.match(await page.locator(".chronicle-review").textContent(), /Ordinary New Run/, "ordinary New Run remains visible");
evidence.push(await screenshot(page, "desktop-final-choice.png"));
await selectScreen(page, "PST-002");
assert.equal(await page.locator(".oath-law").count(), 3, "Oath Season law choices visible");
await page.locator('[data-oath-law="broken-front"]').click();
assert.equal(await page.locator(".oath-law.is-selected").count(), 2, "Oath laws toggle independently");
assert.match(await page.locator(".no-carryover").textContent(), /NO CLEARED RECRUIT CARRYOVER/, "cleared Recruit carryover denied");
await selectScreen(page, "PST-003");
assert.match(await page.locator(".oath-receipts").textContent(), /Renown/, "Oath result shows sourced receipts");
await openView(page, "regions");
assert.equal(await page.locator(".region-tabs button").count(), 8, "region count");
for (let index = 0; index < 8; index += 1) {
await page.locator(`[data-region-index="${index}"]`).click();
await page.waitForFunction(() => {
const image = document.querySelector(".region-stage img");
return image && image.complete && image.naturalWidth > 0;
});
}
evidence.push(await screenshot(page, "desktop-regions.png"));
await openView(page, "catalogs");
assert.equal(await page.locator(".catalog-tabs button").count(), 6, "searchable catalog family count");
await page.locator('[data-catalog-type="professions"]').click();
await page.locator('[data-catalog-filter="base"]').click();
assert.equal(await page.locator(".catalog-entry").count(), 12, "base profession count");
await page.locator('[data-catalog-type="equipment"]').click();
await page.locator("[data-catalog-search]").fill("Stepback Soles");
assert.equal(await page.locator(".catalog-entry").count(), 1, "equipment search result");
assert.equal(await page.locator(".catalog-entry strong").textContent(), "Stepback Soles", "equipment search identity");
evidence.push(await screenshot(page, "desktop-catalog.png"));
await openView(page, "reference");
assert.equal(await page.locator(".reference-selector button").count(), 15, "fifteen reference mappings");
assert.equal(await page.locator(".reference-wireframe img, .reference-wireframe video").count(), 0, "reference map embeds no commercial media");
assert.match(await page.locator(".reference-wireframe footer").textContent(), /no commercial media/, "wireframe boundary visible");
assert.equal(await page.locator(".adaptation-panel .game-stage").getAttribute("data-stage-id"), "SHP-001", "default mapping binds integrated Market");
await page.locator('[data-reference-index="11"]').click();
assert.equal(await page.locator(".adaptation-panel .game-stage").getAttribute("data-stage-id"), "PRO-002", "persistent-path mapping binds Guild Charter");
assert.match(await page.locator(".fidelity-decisions").textContent(), /Eight horizontal Region seals/, "reference decision states original adaptation");
evidence.push(await screenshot(page, "desktop-reference-map.png"));
await selectScreen(page, "SET-002");
await page.locator('[data-text-scale="130"]').click();
assert.equal(await page.locator("body").getAttribute("data-text-scale"), "130", "130% state applies");
await page.locator('[data-setting-key="contrast"]').click();
assert.match(await page.locator("body").getAttribute("class"), /high-contrast/, "high-contrast state applies");
await page.locator('[data-locale="zh_CN"]').first().click();
assert.equal(await page.locator("html").getAttribute("lang"), "zh-CN", "Simplified Chinese state applies");
assert.match(await page.locator("main h1").textContent(), /显示与文字/, "selected screen localizes");
evidence.push(await screenshot(page, "desktop-settings-zh-130.png"));
await page.locator('[data-text-scale="100"]').click();
await page.locator("#fullscreen-current").click();
assert.ok(await page.locator("#stage-dialog").evaluate((dialog) => dialog.open), "stage viewer opens");
await page.locator("#zoom-in").click();
assert.equal(await page.locator("#zoom-reset").textContent(), "125%", "stage viewer zooms");
await page.locator("#close-dialog").click();
await openView(page, "review");
assert.equal(await page.locator(".review-groups article").count(), 7, "seven Owner review groups");
assert.equal(await page.locator('[data-view="review"] em').textContent(), "7", "review navigation badge matches group count");
assert.match(await page.locator(".review-groups article").last().textContent(), /Charter and final choice|公会宪章与终章选择/, "seventh group covers Charter and final choice");
assert.deepEqual(errors, [], `desktop browser errors: ${errors.join(" | ")}`);
await page.close();
}
async function responsiveAudit(browser, evidence) {
const fixtures = [
{ name: "tablet-battle.png", viewport: { width: 1024, height: 768 }, view: "screens", screen: "BAT-002", vertical: false },
{ name: "phone-portrait-opening.png", viewport: { width: 390, height: 844 }, view: "firstplay", vertical: true },
{ name: "phone-landscape-battle.png", viewport: { width: 844, height: 390 }, view: "firstplay", flow: 16, vertical: true },
{ name: "phone-portrait-final-choice.png", viewport: { width: 390, height: 844 }, view: "screens", screen: "PST-001", vertical: true },
{ name: "phone-portrait-equipment.png", viewport: { width: 390, height: 844 }, view: "screens", screen: "EQP-002", vertical: true },
{ name: "phone-landscape-charter.png", viewport: { width: 844, height: 390 }, view: "screens", screen: "PRO-002", vertical: true },
{ name: "phone-portrait-reference-market.png", viewport: { width: 390, height: 844 }, view: "reference", reference: 2, vertical: false },
{ name: "phone-landscape-reference-battle.png", viewport: { width: 844, height: 390 }, view: "reference", reference: 7, vertical: false },
];
for (const fixture of fixtures) {
const page = await browser.newPage({ viewport: fixture.viewport, deviceScaleFactor: 1 });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(`${baseURL}#${fixture.view}`, { waitUntil: "networkidle" });
if (fixture.screen) await selectScreen(page, fixture.screen);
if (Number.isInteger(fixture.flow)) await page.locator(`[data-flow-index="${fixture.flow}"]`).click();
if (Number.isInteger(fixture.reference)) {
await openView(page, "reference");
await page.locator(`[data-reference-index="${fixture.reference}"]`).click();
}
if (fixture.viewport.width < 880) {
await page.locator("#open-nav").click();
assert.match(await page.locator("#sidebar").getAttribute("class"), /is-open/, `${fixture.name} drawer opens`);
await page.locator("#close-nav").click();
assert.doesNotMatch(await page.locator("#sidebar").getAttribute("class"), /is-open/, `${fixture.name} drawer closes`);
await waitForMobileNavClosed(page);
}
await noHorizontalOverflow(page, fixture.name);
await stageFitsViewport(page, fixture.name, fixture.vertical);
if (fixture.screen === "EQP-002") {
assert.equal(await page.locator(".equipment-row:visible").count(), 4, "phone equipment keeps all four candidate rows visible");
const actionBounds = await page.locator('[data-stage-id="EQP-002"] .equipment-delta .stage-primary').evaluate((button) => {
const buttonBox = button.getBoundingClientRect();
const stageBox = button.closest(".game-stage").getBoundingClientRect();
return { buttonTop: buttonBox.top, buttonBottom: buttonBox.bottom, stageTop: stageBox.top, stageBottom: stageBox.bottom };
});
assert.ok(actionBounds.buttonTop >= actionBounds.stageTop && actionBounds.buttonBottom <= actionBounds.stageBottom, `phone equipment action fits stage: ${JSON.stringify(actionBounds)}`);
}
evidence.push(await screenshot(page, fixture.name));
assert.deepEqual(errors, [], `${fixture.name} browser errors: ${errors.join(" | ")}`);
await page.close();
}
}
async function bundleAudit(browser) {
const bundle = path.join(root, "prototype/gallery/aetherbound-guild-prototype.html");
assert.ok(fs.statSync(bundle).size > 1_000_000, "Gallery bundle contains embedded media");
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const external = [];
page.on("request", (request) => {
if (!request.url().startsWith("file:") && !request.url().startsWith("data:")) external.push(request.url());
});
await page.goto(`file://${bundle}#firstplay`, { waitUntil: "load" });
assert.equal(await page.locator(".recruit-offer").count(), 4, "Gallery bundle initializes offline");
assert.deepEqual(external, [], `Gallery external requests: ${external.join(" | ")}`);
await page.close();
}
async function main() {
fs.mkdirSync(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: browserPath });
const evidence = [];
try {
await desktopAudit(browser, evidence);
await responsiveAudit(browser, evidence);
await bundleAudit(browser);
} finally {
await browser.close();
}
console.log("ABG_R7_BROWSER_OK screens=50 dedicated=50 generic=0 flow_states=23 actors=4 equipment=12 vfx=16 reference_maps=15 review_groups=7 regions=8 catalogs=42,36,320,60,100,16 responsive=desktop,tablet,phone_portrait,phone_landscape bundle=offline");
console.log(`evidence=${evidence.join(",")}`);
}
main().catch((error) => {
console.error(error.stack || error);
process.exitCode = 1;
});