fa9ef9037f
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / Repository checks (pull_request) Failing after 37s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m27s
Project CI / Frontend tests (pull_request) Successful in 3m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m25s
Project CI / Native shell tests (pull_request) Successful in 9m12s
三个发布 helper 补衍生作品必填 changeSummary(theme 脚本那 6 行是另一 session 已写好的,随本次一并提交);lineage 新增衍生作品 PUT 提升被拒 409 FORK_AUTHORIZATION_INHERITED 且档位不变、入口禁用态与未登录文案断言;新增贡献归集段(不变式 + 三链路传递 + 非作者 403 + 匿名 401)
1239 lines
45 KiB
JavaScript
1239 lines
45 KiB
JavaScript
// 族谱可视化(节点-连线树)的截图与 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',
|
||
`<!doctype html><html><head><meta charset="utf-8"><title>${marker}</title></head><body><h1>${marker}</h1></body></html>`,
|
||
);
|
||
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');
|
||
}
|
||
|
||
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 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;
|
||
});
|