diff --git a/scripts/capture-game-lineage-visual.mjs b/scripts/capture-game-lineage-visual.mjs
new file mode 100644
index 000000000..0f9eee8e5
--- /dev/null
+++ b/scripts/capture-game-lineage-visual.mjs
@@ -0,0 +1,1154 @@
+// 族谱可视化(节点-连线树)的截图与 DOM 断言工具。
+//
+// 这不是 CI 门禁脚本,是给人看的验收工具:它先造一棵真实的多代多分支树(+一个降级态 fixture),
+// 再用 Playwright 打开 /games/lineage?id=<根> 出 5 张截图并顺手做 DOM 断言。
+//
+// 用法(需要本地 dev 栈 + admin 凭据;playwright 走临时目录,见仓库既有 e2e 的用法):
+// E2E_ADMIN_USER=admin E2E_ADMIN_PASSWORD=123456 \
+// E2E_PLAYWRIGHT_DIR=%TEMP%/genarrative-pw E2E_CHROMIUM_EXECUTABLE='C:/Program Files/Google/Chrome/Application/chrome.exe' \
+// node scripts/capture-game-lineage-visual.mjs
+//
+// 契约来源(读实现确认;符号检索定位,不写行号):src/components/game-distribution/GameLineagePage.tsx
+// - 节点:`.game-lineage-node`(`role="button"` + `tabIndex=0` + `aria-label`)、锚点额外 `--anchor` + `aria-current="true"`、
+// 占位节点 `--stub` + `aria-disabled="true"`(标题就是「原作品已不可用」)、非高亮节点 `--dimmed`
+// - 画布:`.game-lineage-canvas`(滚轮缩放、指针拖拽平移)→ `.game-lineage-canvas__content` 带 transform
+// - 缩放控件:`button[aria-label="放大"]` / `button[aria-label="缩小"]`、百分比 `role="status"` 的 `.game-lineage-zoom`
+// - hover:节点 onMouseEnter → 祖先路径高亮、其余 `--dimmed`
+// - 降级文案:占位节点标题「原作品已不可用」、页内 notice「母版已不可用…」
+// - 首次进入自动 fitToView(不抢用户后续缩放)
+// - 布局纯函数:src/components/game-distribution/lineageTreeLayout.ts(缺失父作品会生成 stub 节点)
+
+import { createHash } from 'node:crypto';
+import { readFileSync } from 'node:fs';
+import { mkdtemp } from 'node:fs/promises';
+import { createRequire } from 'node:module';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { deflateSync } from 'node:zlib';
+
+import JSZip from 'jszip';
+
+const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
+const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
+const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
+const DEV_PASSWORD = 'GenE2e123!';
+const GATE_KEY = 'game-distribution:publish';
+
+if (!ADMIN_USER || !ADMIN_PASSWORD) {
+ console.error('缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD');
+ process.exit(2);
+}
+
+const devStack = JSON.parse(
+ readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'),
+);
+const API = (
+ process.env.E2E_API_BASE ??
+ devStack.services?.['api-server']?.url ??
+ ''
+).replace(/\/+$/u, '');
+const WEB = (
+ process.env.E2E_WEB_BASE ??
+ devStack.services?.web?.url ??
+ 'http://127.0.0.1:3000'
+).replace(/\/+$/u, '');
+
+let checks = 0;
+let failures = 0;
+function check(name, ok, detail = '') {
+ checks += 1;
+ if (!ok) failures += 1;
+ console.log(
+ `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
+ );
+}
+// ---------- 真实封面图(脚本内生成,不依赖网络/外部资源) ----------
+//
+// 需求方要凭截图判断「够不够帅」,1×1 透明 PNG 会在卡片上显示成灰块,看不出视觉上限;
+// 这里用纯 Node(zlib + 手写 PNG chunk)生成 512×288 的**有内容**封面:每个节点一张、
+// 色相不同、并叠加斜条纹与底部条带,便于在截图里一眼分辨不同节点的封面。
+
+const PNG_SIGNATURE = Buffer.from([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
+]);
+
+const PNG_CRC_TABLE = (() => {
+ const table = new Uint32Array(256);
+ for (let index = 0; index < 256; index += 1) {
+ let value = index;
+ for (let bit = 0; bit < 8; bit += 1) {
+ value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
+ }
+ table[index] = value >>> 0;
+ }
+ return table;
+})();
+
+function pngCrc32(buffer) {
+ let crc = 0xffffffff;
+ for (const byte of buffer) {
+ crc = PNG_CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+function pngChunk(type, data) {
+ const length = Buffer.alloc(4);
+ length.writeUInt32BE(data.length, 0);
+ const typeAndData = Buffer.concat([Buffer.from(type, 'ascii'), data]);
+ const crc = Buffer.alloc(4);
+ crc.writeUInt32BE(pngCrc32(typeAndData), 0);
+ return Buffer.concat([length, typeAndData, crc]);
+}
+
+function hslToRgb(hue, saturation, lightness) {
+ const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
+ const hueSection = (((hue % 360) + 360) % 360) / 60;
+ const second = chroma * (1 - Math.abs((hueSection % 2) - 1));
+ const base = lightness - chroma / 2;
+ const table = [
+ [chroma, second, 0],
+ [second, chroma, 0],
+ [0, chroma, second],
+ [0, second, chroma],
+ [second, 0, chroma],
+ [chroma, 0, second],
+ ];
+ const [red, green, blue] = table[Math.floor(hueSection) % 6];
+ return [
+ Math.round((red + base) * 255),
+ Math.round((green + base) * 255),
+ Math.round((blue + base) * 255),
+ ];
+}
+
+/// 每个 index 一张不同的封面:色相轮转 + 斜条纹 + 底部条带,便于在截图里区分节点。
+function makeCoverPng(index, total = 6) {
+ const width = 512;
+ const height = 288;
+ const hue = Math.round((360 / total) * index + 18);
+ const stripeCount = 3 + index;
+ const raw = Buffer.alloc((width * 3 + 1) * height);
+ let offset = 0;
+ for (let y = 0; y < height; y += 1) {
+ raw[offset] = 0; // filter: none
+ offset += 1;
+ for (let x = 0; x < width; x += 1) {
+ // 垂直渐变:顶部亮、底部暗
+ const gradient = 0.72 - (y / height) * 0.42;
+ // 斜条纹:每张图的条数不同,条纹上提亮
+ const stripe = (x + y * 0.75) % 96 < 96 / stripeCount ? 0.16 : 0;
+ // 底部条带:模拟卡片下缘的一条深色 caption
+ const bottomBand = y > height - 46 ? -0.24 : 0;
+ const lightness = Math.min(
+ 0.9,
+ Math.max(0.08, gradient + stripe + bottomBand),
+ );
+ const [red, green, blue] = hslToRgb(hue, 0.55, lightness);
+ raw[offset] = red;
+ raw[offset + 1] = green;
+ raw[offset + 2] = blue;
+ offset += 3;
+ }
+ }
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(width, 0);
+ ihdr.writeUInt32BE(height, 4);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 2; // color type: truecolor
+ ihdr[10] = 0;
+ ihdr[11] = 0;
+ ihdr[12] = 0;
+ return Buffer.concat([
+ PNG_SIGNATURE,
+ pngChunk('IHDR', ihdr),
+ pngChunk('IDAT', deflateSync(raw, { level: 9 })),
+ pngChunk('IEND', Buffer.alloc(0)),
+ ]);
+}
+
+const COVER_PNG = makeCoverPng(0);
+
+async function api(pathname, options = {}) {
+ const { method = 'GET', token, body, headers = {}, binary } = options;
+ const finalHeaders = { ...ENVELOPE, ...headers };
+ if (token) finalHeaders.Authorization = `Bearer ${token}`;
+ let finalBody;
+ if (binary) {
+ finalBody = binary;
+ } else if (body !== undefined) {
+ finalHeaders['Content-Type'] = 'application/json';
+ finalBody = JSON.stringify(body);
+ }
+ const response = await fetch(`${API}${pathname}`, {
+ method,
+ headers: finalHeaders,
+ body: finalBody,
+ signal: AbortSignal.timeout(120_000),
+ });
+ const text = await response.text();
+ let json = null;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ json = null;
+ }
+ return {
+ status: response.status,
+ text,
+ json,
+ data: json?.data,
+ error: json?.error,
+ };
+}
+
+function brief(body) {
+ const code = body?.error?.code ?? body?.json?.error?.code ?? '';
+ return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 160)}`;
+}
+
+async function register(prefix) {
+ const phone = `${prefix}${String(Date.now()).slice(-8)}`;
+ const response = await api('/api/auth/entry', {
+ method: 'POST',
+ body: { purePhoneNumber: phone, password: DEV_PASSWORD },
+ });
+ return { phone, token: response.data?.token };
+}
+
+async function setDisplayName(token, displayName) {
+ return api('/api/profile/me', {
+ method: 'PATCH',
+ token,
+ body: { displayName },
+ });
+}
+
+function gameMetadata({ title, coverAssetId }) {
+ return {
+ title,
+ summary: '族谱可视化截图 fixture',
+ description: '',
+ category: '休闲',
+ tags: ['e2e'],
+ coverAssetId,
+ deviceSupport: { desktop: true, mobile: false, touch: false },
+ inputModes: ['keyboard', 'mouse'],
+ orientation: 'landscape',
+ };
+}
+
+async function uploadCover(token, id, png = COVER_PNG) {
+ const fileName = `visual-${id}.png`;
+ const ticket = await api('/api/assets/direct-upload-tickets', {
+ method: 'POST',
+ token,
+ body: {
+ legacyPrefix: 'generated-character-drafts',
+ pathSegments: ['game-distribution', 'lineage-visual', String(id)],
+ fileName,
+ contentType: 'image/png',
+ access: 'private',
+ maxSizeBytes: png.length,
+ metadata: { asset_kind: 'game_distribution_cover' },
+ },
+ });
+ if (ticket.status !== 200) throw new Error(`直传凭证失败 ${brief(ticket)}`);
+ const upload = ticket.data.upload;
+ const form = new FormData();
+ for (const [key, value] of Object.entries(upload.formFields ?? {})) {
+ if (value !== null && value !== undefined) form.append(key, String(value));
+ }
+ form.append('file', new Blob([png], { type: 'image/png' }), fileName);
+ const put = await fetch(upload.host, { method: 'POST', body: form });
+ if (!put.ok) throw new Error(`直传失败 ${put.status}`);
+ const confirm = await api('/api/assets/objects/confirm', {
+ method: 'POST',
+ token,
+ body: {
+ bucket: upload.bucket,
+ objectKey: upload.objectKey,
+ contentType: 'image/png',
+ contentLength: png.length,
+ assetKind: 'game_distribution_cover',
+ accessPolicy: 'private',
+ entityId: 'game-distribution-lineage-visual',
+ },
+ });
+ if (confirm.status !== 200) throw new Error(`确认素材失败 ${brief(confirm)}`);
+ return confirm.data.assetObject.assetObjectId;
+}
+
+async function createGame({ token, metadata, idemKey, fork }) {
+ return api('/api/game-distribution/games', {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': idemKey },
+ body: fork ? { ...metadata, fork } : metadata,
+ });
+}
+
+async function ownerGame(token, gameId) {
+ const response = await api(`/api/game-distribution/my-games/${gameId}`, {
+ token,
+ });
+ return response.data?.game ?? null;
+}
+
+async function publicVersionIdOf(token, gameId) {
+ const versions = (await ownerGame(token, gameId))?.versions ?? [];
+ return (
+ versions.find((version) => version.status === 'published')?.versionId ?? ''
+ );
+}
+
+function publicDetail(gameId) {
+ return api(`/api/game-distribution/games/${gameId}`);
+}
+
+async function buildZip(marker) {
+ const zip = new JSZip();
+ zip.file(
+ 'index.html',
+ `
${marker}${marker}
`,
+ );
+ const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' }));
+ return {
+ bytes,
+ fileCount: 1,
+ sha256: createHash('sha256').update(bytes).digest('hex'),
+ };
+}
+
+async function publishToPublic({
+ token,
+ admin,
+ gameId,
+ gameRevision,
+ metadata,
+ stamp,
+ tag,
+ label,
+}) {
+ const zip = await buildZip(`visual-${tag}-${stamp}`);
+ const created = await api(`/api/game-distribution/games/${gameId}/versions`, {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': `visual-version-${tag}-${stamp}` },
+ body: {
+ packageSha256: zip.sha256,
+ packageBytes: zip.bytes.length,
+ packageFileCount: zip.fileCount,
+ packageEntryPath: 'index.html',
+ gameMetadata: metadata,
+ },
+ });
+ const versionId = created.data?.versionId;
+ if (!versionId) {
+ check(`${label} 建版本`, false, brief(created));
+ return { versionId: null };
+ }
+ const upload = await api(
+ `/api/game-distribution/versions/${versionId}/package`,
+ {
+ method: 'PUT',
+ token,
+ headers: {
+ 'Idempotency-Key': `visual-upload-${tag}-${stamp}`,
+ 'Content-Type': 'application/zip',
+ },
+ binary: zip.bytes,
+ },
+ );
+ if (upload.status !== 200) {
+ check(`${label} 传发行包`, false, brief(upload));
+ return { versionId: null };
+ }
+ const submitted = await api(
+ `/api/game-distribution/versions/${versionId}/submit`,
+ {
+ method: 'POST',
+ token,
+ headers: { 'Idempotency-Key': `visual-submit-${tag}-${stamp}` },
+ body: { expectedPublicationRevision: gameRevision },
+ },
+ );
+ const readback = await api(`/api/game-distribution/versions/${versionId}`, {
+ token,
+ });
+ const approved = await api(
+ `/admin/api/game-distribution/versions/${versionId}/review`,
+ {
+ method: 'POST',
+ token: admin,
+ headers: { 'Idempotency-Key': `visual-approve-${tag}-${stamp}` },
+ body: {
+ decision: 'approve',
+ expectedPublicationRevision:
+ readback.data?.version?.publicationRevision ?? gameRevision,
+ },
+ },
+ );
+ check(
+ `${label} 发布链路完成(送审 ${submitted.status} / 通过 ${approved.status})`,
+ submitted.status === 202 && approved.status === 200,
+ `submitted=${submitted.status} approved=${approved.status}`,
+ );
+ return { versionId };
+}
+
+async function promoteForkAuthorization({ token, gameId, to, key }) {
+ return api(`/api/game-distribution/games/${gameId}/fork-authorization`, {
+ method: 'PUT',
+ token,
+ headers: { 'Idempotency-Key': key },
+ body: { expectedForkAuthorization: 'forbidden', forkAuthorization: to },
+ });
+}
+
+// ---------- 造树 ----------
+
+async function createPublishedNode({
+ label,
+ title,
+ owner,
+ admin,
+ parent,
+ promoteTo,
+ stamp,
+ coverPng,
+}) {
+ const coverAssetId = await uploadCover(
+ owner.token,
+ `${stamp}-${label}`,
+ coverPng,
+ );
+ const parentVersionId = parent
+ ? await publicVersionIdOf(parent.token, parent.gameId)
+ : '';
+ const metadata = gameMetadata({ title, coverAssetId });
+ const created = await createGame({
+ token: owner.token,
+ metadata,
+ idemKey: `visual-${label}-${stamp}`,
+ ...(parent
+ ? { fork: { parentGameId: parent.gameId, parentVersionId } }
+ : {}),
+ });
+ const gameId = created.data?.id;
+ check(
+ `造树:节点 ${label} 创建(${parent ? `父=${parent.label}` : '根'})`,
+ created.status === 200 && Boolean(gameId),
+ brief(created),
+ );
+ if (!gameId) return { label, gameId: null, token: owner.token, title };
+ await publishToPublic({
+ token: owner.token,
+ admin,
+ gameId,
+ gameRevision: created.data?.publicationRevision ?? 0,
+ metadata,
+ stamp,
+ tag: `visual-${label}`,
+ label,
+ });
+ const detail = await publicDetail(gameId);
+ check(
+ `造树:节点 ${label} 已公开`,
+ detail.data?.status === 'published',
+ `status=${detail.data?.status ?? '∅'}`,
+ );
+ if (promoteTo) {
+ const promoted = await promoteForkAuthorization({
+ token: owner.token,
+ gameId,
+ to: promoteTo,
+ key: `visual-promote-${label}-${stamp}`,
+ });
+ check(
+ `造树:节点 ${label} 授权=${promoteTo}`,
+ promoted.status === 200,
+ brief(promoted),
+ );
+ }
+ return { label, gameId, token: owner.token, title };
+}
+
+// ---------- Playwright ----------
+
+async function loadPlaywright() {
+ const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim();
+ if (!dir) return import('playwright');
+ const requireFromDir = createRequire(path.join(dir, 'noop.js'));
+ return requireFromDir('playwright');
+}
+
+async function main() {
+ console.log(`[lineage-visual] api-server=${API} web=${WEB}`);
+ const stamp = Date.now();
+ const suffix = String(stamp).slice(-6);
+
+ const adminLogin = await api('/admin/api/login', {
+ method: 'POST',
+ body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
+ });
+ const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
+ check('管理员登录', Boolean(admin), `status=${adminLogin.status}`);
+ if (!admin) process.exit(1);
+ await api('/admin/api/feature-gates', {
+ method: 'PUT',
+ token: admin,
+ body: {
+ gateKey: GATE_KEY,
+ enabled: true,
+ rolloutPercent: 100,
+ allowUserIds: [],
+ allowUserTags: [],
+ denyUserIds: [],
+ description: '族谱可视化截图',
+ },
+ });
+
+ const authorA = await register('132');
+ const authorB = await register('133');
+ const nameA = `可视化作者甲${suffix}`;
+ const nameB = `可视化作者乙${suffix}`;
+ await setDisplayName(authorA.token, nameA);
+ await setDisplayName(authorB.token, nameB);
+ check(
+ '两个作者账号就绪(用于分支上的作者多样性)',
+ Boolean(authorA.token && authorB.token),
+ `phones=${authorA.phone}/${authorB.phone}`,
+ );
+
+ // 每个节点一张不同的真实封面(脚本内生成,512×288 渐变+斜条纹),便于在截图里区分节点。
+ const coverFor = (index) => makeCoverPng(index, 8);
+
+ // 富树:1 根 + 3 直接子 + 1 孙 + 1 曾孙
+ const root = await createPublishedNode({
+ label: 'R',
+ title: `可视化母版 ${suffix}`,
+ owner: authorA,
+ admin,
+ promoteTo: 'nonCommercial',
+ stamp,
+ coverPng: coverFor(0),
+ });
+ const b1 = await createPublishedNode({
+ label: 'B1',
+ title: `分支 B1 ${suffix}`,
+ owner: authorA,
+ admin,
+ parent: root,
+ promoteTo: 'nonCommercial',
+ stamp,
+ coverPng: coverFor(1),
+ });
+ const c1 = await createPublishedNode({
+ label: 'C1',
+ title: `分支 C1 ${suffix}`,
+ owner: authorB,
+ admin,
+ parent: root,
+ promoteTo: 'full',
+ stamp,
+ coverPng: coverFor(2),
+ });
+ const f1 = await createPublishedNode({
+ label: 'F1',
+ title: `分支 F1 ${suffix}`,
+ owner: authorB,
+ admin,
+ parent: root,
+ stamp,
+ coverPng: coverFor(3),
+ });
+ const d = await createPublishedNode({
+ label: 'D',
+ title: `二代 D ${suffix}`,
+ owner: authorA,
+ admin,
+ parent: b1,
+ promoteTo: 'nonCommercial',
+ stamp,
+ coverPng: coverFor(4),
+ });
+ const e = await createPublishedNode({
+ label: 'E',
+ title: `三代 E ${suffix}`,
+ owner: authorB,
+ admin,
+ parent: d,
+ stamp,
+ coverPng: coverFor(5),
+ });
+ const nodeIds = {
+ R: root.gameId,
+ B1: b1.gameId,
+ C1: c1.gameId,
+ F1: f1.gameId,
+ D: d.gameId,
+ E: e.gameId,
+ };
+ const allBuilt = Object.values(nodeIds).every(Boolean);
+ check('富树 6 节点全部建成', allBuilt, JSON.stringify(nodeIds));
+ if (!allBuilt) process.exit(1);
+
+ // 降级态 fixture:父作品下架后仍保留子作品(子作品的树里父节点应显示「原作品已不可用」)
+ const degradedParent = await createPublishedNode({
+ label: 'P',
+ title: `降级母版 ${suffix}`,
+ owner: authorA,
+ admin,
+ promoteTo: 'nonCommercial',
+ stamp,
+ coverPng: coverFor(6),
+ });
+ const degradedChild = await createPublishedNode({
+ label: 'P-C',
+ title: `降级子作品 ${suffix}`,
+ owner: authorB,
+ admin,
+ parent: degradedParent,
+ stamp,
+ coverPng: coverFor(7),
+ });
+ const parentNow = await ownerGame(authorA.token, degradedParent.gameId);
+ const unpublish = await api(
+ `/api/game-distribution/games/${degradedParent.gameId}/unpublish`,
+ {
+ method: 'POST',
+ token: authorA.token,
+ headers: { 'Idempotency-Key': `visual-unpublish-${stamp}` },
+ body: {
+ expectedPublicationRevision: parentNow?.publicationRevision ?? 0,
+ },
+ },
+ );
+ check(
+ '降级 fixture:父作品已下架(子作品仍公开)',
+ unpublish.status === 200,
+ brief(unpublish),
+ );
+
+ const richUrl = `${WEB}/games/lineage?id=${nodeIds.R}`;
+ const degradedUrl = `${WEB}/games/lineage?id=${degradedChild.gameId}`;
+ console.log(`[lineage-visual] 富树 URL=${richUrl}`);
+ console.log(`[lineage-visual] 降级态 URL=${degradedUrl}`);
+
+ const { chromium } = await loadPlaywright();
+ const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
+ const browser = await chromium.launch({
+ headless: true,
+ ...(executablePath ? { executablePath } : {}),
+ });
+ const shotDir = await mkdtemp(
+ path.join(tmpdir(), 'genarrative-lineage-visual-'),
+ );
+ const shots = [];
+ const takeShot = async (page, name) => {
+ const file = path.join(shotDir, name);
+ await page.screenshot({ path: file, fullPage: false });
+ shots.push(file);
+ console.log(`[lineage-visual] 截图 ${file}`);
+ return file;
+ };
+ // 初次进入的视野契约:要么真的全览(所有节点在画布内),要么按可读缩放下限显示并给出提示。
+ const fitContract = async (target) => {
+ const zoom = Number.parseInt(
+ await target.locator('.game-lineage-zoom').first().innerText(),
+ 10,
+ );
+ const hint = await target
+ .getByText('已按可读缩放显示', { exact: false })
+ .count();
+ const allInside = await target.evaluate(() => {
+ const canvas = document
+ .querySelector('.game-lineage-canvas')
+ ?.getBoundingClientRect();
+ if (!canvas) return false;
+ return [...document.querySelectorAll('.game-lineage-node')].every(
+ (node) => {
+ const box = node.getBoundingClientRect();
+ return (
+ box.left >= canvas.left - 2 &&
+ box.top >= canvas.top - 2 &&
+ box.right <= canvas.right + 2 &&
+ box.bottom <= canvas.bottom + 2
+ );
+ },
+ );
+ });
+ return { zoom, hint, allInside };
+ };
+
+ try {
+ // ---------- 桌面:总览 / 缩放 / hover ----------
+ const desktop = await browser.newContext({
+ viewport: { width: 1440, height: 960 },
+ });
+ const page = await desktop.newPage();
+ await page.goto(richUrl, { waitUntil: 'commit', timeout: 120_000 });
+ const nodeCards = page.locator('.game-lineage-node');
+ await page.waitForFunction(
+ () => document.querySelectorAll('.game-lineage-node').length >= 6,
+ undefined,
+ { timeout: 120_000 },
+ );
+ await page.waitForTimeout(900);
+
+ const apiTree = await api(
+ `/api/game-distribution/games/${nodeIds.R}/lineage`,
+ );
+ const apiNodeCount = (apiTree.data?.nodes ?? []).length;
+ const domNodeCount = await nodeCards.count();
+ check(
+ 'DOM 断言:画布节点数 = 接口返回的可见节点数',
+ domNodeCount === apiNodeCount,
+ `dom=${domNodeCount} api=${apiNodeCount}`,
+ );
+ const generations = await nodeCards.evaluateAll((nodes) =>
+ nodes.map((node) => Number(node.getAttribute('data-generation'))),
+ );
+ check(
+ 'DOM 断言:节点代际分布覆盖 0/1/2/3(多代可见)',
+ [0, 1, 2, 3].every((generation) => generations.includes(generation)),
+ `generations=${generations.sort().join(',')}`,
+ );
+
+ const anchor = page.locator('.game-lineage-node--anchor');
+ check(
+ 'DOM 断言:当前作品节点有可识别标记(--anchor + aria-current)',
+ (await anchor.count()) === 1 &&
+ (await anchor.first().getAttribute('aria-current')) === 'true' &&
+ (await anchor
+ .first()
+ .innerText()
+ .then((text) => text.includes('当前作品'))),
+ `anchorCount=${await anchor.count()}`,
+ );
+ const focusable = await nodeCards.evaluateAll((nodes) =>
+ nodes.every(
+ (node) =>
+ node.getAttribute('tabindex') === '0' &&
+ (node.getAttribute('aria-label') ?? '').length > 0,
+ ),
+ );
+ check('DOM 断言:每个节点都可聚焦且有可访问名称', focusable);
+
+ const b1Node = nodeCards.filter({ hasText: b1.title }).first();
+ const c1Node = nodeCards.filter({ hasText: c1.title }).first();
+ const rootNode = nodeCards.filter({ hasText: root.title }).first();
+ check(
+ 'DOM 断言:三个直接子分支与根都在画布上',
+ (await rootNode.count()) === 1 &&
+ (await b1Node.count()) === 1 &&
+ (await c1Node.count()) === 1 &&
+ (await nodeCards.filter({ hasText: e.title }).count()) === 1,
+ );
+
+ // 本轮返工点:节点卡片减负(只留 封面/代际/标题/作者/Fork)+ 真实封面缩略图
+ const cardText = await nodeCards.first().innerText();
+ check(
+ 'DOM 断言:节点卡片已减负(无「次游玩」、无「与原版对比」)',
+ !cardText.includes('次游玩') &&
+ (await page.locator('.game-lineage-node__compare').count()) === 0 &&
+ (await page.getByText('与原版对比').count()) === 0 &&
+ (await page.locator('.game-lineage-node__fork').count()) >= 6,
+ `cardText=${cardText.replace(/\n/gu, ' / ').slice(0, 70)}`,
+ );
+ const coverImages = await page
+ .locator('.game-lineage-node__main img')
+ .count();
+ check(
+ 'DOM 断言:节点卡片渲染真实封面缩略图(coverObjectKey 有值才渲染)',
+ coverImages >= 6,
+ `nodeCoverImages=${coverImages}`,
+ );
+ const axisVisible = await page.locator('.game-lineage-axis').count();
+ check(
+ 'DOM 断言:代际轴是独立固定层且标签未被画布裁掉',
+ axisVisible >= 1 &&
+ (await page
+ .locator('.game-lineage-axis')
+ .first()
+ .boundingBox()
+ .then((box) => Boolean(box) && box.x >= 0)),
+ `axisLayers=${axisVisible}`,
+ );
+
+ const initialFit = await fitContract(page);
+ check(
+ 'DOM 断言(桌面初访):要么真全览,要么按可读缩放下限(≥60%)显示并给出提示',
+ initialFit.allInside || (initialFit.zoom >= 60 && initialFit.hint >= 1),
+ `zoom=${initialFit.zoom}% hint=${initialFit.hint} allInside=${initialFit.allInside}`,
+ );
+
+ await takeShot(page, 'lineage-01-overview.png');
+
+ // ② zoom 到分支
+ for (let index = 0; index < 4; index += 1) {
+ await page.locator('button[aria-label="放大"]').first().click();
+ await page.waitForTimeout(150);
+ }
+ const zoomLabel = await page
+ .locator('.game-lineage-zoom')
+ .first()
+ .innerText();
+ await takeShot(page, 'lineage-02-zoom-branch.png');
+ check(
+ 'DOM 断言:放大后缩放百分比上升',
+ Number.parseInt(zoomLabel, 10) > 100,
+ `zoom=${zoomLabel}`,
+ );
+
+ // ⑦ 放大状态下 hover:连线级路径强化特写(本轮返工的核心诉求)
+ await page
+ .locator('.game-lineage-node')
+ .filter({ hasText: b1.title })
+ .first()
+ .hover();
+ await page.waitForTimeout(500);
+ const activeEdges = await page
+ .locator('.game-lineage-edge--active')
+ .count();
+ const dimmedEdges = await page
+ .locator('.game-lineage-edge--dimmed')
+ .count();
+ await takeShot(page, 'lineage-07-hover-edge-path.png');
+ check(
+ 'DOM 断言:hover 时「祖先路径的连线」被强化(--active 边 ≥1 且其余边 --dimmed)',
+ activeEdges >= 1 && dimmedEdges >= 1,
+ `activeEdges=${activeEdges} dimmedEdges=${dimmedEdges}`,
+ );
+ const strokeWidth = await page
+ .locator('.game-lineage-edge')
+ .first()
+ .evaluate((node) => getComputedStyle(node).strokeWidth);
+ const vectorEffect = await page
+ .locator('.game-lineage-edge')
+ .first()
+ .evaluate((node) => getComputedStyle(node).vectorEffect);
+ check(
+ 'DOM 断言:连线用 non-scaling-stroke(缩放后线宽仍可辨)',
+ vectorEffect === 'non-scaling-stroke',
+ `strokeWidth=${strokeWidth} vectorEffect=${vectorEffect}`,
+ );
+
+ // ⑥ 点「适应窗口」→ 全览(所有节点都落在画布内)
+ const zoomBeforeFit = Number.parseInt(
+ await page.locator('.game-lineage-zoom').first().innerText(),
+ 10,
+ );
+ await page.locator('button[aria-label="适应窗口"]').first().click();
+ await page.waitForTimeout(600);
+ const zoomAfterFit = Number.parseInt(
+ await page.locator('.game-lineage-zoom').first().innerText(),
+ 10,
+ );
+ const allInsideCanvas = await page.evaluate(() => {
+ const canvas = document
+ .querySelector('.game-lineage-canvas')
+ ?.getBoundingClientRect();
+ if (!canvas) return false;
+ return [...document.querySelectorAll('.game-lineage-node')].every(
+ (node) => {
+ const box = node.getBoundingClientRect();
+ return (
+ box.left >= canvas.left - 2 &&
+ box.top >= canvas.top - 2 &&
+ box.right <= canvas.right + 2 &&
+ box.bottom <= canvas.bottom + 2
+ );
+ },
+ );
+ });
+ await takeShot(page, 'lineage-06-fit-window.png');
+ check(
+ 'DOM 断言:点「适应窗口」后 6 个节点全部落在画布内(真正的全览)',
+ allInsideCanvas && zoomAfterFit <= zoomBeforeFit,
+ `zoom ${zoomBeforeFit}% → ${zoomAfterFit}% allInside=${allInsideCanvas}`,
+ );
+
+ // 复位视野
+ await page.reload({ waitUntil: 'commit', timeout: 120_000 });
+ await page.waitForFunction(
+ () => document.querySelectorAll('.game-lineage-node').length >= 6,
+ undefined,
+ { timeout: 120_000 },
+ );
+ await page.waitForTimeout(900);
+
+ // ③ hover 中间节点 → 祖先路径高亮、其余降透明度
+ await nodeCards.filter({ hasText: b1.title }).first().hover();
+ await page.waitForTimeout(500);
+ const dimmedTitles = await page
+ .locator('.game-lineage-node--dimmed')
+ .evaluateAll((nodes) => nodes.map((node) => node.textContent ?? ''));
+ const b1Dimmed = await nodeCards
+ .filter({ hasText: b1.title })
+ .first()
+ .getAttribute('class');
+ const rootDimmed = await nodeCards
+ .filter({ hasText: root.title })
+ .first()
+ .getAttribute('class');
+ await takeShot(page, 'lineage-03-hover-ancestor.png');
+ check(
+ 'DOM 断言:hover 中间节点 → 自身与祖先不高亮(不降透明度)',
+ !(b1Dimmed ?? '').includes('--dimmed') &&
+ !(rootDimmed ?? '').includes('--dimmed'),
+ `b1=${b1Dimmed} root=${rootDimmed}`,
+ );
+ check(
+ 'DOM 断言:hover 时同代兄弟分支降透明度',
+ dimmedTitles.some((text) => text.includes(c1.title)) &&
+ dimmedTitles.some((text) => text.includes(f1.title)),
+ `dimmed=${dimmedTitles.length} 条`,
+ );
+
+ // 点击子节点 → 跳详情页
+ await page.reload({ waitUntil: 'commit', timeout: 120_000 });
+ await page.waitForFunction(
+ () => document.querySelectorAll('.game-lineage-node').length >= 6,
+ undefined,
+ { timeout: 120_000 },
+ );
+ await page.waitForTimeout(800);
+ await nodeCards.filter({ hasText: d.title }).first().click();
+ await page.waitForTimeout(800);
+ check(
+ 'DOM 断言:点击节点跳到该作品详情页(URL 含其 id)',
+ page.url().includes(`/games/detail`) && page.url().includes(nodeIds.D),
+ `url=${page.url()}`,
+ );
+ await page.goto(richUrl, { waitUntil: 'commit', timeout: 120_000 });
+ await page.waitForFunction(
+ () => document.querySelectorAll('.game-lineage-node').length >= 6,
+ undefined,
+ { timeout: 120_000 },
+ );
+ await page.waitForTimeout(800);
+ await nodeCards.filter({ hasText: e.title }).first().focus();
+ await page.keyboard.press('Enter');
+ await page.waitForTimeout(800);
+ check(
+ 'DOM 断言:键盘 Enter 也能跳转(可聚焦 + 键盘可达)',
+ page.url().includes(nodeIds.E),
+ `url=${page.url()}`,
+ );
+
+ // ---------- 移动端窄屏 ----------
+ // 只给窄视口宽度(不模拟触摸设备):验证窄屏布局不溢出 + 画布内部可横向平移。
+ const mobile = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ });
+ const mobilePage = await mobile.newPage();
+ await mobilePage.goto(richUrl, { waitUntil: 'commit', timeout: 120_000 });
+ await mobilePage.waitForFunction(
+ () => document.querySelectorAll('.game-lineage-node').length >= 6,
+ undefined,
+ { timeout: 120_000 },
+ );
+ await mobilePage.waitForTimeout(1200);
+ // 窄屏首访会弹「欢迎/移动端仅支持作品展示」弹层(platform-mobile-home-welcome-overlay,z-90 覆盖全屏),
+ // 它会拦住画布的指针事件 —— 按真实用户路径先关掉它(按钮文案「好」)。
+ const welcomeOverlay = mobilePage.locator(
+ '.platform-mobile-home-welcome-overlay',
+ );
+ const welcomeVisible = await welcomeOverlay
+ .first()
+ .waitFor({ state: 'visible', timeout: 5_000 })
+ .then(() => true)
+ .catch(() => false);
+ if (welcomeVisible) {
+ const dismiss = mobilePage.getByRole('button', {
+ name: '好',
+ exact: true,
+ });
+ if ((await dismiss.count()) > 0) {
+ await dismiss.first().click();
+ await mobilePage.waitForTimeout(600);
+ }
+ }
+ console.log(
+ `[lineage-visual] 窄屏欢迎弹层:${welcomeVisible ? '出现过并已关闭(按钮「好」)' : '未出现'}`,
+ );
+ const overflow = await mobilePage.evaluate(() => ({
+ scrollWidth: document.documentElement.scrollWidth,
+ innerWidth: window.innerWidth,
+ bodyScrollWidth: document.body.scrollWidth,
+ }));
+ check(
+ 'DOM 断言(窄屏):页面本身不横向溢出(画布内部才平移)',
+ overflow.scrollWidth <= overflow.innerWidth + 1 &&
+ overflow.bodyScrollWidth <= overflow.innerWidth + 1,
+ JSON.stringify(overflow),
+ );
+ const narrowFit = await fitContract(mobilePage);
+ check(
+ 'DOM 断言(窄屏初访):不再一进来 35% —— 要么真全览,要么 ≥可读下限并给提示',
+ narrowFit.allInside || (narrowFit.zoom >= 60 && narrowFit.hint >= 1),
+ `zoom=${narrowFit.zoom}% hint=${narrowFit.hint} allInside=${narrowFit.allInside}`,
+ );
+ const canvasBox = await mobilePage
+ .locator('.game-lineage-canvas')
+ .first()
+ .boundingBox();
+ const contentTransform = async () => {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const value = await mobilePage
+ .locator('.game-lineage-canvas__content')
+ .first()
+ .evaluate((node) => getComputedStyle(node).transform, undefined, {
+ timeout: 8_000,
+ })
+ .catch(() => null);
+ if (value) return value;
+ await mobilePage.waitForTimeout(700);
+ }
+ return 'unavailable';
+ };
+ const transformBefore = await contentTransform();
+ // 起拖点必须在**视口内**且落在画布上:窄屏下画布底部会伸出视口(画布高 56vh+,底部在折叠线外),
+ // 之前的实现从画布底部起拖,落点在视口外,指针事件根本没送到画布上。这里取可见区域内的点,
+ // 并用 elementFromPoint 现场确认命中画布(代际轴是 pointer-events:none,事件会穿透到画布)。
+ if (canvasBox) {
+ // 在画布可见区域里找一个「命中的元素就是画布本身」的空白点:落在节点/标题上会触发节点点击
+ // (拖动后可能直接跳转到详情页),落在底部 tab 栏会被挡住。找不到空白区本身就是一条 UX 发现。
+ const dragPoint = await mobilePage.evaluate(() => {
+ const canvas = document.querySelector('.game-lineage-canvas');
+ if (!canvas) return null;
+ const box = canvas.getBoundingClientRect();
+ // 起点要求:在视口内、在画布内、不在节点卡上(避免拖动被当成节点点击而跳详情页)、
+ // 且左侧至少还有 110px 画布空间(拖 90px 不越出画布)。
+ for (const dy of [0.2, 0.35, 0.5, 0.65, 0.8]) {
+ for (const dx of [0.85, 0.7, 0.55, 0.4, 0.25]) {
+ const x = box.x + box.width * dx;
+ const y = box.y + box.height * dy;
+ if (y > window.innerHeight - 130 || y < box.y + 8) continue;
+ if (x - box.x < 110 || x > box.x + box.width - 8) continue;
+ const element = document.elementFromPoint(x, y);
+ if (!element || !canvas.contains(element)) continue;
+ if (element.closest('.game-lineage-node')) continue;
+ return {
+ x,
+ y,
+ hit: `${element.tagName}.${String(element.className).slice(0, 24)}`,
+ };
+ }
+ }
+ return {
+ x: Math.min(box.x + box.width - 12, box.x + 120),
+ y: Math.min(box.y + box.height - 24, window.innerHeight - 160),
+ hit: 'fallback',
+ };
+ });
+ const hitLabel = dragPoint ? dragPoint.hit : 'null';
+ console.log(
+ `[lineage-visual] 窄屏拖动起点=(${Math.round(dragPoint?.x ?? 0)},${Math.round(dragPoint?.y ?? 0)}) 命中=${hitLabel}`,
+ );
+ check(
+ 'DOM 断言(窄屏):存在可拖动的起点(画布内、未落在节点卡上、左侧留有拖动空间)',
+ hitLabel !== 'fallback' && hitLabel !== 'null',
+ `hit=${hitLabel} at=(${Math.round(dragPoint?.x ?? 0)},${Math.round(dragPoint?.y ?? 0)})`,
+ );
+ if (dragPoint) {
+ await mobilePage.mouse.move(dragPoint.x, dragPoint.y);
+ await mobilePage.mouse.down();
+ await mobilePage.mouse.move(dragPoint.x - 45, dragPoint.y, {
+ steps: 20,
+ });
+ await mobilePage.mouse.move(dragPoint.x - 90, dragPoint.y, {
+ steps: 20,
+ });
+ await mobilePage.mouse.up();
+ await mobilePage.waitForTimeout(500);
+ }
+ }
+ let transformAfter = await contentTransform();
+ if (transformAfter === transformBefore && canvasBox) {
+ // 兜底:换画布右上区域的可见点再试一次
+ const retryX = canvasBox.x + canvasBox.width - 30;
+ const retryY = canvasBox.y + 40;
+ await mobilePage.mouse.move(retryX, retryY);
+ await mobilePage.mouse.down();
+ await mobilePage.mouse.move(retryX - 150, retryY, { steps: 25 });
+ await mobilePage.mouse.up();
+ await mobilePage.waitForTimeout(500);
+ transformAfter = await contentTransform();
+ }
+ check(
+ 'DOM 断言(窄屏):拖拽可横向平移画布(起点命中画布 + transform 改变)',
+ transformAfter !== 'unavailable' &&
+ transformBefore !== 'unavailable' &&
+ transformAfter !== transformBefore,
+ `before=${transformBefore} after=${transformAfter}`,
+ );
+ check(
+ 'DOM 断言(窄屏):拖拽没有误跳转到详情页(拖动 ≠ 点击节点)',
+ mobilePage.url().includes('/games/lineage?id='),
+ `url=${mobilePage.url()}`,
+ );
+ await takeShot(mobilePage, 'lineage-04-mobile-narrow.png');
+
+ // ---------- 降级态 ----------
+ const degradedPage = await desktop.newPage();
+ await degradedPage.goto(degradedUrl, {
+ waitUntil: 'commit',
+ timeout: 120_000,
+ });
+ await degradedPage.waitForTimeout(2500);
+ const stubNodes = degradedPage.locator('.game-lineage-node--stub');
+ const stubCount = await stubNodes.count();
+ const degradedText = await degradedPage.locator('#root').innerText();
+ await takeShot(degradedPage, 'lineage-05-parent-unavailable.png');
+ check(
+ 'DOM 断言(降级):缺失父作品渲染为占位节点(--stub + aria-disabled)',
+ stubCount >= 1 &&
+ (await stubNodes.first().getAttribute('aria-disabled')) === 'true',
+ `stub=${stubCount}`,
+ );
+ check(
+ 'DOM 断言(降级):占位节点标题为「原作品已不可用」',
+ (await stubNodes.first().innerText()).includes('原作品已不可用'),
+ `stubText=${(await stubNodes.first().innerText()).slice(0, 60)}`,
+ );
+ check(
+ 'DOM 断言(降级):页面不出现被下架父作品的标题与作者名',
+ !degradedText.includes(degradedParent.title) &&
+ !degradedText.includes(nameA),
+ `parentTitle=${degradedParent.title}`,
+ );
+ check(
+ 'DOM 断言(降级):给出母版不可用的说明文案',
+ degradedText.includes('不可用'),
+ );
+ } finally {
+ await browser.close().catch(() => {});
+ }
+
+ console.log('');
+ console.log('截图清单(绝对路径 + 应看到什么):');
+ const expectations = [
+ '① lineage-01-overview.png:初访视野 —— 6 个节点(1 根 + 3 个 1 代 + 1 个 2 代 + 1 个 3 代)与连线、左侧**固定代际轴**(母版/第1代/第2代/第3代);节点卡只有「封面缩略图 + 代际角标 + 标题 + 作者名 + Fork」,根卡带「当前作品」;若一次装不下,则按可读缩放下限显示并出现「已按可读缩放显示…点「适应窗口」看全树」提示。',
+ '② lineage-02-zoom-branch.png:点 4 次「放大」后(141%)—— 卡片与连线都放大,连线在缩放下仍保持可辨线宽(non-scaling-stroke),**左侧代际轴标签仍完整可见**(不再被画布裁掉)。',
+ '⑦ lineage-07-hover-edge-path.png(新增,核心)—— 在放大状态下 hover 1 代 B1:B1→根的**祖先路径连线被强化**(--active),其余连线变淡(--dimmed),同时节点侧兄弟卡降透明度。这是「可视化路径」需求的直接证据。',
+ '⑥ lineage-06-fit-window.png(新增)—— 点「适应窗口」后:全树真正完整落在画布框内(6 个节点都在框内)、右侧大片空白消失(内容水平居中)、第 3 代不再贴底被裁。',
+ '③ lineage-03-hover-ancestor.png:默认视野 hover B1 —— 自身与祖先不降透明度,同代兄弟 C1/F1(及 D/E)降透明度。',
+ '④ lineage-04-mobile-narrow.png:390×844 窄屏 —— 控件折行、**页面本身不横向溢出**、底部 tab 正常;初访不再是 35%(按可读下限 + 提示),画布内可拖拽横向平移(这张是平移后的画面)。',
+ '⑤ lineage-05-parent-unavailable.png:父作品下架后 —— 缺失父作品处是占位卡「原作品已不可用/标题与作者名不对外展示」,顶部有「母版已不可用…」notice,标题回退为「创作族谱」,全文不出现被下架父作品的标题与作者名。',
+ ];
+ console.log(`截图目录=${shotDir}`);
+ for (const line of expectations) console.log(` ${line}`);
+
+ console.log(
+ `[lineage-visual] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures}`,
+ );
+ if (failures > 0) process.exitCode = 1;
+}
+
+main().catch((error) => {
+ console.error(`[lineage-visual] 未捕获异常:${error?.stack ?? error}`);
+ process.exitCode = 1;
+});