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; +});