// 族谱可视化(节点-连线树)的截图与 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, changeSummary, }) { 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, // 衍生作品必填「本次核心改动说明」;根节点不传(传了也会被忽略)。 ...(changeSummary ? { changeSummary } : {}), }, }); 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, // 衍生节点(有父)必填核心改动说明:截图里的「每一代差异」用它渲染。 ...(parent ? { changeSummary: `视觉夹具:节点 ${label} 相对 ${parent.label} 的核心改动说明`, } : {}), }); const detail = await publicDetail(gameId); check( `造树:节点 ${label} 已公开`, detail.data?.status === 'published', `status=${detail.data?.status ?? '∅'}`, ); // 新语义:衍生作品在创建时**继承**父作品档位(创建后忽略请求里的档位、母版才允许 PUT 提升)。 if (parent) { const childView = await ownerGame(owner.token, gameId); const parentView = await ownerGame(parent.token, parent.gameId); const childTier = childView?.forkAuthorization ?? null; const parentTier = parentView?.forkAuthorization ?? null; check( `造树:衍生节点 ${label} 继承父档位(${childTier} === ${parentTier})`, childTier !== null && childTier === parentTier, `child=${childTier} parent=${parentTier}`, ); } else 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'); } /// 窄屏三档核验(360 / 390 / 414):移动端形态(大纲缩进树 / 图谱画布 / bottom sheet / 面包屑)、 /// 触控尺寸与横向溢出,以及**本轮新豁免**:深链进 `/games/lineage?id=…` 首屏不再被 /// 「移动端仅支持作品展示」欢迎弹窗盖住。 /// /// 契约来源(读实现确认,按符号引用): /// - 移动端形态与默认值:`src/components/game-distribution/GameLineagePage.tsx` 的 `viewMode` / /// `effectiveViewMode`(`isMobileViewport ? 'outline' : 'canvas'`) /// - 大纲树:`src/components/game-distribution/LineageOutlineList.tsx` 的 `lineage-outline` / /// `lineage-outline__row` / `lineage-outline__main` / `lineage-outline__guide` /// - 底部 sheet:`game-lineage-panel--sheet` + `game-lineage-sheet-backdrop`(点遮罩关闭) /// - 图谱视图切换:`button[aria-pressed]` 文案「图谱视图」 /// - 面包屑:`game-lineage-breadcrumb__crumb`(`scrollIntoView` 由实现保证) async function captureNarrowBreakpoints({ browser, lineageUrl, shotDir }) { const widths = (process.env.E2E_CAPTURE_NARROW_WIDTHS ?? '') .split(',') .map((entry) => Number.parseInt(entry.trim(), 10)) .filter((value) => Number.isInteger(value) && value >= 320); if (widths.length === 0) return; for (const width of widths) { const context = await browser.newContext({ viewport: { width, height: 844 }, hasTouch: true, permissions: ['clipboard-read', 'clipboard-write'], }); const page = await context.newPage(); const take = async (name) => { const file = path.join(shotDir, `narrow-${width}-${name}.png`); await page.screenshot({ path: file, fullPage: false }); console.log(`[lineage-visual] 截图 ${file}`); return file; }; try { await page.goto(lineageUrl, { waitUntil: 'commit', timeout: 120_000 }); await page.waitForFunction( () => document.querySelector('.lineage-outline') !== null || document.querySelector('.game-lineage-canvas') !== null, undefined, { timeout: 120_000 }, ); await page.waitForTimeout(1500); const welcome = await page.evaluate(() => ({ text: document.body.innerText.includes('移动端仅支持作品展示'), node: document.querySelectorAll( '[class*="platform-mobile-home-welcome"]', ).length, })); check( `${width}px:深链进族谱页不再被「移动端仅支持作品展示」弹窗盖住`, !welcome.text && welcome.node === 0, `text=${welcome.text} nodes=${welcome.node}`, ); const layout = await page.evaluate(() => { const rows = [...document.querySelectorAll('.lineage-outline__row')]; const mains = rows .map((row) => row.querySelector('.lineage-outline__main')) .filter(Boolean); const rects = mains.map((el) => el.getBoundingClientRect()); const outer = document.querySelector('.lineage-outline'); const outerRight = outer ? outer.getBoundingClientRect().right : window.innerWidth; return { outline: document.querySelectorAll('.lineage-outline').length, rows: rows.length, guides: document.querySelectorAll('.lineage-outline__guide').length, minRowHeight: rows.length ? Math.min( ...rows.map((el) => Math.round(el.getBoundingClientRect().height), ), ) : 0, firstIndent: mains.length ? getComputedStyle(mains[0]).paddingLeft : '', lastIndent: mains.length ? getComputedStyle(mains[mains.length - 1]).paddingLeft : '', // 缩进的真实载体是「每一代多一列导线」:比较各行内容的左边界更有意义(padding 不变)。 firstLeft: rects.length ? Math.round(rects[0].left) : 0, lastLeft: rects.length ? Math.round(rects[rects.length - 1].left) : 0, distinctLefts: new Set(rects.map((box) => Math.round(box.left))).size, overflowingRows: rects.filter((box) => box.right > outerRight + 1) .length, docOverflow: document.documentElement.scrollWidth - window.innerWidth, }; }); check( `${width}px:默认是大纲式缩进树(有行、有导线、有缩进层级)`, layout.outline === 1 && layout.rows >= 2 && layout.guides >= 1 && layout.distinctLefts >= 2 && layout.lastLeft - layout.firstLeft >= 8, `rows=${layout.rows} guides=${layout.guides} leftOrders=${layout.firstLeft}→${layout.lastLeft} levels=${layout.distinctLefts}`, ); check( `${width}px:大纲行高 ≥44(触控友好)且长标题不横向溢出`, layout.minRowHeight >= 44 && layout.overflowingRows === 0 && layout.docOverflow <= 1, `minRowHeight=${layout.minRowHeight} overflowingRows=${layout.overflowingRows} docOverflow=${layout.docOverflow}`, ); await take('outline'); // 点第一行 → bottom sheet await page .locator('.lineage-outline__row .lineage-outline__main') .first() .click(); await page.waitForTimeout(1200); const sheet = await page.evaluate(() => { const panel = document.querySelector('.game-lineage-panel--sheet'); const backdrop = document.querySelector('.game-lineage-sheet-backdrop'); if (!panel) return null; const box = panel.getBoundingClientRect(); return { bottomGap: Math.round(window.innerHeight - box.bottom), height: Math.round(box.height), hasBackdrop: Boolean(backdrop), }; }); check( `${width}px:点节点弹出底部 sheet(贴底、有遮罩)`, Boolean(sheet) && sheet.bottomGap <= 6 && sheet.hasBackdrop, JSON.stringify(sheet), ); await take('sheet'); // 关闭 sheet:先试显式的「关闭信息面板」按钮(遮罩就是它),再试 Esc。 const closeButton = page .getByRole('button', { name: '关闭信息面板' }) .first(); let sheetClosed = false; if ((await closeButton.count()) > 0) { await closeButton.click({ force: true }).catch(() => {}); await page.waitForTimeout(600); sheetClosed = (await page.locator('.game-lineage-panel--sheet').count()) === 0; } if (!sheetClosed) { await page.keyboard.press('Escape').catch(() => {}); await page.waitForTimeout(400); sheetClosed = (await page.locator('.game-lineage-panel--sheet').count()) === 0; } check(`${width}px:底部 sheet 可关闭(遮罩按钮或 Esc)`, sheetClosed); // 面包屑(锁定路径):窄屏上「锁定」由大纲行触发(画布卡片在窄屏是紧凑卡片, // 整个卡片体都是「打开详情」按钮,点它只会跳详情——见下方图谱视图段的备注), // 所以这里在大纲形态下再点一行,断言面包屑出现且当前项已被滚进视口。 await page .locator('.lineage-outline__row .lineage-outline__main') .first() .click(); await page.waitForTimeout(1200); const outlineCrumb = await page.evaluate(() => { const items = [ ...document.querySelectorAll('.game-lineage-breadcrumb__crumb'), ]; if (items.length === 0) return null; const box = items[items.length - 1].getBoundingClientRect(); return { count: items.length, insideViewport: box.left >= -1 && box.right <= window.innerWidth + 1, left: Math.round(box.left), right: Math.round(box.right), innerWidth: window.innerWidth, }; }); check( `${width}px:大纲行锁定后面包屑出现且当前项被滚进视口`, Boolean(outlineCrumb) && outlineCrumb.count >= 1 && outlineCrumb.insideViewport, JSON.stringify(outlineCrumb), ); await take('breadcrumb'); // 画布检查前重载一次:保证不残留 sheet 与滚动位置,切形态的判断才干净。 await page.reload({ waitUntil: 'commit', timeout: 120_000 }); await page.waitForFunction( () => document.querySelector('.lineage-outline') !== null || document.querySelector('.game-lineage-canvas') !== null, undefined, { timeout: 120_000 }, ); await page.waitForTimeout(1500); await page.evaluate(() => window.scrollTo(0, 0)); // 切图谱视图:画布初始定位应把「当前作品」放进画布 const toggle = page.getByText('图谱视图', { exact: true }).first(); if ((await toggle.count()) > 0) { await toggle.scrollIntoViewIfNeeded().catch(() => {}); await toggle.click({ timeout: 20000 }).catch(async () => { await toggle.click({ force: true, timeout: 20000 }).catch(() => {}); }); await page.waitForTimeout(2500); const canvas = await page.evaluate(() => { const canvasEl = document.querySelector('.game-lineage-canvas'); const anchor = document.querySelector('.game-lineage-node--anchor'); if (!canvasEl) return null; const canvasBox = canvasEl.getBoundingClientRect(); const anchorBox = anchor?.getBoundingClientRect(); const visibleNodes = [ ...document.querySelectorAll('.game-lineage-node'), ].filter((node) => { const box = node.getBoundingClientRect(); return ( box.left >= canvasBox.left - 2 && box.right <= canvasBox.right + 2 && box.top >= canvasBox.top - 2 && box.bottom <= canvasBox.bottom + 2 ); }).length; const totalNodes = document.querySelectorAll('.game-lineage-node').length; return { anchorFound: Boolean(anchor), anchorInside: Boolean(anchorBox) && anchorBox.left >= canvasBox.left - 2 && anchorBox.right <= canvasBox.right + 2 && anchorBox.top >= canvasBox.top - 2 && anchorBox.bottom <= canvasBox.bottom + 2, visibleNodes, totalNodes, zoom: document .querySelector('.game-lineage-zoom') ?.textContent?.trim(), hint: document.body.innerText.includes('已按可读缩放显示'), }; }); check( `${width}px:图谱视图(横向画布)首屏把当前作品放进画布`, Boolean(canvas) && canvas.anchorFound && canvas.anchorInside, JSON.stringify(canvas), ); await take('canvas'); console.log( `[narrow ${width}] 备注:图谱视图里点卡片会跳详情(窄屏紧凑卡片整块是「打开详情」按钮,没有可锁定区域)——信息面板/面包屑改由大纲形态验证。`, ); } } finally { await context.close(); } } } /// 「只截图、不造树」模式:给已有作品(例如 scripts/seed-game-distribution-co-creation.mjs 灌的共创网络) /// 出验收截图。设 `E2E_CAPTURE_LINEAGE_URL` 即进入该模式——不注册账号、不建作品、不下架,纯 GET + 截图。 async function captureExistingUrls() { const lineageUrl = (process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim(); const themeUrl = (process.env.E2E_CAPTURE_THEME_URL ?? '').trim(); const selectTitle = (process.env.E2E_CAPTURE_SELECT_TITLE ?? '').trim(); // 最小节点数:两节点的子树(例如「母版 → 一个衍生」)也要能截,默认 3。 const minNodes = Number.parseInt((process.env.E2E_CAPTURE_MIN_NODES ?? '').trim(), 10) || 3; // 视口可配:全树总览常常比 960 高,给个高的窗口才能真的「一眼看全」。 const sizeMatch = /^(\d{3,4})x(\d{3,4})$/u.exec( (process.env.E2E_CAPTURE_WINDOW ?? '').trim(), ); const desktopViewport = sizeMatch ? { width: Number(sizeMatch[1]), height: Number(sizeMatch[2]) } : { width: 1440, height: 960 }; 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-co-creation-visual-'), ); const shots = []; const take = async (target, name) => { const file = path.join(shotDir, name); await target.screenshot({ path: file, fullPage: false }); shots.push(file); console.log(`[lineage-visual] 截图 ${file}`); return file; }; try { // 封面是远端资源:等所有 落地再截,否则会出现灰块假象。 const waitImages = async (target) => { await target .waitForFunction( () => [...document.images].every( (img) => img.complete && img.naturalWidth > 0, ), undefined, { timeout: 60_000 }, ) .catch(() => {}); await target.waitForTimeout(600); }; // ---------- 桌面:全树总览 ---------- const desktop = await browser.newContext({ viewport: desktopViewport, }); const page = await desktop.newPage(); await page.goto(lineageUrl, { waitUntil: 'commit', timeout: 120_000 }); await page.waitForFunction( (min) => document.querySelectorAll('.game-lineage-node').length >= min, minNodes, { timeout: 120_000 }, ); await page.waitForTimeout(1400); await waitImages(page); const nodeCount = await page.locator('.game-lineage-node').count(); check( '截图模式:/games/lineage 已渲染多代节点', nodeCount >= minNodes, `nodes=${nodeCount} url=${lineageUrl}`, ); // 首屏视野契约(与 lineage 工具同口径):要么真的全览,要么按可读缩放下限 + 提示。 const fit = await page.evaluate(() => { const canvas = document .querySelector('.game-lineage-canvas') ?.getBoundingClientRect(); if (!canvas) return { allInside: false, hint: false }; const allInside = [ ...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 { allInside, hint: [...document.querySelectorAll('*')].some((element) => (element.textContent ?? '').includes('已按可读缩放显示'), ), }; }); check( '截图模式:首屏要么全树在画布内,要么给出「已按可读缩放显示」提示', fit.allInside || fit.hint, `allInside=${fit.allInside} hint=${fit.hint} 视口=${desktopViewport.width}x${desktopViewport.height}`, ); await take(page, '01-lineage-overview.png'); // ---------- 桌面:选中态(信息面板 + 面包屑) ---------- // 注意:卡片主体是「打开详情」按钮(stopPropagation),点它会跳走;要「选中/锁定」得点卡片**内边距**(6,6)。 const card = selectTitle ? page .locator('.game-lineage-node') .filter({ hasText: selectTitle }) .first() : page.locator('.game-lineage-node').nth(1); let selected = false; if ((await card.count()) > 0) { await card.click({ position: { x: 6, y: 6 } }); selected = true; } else { await page .locator('.game-lineage-node') .nth(1) .click({ position: { x: 6, y: 6 } }); selected = true; } if (selected) { await page .locator('.game-lineage-panel') .first() .waitFor({ state: 'visible', timeout: 30_000 }); await page.waitForTimeout(700); const crumbs = await page .locator('.game-lineage-breadcrumb__crumb') .count(); check( '截图模式:选中节点后出现信息面板与面包屑', crumbs >= 1, `面包屑=${crumbs}`, ); await waitImages(page); await take(page, '02-lineage-selected.png'); } // ---------- 窄屏 ---------- // 移动端自 2026-10-07 起默认是**大纲式缩进树**(`lineage-outline`),不再默认渲染画布节点; // 这里两种形态都接受,只断言「有可交互节点 + 页面不横向溢出」。 const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, }); const mobilePage = await mobile.newPage(); await mobilePage.goto(lineageUrl, { waitUntil: 'commit', timeout: 120_000, }); await mobilePage.waitForFunction( (min) => document.querySelectorAll('.game-lineage-node').length >= min || document.querySelectorAll('.lineage-outline__row').length >= min, minNodes, { timeout: 120_000 }, ); await mobilePage.waitForTimeout(1400); // 窄屏首次进入可能弹「移动端仅支持作品展示」的欢迎弹窗(族谱深链已豁免,这里兜底): // 先点掉再截图,否则截出来只剩一个遮罩弹窗。 const welcomeOk = mobilePage.getByRole('button', { name: '好', exact: true, }); if ((await welcomeOk.count()) > 0) { await welcomeOk.first().click(); await mobilePage.waitForTimeout(800); } await waitImages(mobilePage); const narrow = await mobilePage.evaluate(() => ({ canvas: Boolean(document.querySelector('.game-lineage-canvas')), outline: document.querySelectorAll('.lineage-outline__row').length, nodes: document.querySelectorAll('.game-lineage-node').length, overflow: document.documentElement.scrollWidth - window.innerWidth > 1, })); check( '截图模式:窄屏下族谱有可交互节点(大纲或画布)且页面不横向溢出', (narrow.outline >= minNodes || narrow.nodes >= minNodes) && !narrow.overflow, `outline=${narrow.outline} nodes=${narrow.nodes} canvas=${narrow.canvas} overflow=${narrow.overflow}`, ); await take(mobilePage, '03-lineage-narrow.png'); await mobile.close(); // ---------- 主题页 ---------- if (themeUrl) { const themePage = await desktop.newPage(); await themePage.goto(themeUrl, { waitUntil: 'commit', timeout: 120_000 }); await themePage .locator('.game-theme-root') .first() .waitFor({ state: 'visible', timeout: 60_000 }); await themePage.waitForTimeout(1000); await waitImages(themePage); const roots = await themePage.locator('.game-theme-root').count(); check('截图模式:主题页已渲染作品树', roots >= 1, `roots=${roots}`); await take(themePage, '04-theme-page.png'); void themePage; } await desktop.close(); await captureNarrowBreakpoints({ browser, lineageUrl, shotDir }); console.log(`[lineage-visual] 截图目录=${shotDir}`); console.log(`[lineage-visual] 结果:check=${checks} fail=${failures}`); } finally { await browser.close(); } return shots; } async function main() { if ((process.env.E2E_CAPTURE_LINEAGE_URL ?? '').trim()) { await captureExistingUrls(); const failed = failures > 0; console.log( `[lineage-visual] 只用现有作品截图:check=${checks} fail=${failures}`, ); process.exit(failed ? 1 : 0); } 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 accessibleNames = await nodeCards.evaluateAll((nodes) => nodes.every((node) => (node.getAttribute('aria-label') ?? '').length > 0), ); check( 'DOM 断言:每个节点卡片都有可访问名称(aria-label)', accessibleNames, ); const openButtons = await page.locator('.game-lineage-node__open').count(); check( 'DOM 断言:每个可交互节点都有可聚焦的「打开《…》详情」主行动作(占位节点除外)', openButtons >= 6, `openButtons=${openButtons}`, ); 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__cover 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); // 关键字可达:主行动作(打开《…》详情)聚焦后按 Enter 应跳转(卡片主体现在=选中,不再跳详情)。 const openE = page.locator( `.game-lineage-node__open[aria-label="打开《${e.title}》详情"]`, ); await openE.first().focus(); await page.keyboard.press('Enter'); await page.waitForTimeout(800); check( 'DOM 断言:键盘 Enter(在「打开详情」主行动作上)能跳转', page.url().includes(nodeIds.E), `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(900); const b1CardForSelect = page .locator('.game-lineage-node') .filter({ hasText: b1.title }) .first(); await b1CardForSelect.click({ position: { x: 6, y: 6 } }); await page.waitForTimeout(700); const breadcrumb = page.locator('.game-lineage-breadcrumb'); const crumbText = await breadcrumb .first() .innerText() .catch(() => ''); const selectedDimmed = await page .locator('.game-lineage-node--dimmed') .count(); const selectedActiveEdges = await page .locator('.game-lineage-edge--active') .count(); check( 'DOM 断言(新):点击卡片主体 → 出现面包屑「已选中的路径」且包含根与当前节点', (await breadcrumb.count()) >= 1 && crumbText.includes(root.title) && crumbText.includes(b1.title), `crumb=${crumbText.replace(/\n+/gu, ' › ').slice(0, 70)}`, ); check( 'DOM 断言(新):点击后路径持续高亮(--active 边 ≥1 且其余节点降透明度)', selectedActiveEdges >= 1 && selectedDimmed >= 1, `activeEdges=${selectedActiveEdges} dimmed=${selectedDimmed}`, ); await page.mouse.move(5, 5); await page.waitForTimeout(500); const dimmedAfterHoverOut = await page .locator('.game-lineage-node--dimmed') .count(); check( 'DOM 断言(新):鼠标移开后选中路径仍保持(不是 hover 一次性效果)', dimmedAfterHoverOut >= 1, `dimmed=${dimmedAfterHoverOut}`, ); check( 'DOM 断言(新):卡片主体点击不会跳走详情页', page.url().includes('/games/lineage'), `url=${page.url()}`, ); await takeShot(page, 'lineage-08-selected-path.png'); // ---------- 移动端窄屏 ---------- // 只给窄视口宽度(不模拟触摸设备):验证窄屏布局不溢出 + 画布内部可横向平移。 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-08-selected-path.png(新增)—— 点击 1 代卡片主体(非标题/按钮)后:路径被「选中」并**保持**高亮(祖先连线加粗、其余节点降透明度),鼠标移开也不消失,顶部出现面包屑「已选中的路径」(含根 → 当前节点);页面不跳转。', '④ 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; });