Files
Genarrative/src/components/game-distribution/GameDistributionPages.test.tsx
T

5614 lines
201 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import { readFileSync } from 'node:fs';
import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components';
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
GameDistributionPlaySessionResponse,
GameDistributionVersionStatus,
} from '../../../packages/shared/src/contracts/gameDistribution';
import { ApiClientError } from '../../services/apiClient';
import type {
GameDistributionGame,
GameDistributionGameWithCollection,
GameDistributionLineageNode,
GameDistributionMyGame,
GameDistributionVersionStatusEntry,
} from '../../services/gameDistributionClient';
import {
normalizePlatformToast,
PLATFORM_TOAST_DEFAULT_DURATION_MS,
showPlatformToast,
} from '../common/platformToast';
import { PlatformToastHost } from '../common/PlatformToastHost';
import { setCreatorViewer } from '../creator/creatorRelationshipState';
import { GameComparePage } from './GameComparePage';
import { GameDetailPage } from './GameDetailPage';
import { FORK_LAUNCH_FALLBACK_DELAY_MS } from './gameForkProtocol';
import { GameGalleryPage } from './GameGalleryPage';
import { GameLineagePage } from './GameLineagePage';
import { GamePlayPage } from './GamePlayPage';
import { GameThemePage } from './GameThemePage';
import { GameThemesPage } from './GameThemesPage';
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
import { LINEAGE_COVER_ASPECT } from './lineageTreeLayout';
const listGamesMock = vi.hoisted(() => vi.fn());
const setGameCollectionMock = vi.hoisted(() => vi.fn());
const listThemesMock = vi.hoisted(() => vi.fn());
const getThemeMock = vi.hoisted(() => vi.fn());
const getGameMock = vi.hoisted(() => vi.fn());
const getGameLineageMock = vi.hoisted(() => vi.fn());
const getDerivedGamesMock = vi.hoisted(() => vi.fn());
const getMyGameMock = vi.hoisted(() => vi.fn());
const loadVersionRecoveryActionsMock = vi.hoisted(() => vi.fn());
const recordGamePlayMock = vi.hoisted(() => vi.fn());
const resolvedAssetUrlMock = vi.hoisted(() => vi.fn());
const listGameReviewsMock = vi.hoisted(() => vi.fn());
const getMyGameReviewMock = vi.hoisted(() => vi.fn());
const purchaseGameMock = vi.hoisted(() => vi.fn());
const createGamePlaySessionMock = vi.hoisted(() => vi.fn());
const getPublicAuthUserByIdMock = vi.hoisted(() => vi.fn());
const getCreatorRelationshipMock = vi.hoisted(() => vi.fn());
const changeCreatorRelationshipMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/authService', async (original) => ({
...(await original<typeof import('../../services/authService')>()),
getPublicAuthUserById: getPublicAuthUserByIdMock,
}));
vi.mock('../../services/creatorProfileClient', async (original) => ({
...(await original<typeof import('../../services/creatorProfileClient')>()),
getCreatorRelationship: getCreatorRelationshipMock,
changeCreatorRelationship: changeCreatorRelationshipMock,
}));
vi.mock('../../services/gameDistributionClient', () => ({
listGames: listGamesMock,
setGameCollection: setGameCollectionMock,
listThemes: listThemesMock,
getTheme: getThemeMock,
getGame: getGameMock,
getGameLineage: getGameLineageMock,
getDerivedGames: getDerivedGamesMock,
getMyGame: getMyGameMock,
loadVersionRecoveryActions: loadVersionRecoveryActionsMock,
recordGamePlay: recordGamePlayMock,
listGameReviews: listGameReviewsMock,
getMyGameReview: getMyGameReviewMock,
purchaseGame: purchaseGameMock,
createGamePlaySession: createGamePlaySessionMock,
}));
// 详情页购买后要刷新平台钱包;这里只提供选择器需要的状态与刷新钩子,避免真实取余额请求。
const walletMocks = vi.hoisted(() => ({
ownerUserId: 'author-1',
mudPointBalance: { totalPoints: 500, permanentPoints: 500, limitedPoints: 0 },
legacyWalletBalance: 500,
onWalletBalanceMayHaveChanged: vi.fn(async () => {}),
}));
vi.mock('../../stores/usePlatformWalletStore', () => ({
usePlatformWalletStore: <T,>(selector: (state: typeof walletMocks) => T) =>
selector(walletMocks),
}));
/**
* 详情页的登录态:默认一个可访问受保护数据的用户(作者视角评价区要用)。
* 「未登录」用例会把它改成 null 再断言登录门禁行为;openLoginModal 用同一实例便于断言。
*/
const authUiMock = vi.hoisted(() => ({
value: {
user: { id: 'author-1' } as { id: string } | null,
canAccessProtectedData: true,
openLoginModal: vi.fn(),
},
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => authUiMock.value,
}));
/** 改造入口的复制走统一的剪贴板服务;这里只断言「复制了什么」。 */
const copyTextToClipboardMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: copyTextToClipboardMock,
}));
/** 开始共创唤起:只打桩「发出唤起」这一动作,协议常量仍用真实值(测试按同一阈值推进定时器)。 */
const openForkProtocolMock = vi.hoisted(() => vi.fn());
const requestClientDownloadMock = vi.hoisted(() => vi.fn());
vi.mock('../creation-home/clientDownloadLauncher', () => ({
requestClientDownload: requestClientDownloadMock,
subscribeClientDownloadRequest: () => () => {},
}));
vi.mock('./gameForkProtocol', async (original) => ({
...(await original<typeof import('./gameForkProtocol')>()),
openForkProtocol: openForkProtocolMock,
}));
// 只替换换签结果,测试不发起任何真实请求;返回空串即代表签名失败或字段为空。
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (
source: string | null | undefined,
options: { objectKey?: string | null } = {},
) => {
const objectKey = (options.objectKey ?? '').trim();
return {
resolvedUrl: resolvedAssetUrlMock(objectKey || (source ?? '').trim()),
isResolving: false,
shouldResolve: Boolean(objectKey || (source ?? '').trim()),
};
},
}));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
function createGame(
overrides: Partial<GameDistributionGameWithCollection> = {},
): GameDistributionGameWithCollection {
return {
id: 'game-1',
title: '星轨防线',
summary: '守住最后一条航线。',
description: '一款测试游戏。',
category: '动作',
tags: ['测试'],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'author-1', name: '测试作者' },
deviceSupport: { desktop: true, mobile: true, touch: true },
status: 'published',
publicationRevision: 1,
currentVersion: {
id: 'version-1',
version: '1.0.0',
entryUrl: 'https://play.example.test/releases/version-1/index.html',
sha256: 'sha256',
publishedAt: '2026-09-18T00:00:00.000Z',
controls: ['键盘'],
},
playCount: 12,
createdAt: '2026-09-18T00:00:00.000Z',
priceMudPoints: 0,
purchased: true,
...overrides,
};
}
function setViewportWidth(width: number) {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: width,
writable: true,
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: () => ({
addEventListener: vi.fn(),
matches: width <= 820,
removeEventListener: vi.fn(),
}),
writable: true,
});
}
afterEach(() => {
vi.useRealTimers();
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
authUiMock.value.user = { id: 'author-1' };
authUiMock.value.canAccessProtectedData = true;
authUiMock.value.openLoginModal.mockReset();
copyTextToClipboardMock.mockReset();
copyTextToClipboardMock.mockResolvedValue(true);
openForkProtocolMock.mockReset();
requestClientDownloadMock.mockReset();
setGameCollectionMock.mockReset();
setGameCollectionMock.mockResolvedValue({ collected: true });
listThemesMock.mockReset();
listThemesMock.mockResolvedValue({ themes: [], nextCursor: null });
getThemeMock.mockReset();
getThemeMock.mockResolvedValue(null);
setCreatorViewer(null);
getCreatorRelationshipMock.mockReset();
getCreatorRelationshipMock.mockResolvedValue({
relationship: { isSelf: false, isFollowing: false, isFollowedBy: false },
});
changeCreatorRelationshipMock.mockReset();
changeCreatorRelationshipMock.mockResolvedValue({
relationship: { isSelf: false, isFollowing: true, isFollowedBy: false },
});
setViewportWidth(1024);
listGamesMock.mockReset();
listGamesMock.mockResolvedValue({ games: [], nextCursor: null });
getGameMock.mockReset();
getGameMock.mockResolvedValue(createGame());
getGameLineageMock.mockReset();
getGameLineageMock.mockResolvedValue(null);
getDerivedGamesMock.mockReset();
getDerivedGamesMock.mockResolvedValue(null);
getMyGameMock.mockReset();
getMyGameMock.mockResolvedValue(null);
loadVersionRecoveryActionsMock.mockResolvedValue({});
listGameReviewsMock.mockReset();
listGameReviewsMock.mockResolvedValue({
reviews: [],
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
ratingSummary: { averageScore: null, ratingCount: 0 },
});
getMyGameReviewMock.mockReset();
getMyGameReviewMock.mockResolvedValue({ review: null });
recordGamePlayMock.mockReset();
recordGamePlayMock.mockResolvedValue(undefined);
purchaseGameMock.mockReset();
createGamePlaySessionMock.mockReset();
walletMocks.onWalletBalanceMayHaveChanged.mockClear();
resolvedAssetUrlMock.mockImplementation((objectKey: string) =>
objectKey ? `https://assets.example.test/${objectKey}` : '',
);
});
describe('GameGalleryPage', () => {
it.each([
[{ averageScore: 8.2, ratingCount: 26 }, '8.2/10 · 26 人评分'],
[{ averageScore: 9, ratingCount: 1 }, '9.0/10 · 1 人评分'],
[{ averageScore: null, ratingCount: 0 }, '暂无评分'],
[undefined, '评分暂不可用'],
])('显示列表返回的评分摘要 %#', async (ratingSummary, expected) => {
listGamesMock.mockResolvedValueOnce({
games: [createGame({ ratingSummary })],
nextCursor: null,
});
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
expect(await screen.findByText(expected)).toBeTruthy();
});
it('游标分页:加载更多按 gameId 去重追加,末页隐藏入口', async () => {
const firstPage = Array.from({ length: 48 }, (_, index) =>
createGame({ id: `game-${index + 1}`, title: `目录第 ${index + 1} 款` }),
);
// 第二页故意含一条与本页重复的 id(服务端稳定序下不该发生,但客户端必须去重)+ 两条新的。
const secondPage = [
createGame({ id: 'game-48', title: '目录第 48 款' }),
createGame({ id: 'game-49', title: '目录第 49 款' }),
createGame({ id: 'game-50', title: '目录第 50 款' }),
];
listGamesMock
.mockResolvedValueOnce({ games: firstPage, nextCursor: 'c1' })
.mockResolvedValueOnce({ games: secondPage, nextCursor: null });
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
// 首屏 48 条(服务端默认页大小)+ 「加载更多」入口:不再是「永远只有 48 条」。
await screen.findByText('目录第 48 款');
const grid = document.querySelectorAll('.game-grid > *');
expect(grid.length).toBe(48);
// 第一次请求不带游标(缺省页大小交给服务端)。
expect(listGamesMock.mock.calls[0]?.[0]).toEqual({
keyword: '',
category: '全部',
});
fireEvent.click(screen.getByRole('button', { name: '加载更多' }));
await screen.findByText('目录第 50 款');
// 第二次请求原样回传服务端游标(不自己拼)。
expect(listGamesMock.mock.calls[1]?.[0]).toEqual({
keyword: '',
category: '全部',
cursor: 'c1',
});
// 追加 + 去重:48 + 2(重复的 game-48 只留一份)。
expect(document.querySelectorAll('.game-grid > *').length).toBe(50);
expect(screen.getAllByText('目录第 48 款')).toHaveLength(1);
expect(screen.queryByText('目录第 51 款')).toBeNull();
// 末页 nextCursor = null:入口消失。
expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull();
});
it('加载更多失败:保留已加载内容 + 可读提示,重试成功后追加', async () => {
listGamesMock
.mockResolvedValueOnce({
games: [createGame({ id: 'game-1', title: '第一页作品' })],
nextCursor: 'c1',
})
.mockRejectedValueOnce(new Error('网络开小差了'))
.mockResolvedValueOnce({
games: [createGame({ id: 'game-2', title: '第二页作品' })],
nextCursor: null,
});
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
await screen.findByText('第一页作品');
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
// 失败:已加载内容**不清空**,提示可读,入口还在(可重试)。
expect(await screen.findByText('网络开小差了')).toBeTruthy();
expect(screen.getByText('第一页作品')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '加载更多' }));
await screen.findByText('第二页作品');
expect(screen.getByText('第一页作品')).toBeTruthy();
expect(screen.queryByText('网络开小差了')).toBeNull();
expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull();
});
it('窄屏同样有「加载更多」且不横向溢出', async () => {
setViewportWidth(390);
listGamesMock.mockResolvedValueOnce({
games: [createGame({ id: 'game-1', title: '窄屏第一页' })],
nextCursor: 'c1',
});
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
expect(await screen.findByText('窄屏第一页')).toBeTruthy();
const more = screen.getByRole('button', { name: '加载更多' });
expect(more).toBeTruthy();
// 入口在页面容器内、不是浮层:容器宽度受约束(jsdom 无量宽,这里钉结构与样式契约)。
expect(more.closest('.game-empty-actions')).toBeTruthy();
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
expect(css).toMatch(
/\.game-empty-actions \{[\s\S]{0,300}?flex-wrap: wrap;/u,
);
});
it('scope=forkable(共创页):请求带 forkable、渲染游戏卡片而非主题卡、无主题入口', async () => {
listGamesMock.mockResolvedValueOnce({
games: [
createGame({
id: 'game-forkable',
title: '可以共创的作品',
forkAuthorization: 'full',
}),
],
nextCursor: null,
});
render(<GameGalleryPage scope="forkable" onOpenDetail={vi.fn()} />);
// ① 请求带 forkable(服务端未上线时参数会被忽略,客户端还有同义兜底过滤)。
await screen.findByText('可以共创的作品');
expect(listGamesMock.mock.calls[0]?.[0]).toEqual({
keyword: '',
category: '全部',
forkable: true,
});
// ② 卡片来自广场同一套组件(`.game-grid` + 卡片),不是主题卡。
expect(document.querySelectorAll('.game-grid > *').length).toBe(1);
expect(document.querySelector('.game-themes-page')).toBeNull();
expect(screen.queryByText(/棵作品树/u)).toBeNull();
// ③ 「游戏 / 共创」子页签已删除(共创升为侧边栏顶层入口):
// 本页不再有任何「游戏区」页签,也不再有指向主题列表的入口。
expect(screen.queryByRole('tablist', { name: '游戏区' })).toBeNull();
expect(screen.queryByRole('tab', { name: '共创' })).toBeNull();
// 分类 / 设备筛选页签仍在。
expect(screen.getByRole('tablist', { name: '游戏分类' })).toBeTruthy();
});
it('scope=forkable:卡片带「共创 N」角标,优先 coCreationCount、回退 forkCount、0 不渲染', async () => {
listGamesMock.mockResolvedValueOnce({
games: [
{
...createGame({
id: 'game-new-field',
title: '服务端新字段',
forkAuthorization: 'full',
}),
coCreationCount: 7,
forkCount: 3,
},
{
...createGame({
id: 'game-legacy-field',
title: '老字段回退',
forkAuthorization: 'full',
}),
forkCount: 3,
},
{
...createGame({
id: 'game-zero',
title: '还没人共创',
forkAuthorization: 'full',
}),
coCreationCount: 0,
forkCount: 0,
},
],
nextCursor: null,
});
render(<GameGalleryPage scope="forkable" onOpenDetail={vi.fn()} />);
const newFieldCard = (await screen.findByText('服务端新字段')).closest(
'.game-card',
) as HTMLElement;
expect(
newFieldCard.querySelector('.game-card__co-creation-badge')?.textContent,
).toBe('共创 7');
const legacyCard = screen
.getByText('老字段回退')
.closest('.game-card') as HTMLElement;
expect(
legacyCard.querySelector('.game-card__co-creation-badge')?.textContent,
).toBe('共创 3');
const zeroCard = screen
.getByText('还没人共创')
.closest('.game-card') as HTMLElement;
expect(zeroCard.querySelector('.game-card__co-creation-badge')).toBeNull();
// 角标在共享卡的 badges 槽里(绝对定位在封面左上),不是新加的卡内行。
expect(newFieldCard.querySelector('.game-card__badges')).toBeTruthy();
});
it('游戏广场(scope=all)卡片不带「共创 N」角标', async () => {
listGamesMock.mockResolvedValueOnce({
games: [
{
...createGame({ id: 'game-plain', title: '广场作品' }),
forkCount: 5,
coCreationCount: 5,
},
],
nextCursor: null,
});
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
const card = (await screen.findByText('广场作品')).closest(
'.game-card',
) as HTMLElement;
expect(card.querySelector('.game-card__badges')).toBeNull();
expect(card.querySelector('.game-card__co-creation-badge')).toBeNull();
});
it('scope=forkable:客户端兜底过滤掉禁止共创的作品(与服务端 forkable 同义)', async () => {
listGamesMock.mockResolvedValueOnce({
games: [
createGame({
id: 'game-open',
title: '开放共创',
forkAuthorization: 'full',
}),
createGame({
id: 'game-forbidden',
title: '禁止共创',
forkAuthorization: 'forbidden',
}),
],
nextCursor: null,
});
render(<GameGalleryPage scope="forkable" onOpenDetail={vi.fn()} />);
expect(await screen.findByText('开放共创')).toBeTruthy();
expect(screen.queryByText('禁止共创')).toBeNull();
});
it('scope=forkable:空态说明「暂时没有开放共创的作品」,加载更多仍可用', async () => {
// ③ 空态
listGamesMock.mockResolvedValueOnce({ games: [], nextCursor: null });
const empty = render(
<GameGalleryPage scope="forkable" onOpenDetail={vi.fn()} />,
);
expect(await screen.findByText('暂时没有开放共创的作品')).toBeTruthy();
expect(
screen.getByText('作者把作品开放共创授权后,它会出现在这里。'),
).toBeTruthy();
empty.unmount();
// ④ 加载更多在共创页签同样可用,且**第二页请求也带 forkable**。
listGamesMock
.mockResolvedValueOnce({
games: [
createGame({
id: 'game-1',
title: '第一页共创作品',
forkAuthorization: 'full',
}),
],
nextCursor: 'c1',
})
.mockResolvedValueOnce({
games: [
createGame({
id: 'game-2',
title: '第二页共创作品',
forkAuthorization: 'full',
}),
],
nextCursor: null,
});
render(<GameGalleryPage scope="forkable" onOpenDetail={vi.fn()} />);
await screen.findByText('第一页共创作品');
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
await screen.findByText('第二页共创作品');
// 「加载更多」那一次请求 = 带游标的那一次(前面的调用都是首屏加载)。
const loadMoreCall = listGamesMock.mock.calls.find(
([query]) => query && typeof query === 'object' && 'cursor' in query,
);
expect(loadMoreCall?.[0]).toEqual({
keyword: '',
category: '全部',
cursor: 'c1',
forkable: true,
});
expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull();
});
it('ignores a stale response after search keyword changes', async () => {
const first = deferred<{
games: GameDistributionGame[];
nextCursor: string | null;
}>();
const second = deferred<{
games: GameDistributionGame[];
nextCursor: string | null;
}>();
listGamesMock
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise);
const onOpenDetail = vi.fn();
const { rerender } = render(
<GameGalleryPage searchKeyword="" onOpenDetail={onOpenDetail} />,
);
await waitFor(() => expect(listGamesMock).toHaveBeenCalledTimes(1));
rerender(
<GameGalleryPage searchKeyword="新搜索" onOpenDetail={onOpenDetail} />,
);
await waitFor(() => expect(listGamesMock).toHaveBeenCalledTimes(2));
const newerGame = createGame({ id: 'game-new', title: '新搜索结果' });
second.resolve({ games: [newerGame], nextCursor: null });
await screen.findByText('新搜索结果');
first.resolve({
games: [createGame({ id: 'game-old', title: '旧搜索结果' })],
nextCursor: null,
});
await waitFor(() => expect(screen.queryByText('旧搜索结果')).toBeNull());
});
});
describe('GameDetailPage', () => {
it.each([
['创作者', '成功'],
['未知作者', '空名'],
['创作者', '失败'],
])(
'作者为占位词 %s 时,补查%s均不闪现伪昵称且保留作者操作',
async (name, outcome) => {
const lookup = deferred<{ displayName: string }>();
getPublicAuthUserByIdMock.mockReturnValueOnce(lookup.promise);
getGameMock.mockResolvedValueOnce(
createGame({ author: { id: 'author-2', name } }),
);
const onOpenCreator = vi.fn();
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenCreator={onOpenCreator}
/>,
);
const link = await screen.findByRole('link', { name: '查看创作者主页' });
expect(link.getAttribute('href')).toBe('/creators?id=author-2');
expect(link.querySelector('strong')?.textContent).toBe('');
// 补查由 effect 发起、提交后一拍才发出:这里等它发生,避免断言与 React 提交竞态
// (此前偶发 0 次调用,会让整份文件的门禁随机变红)。
// 占位昵称的补查发生在渲染后的 effect 里:并发负载下首帧可能还没触发,必须等。
await waitFor(() =>
expect(getPublicAuthUserByIdMock).toHaveBeenCalledWith('author-2'),
);
await waitFor(() => {
expect(
(screen.getByRole('button', { name: '关注' }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
await act(async () => {
if (outcome === '失败') lookup.reject(new Error('查询失败'));
else
lookup.resolve({ displayName: outcome === '成功' ? '真实昵称' : '' });
});
expect(link.querySelector('strong')?.textContent).toBe(
outcome === '成功' ? '真实昵称' : '',
);
expect(screen.queryByText(name, { exact: true })).toBeNull();
fireEvent.click(link);
expect(onOpenCreator).toHaveBeenCalledWith('author-2');
fireEvent.click(screen.getByRole('button', { name: '关注' }));
await screen.findByRole('button', { name: '取消关注' });
expect(changeCreatorRelationshipMock).toHaveBeenCalledWith(
'author-2',
'follow',
);
},
);
it('blocks desktop-only games on a mobile viewport', async () => {
setViewportWidth(375);
getGameMock.mockResolvedValueOnce(
createGame({
deviceSupport: { desktop: true, mobile: false, touch: false },
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const playButton = await screen.findByRole('button', { name: /立即玩/u });
expect((playButton as HTMLButtonElement).disabled).toBe(true);
expect(
within(await screen.findByTestId('platform-toast-host')).getByText(
'请在电脑上游玩',
),
).toBeTruthy();
// 行内那份保留(读屏用 `aria-describedby` 关联),但视觉隐藏、不占布局空间。
expect(
document.getElementById('game-mobile-playback-warning')?.className,
).toContain('game-device-warning--sr');
});
it('rejects an invalid query id without requesting the API', async () => {
render(<GameDetailPage gameId="" onBack={vi.fn()} onPlay={vi.fn()} />);
expect(await screen.findByText('游戏编号无效')).toBeTruthy();
expect(getGameMock).not.toHaveBeenCalled();
});
it('已发布但未标注操作方式时不谎称没有可玩版本', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
currentVersion: { ...createGame().currentVersion!, controls: [] },
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
expect(await screen.findByText('未标注操作方式')).toBeTruthy();
expect(screen.queryByText('暂未发布可玩版本')).toBeNull();
});
it('优先展示版本声明的输入方式', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
inputModes: ['keyboard', 'touch'],
currentVersion: { ...createGame().currentVersion!, controls: [] },
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
expect(await screen.findByText('键盘 · 触屏')).toBeTruthy();
expect(screen.queryByText('未标注操作方式')).toBeNull();
});
it('没有当前版本时才显示暂未发布可玩版本', async () => {
getGameMock.mockResolvedValueOnce(createGame({ currentVersion: null }));
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
expect(await screen.findByText('暂未发布可玩版本')).toBeTruthy();
});
it('展示共创授权、代际与溯源信息,并给出「一键开始共创」入口', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
forkAuthorization: 'nonCommercial',
forkCount: 3,
lineage: {
generation: 2,
rootGameId: 'game-root',
rootTitle: '根作品',
parentGameId: 'game-parent',
parentTitle: '原初之光',
parentAuthorName: '原作者',
},
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
expect(await screen.findByText('允许非商用共创')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy();
expect(screen.getByText('父代作者 原作者')).toBeTruthy();
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
// 已公开 + 授权开放 → 「一键开始共创」入口出现(M1 时它还不存在,这里钉住新契约)。
expect(screen.getByRole('button', { name: '一键开始共创' })).toBeTruthy();
});
it('旧数据缺少授权与血缘字段时只按禁止共创渲染', async () => {
getGameMock.mockResolvedValueOnce(createGame());
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
expect(await screen.findByText('禁止共创')).toBeTruthy();
expect(screen.queryByText(/代作品/u)).toBeNull();
expect(screen.queryByText(/已被改编/u)).toBeNull();
expect(screen.queryByText(/允许/u)).toBeNull();
});
it('父作品已被软删除时不展示被删作品标题与作者名', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
forkAuthorization: 'full',
forkCount: 0,
lineage: {
generation: 2,
rootGameId: 'game-root',
rootTitle: null,
parentGameId: 'game-parent',
parentTitle: null,
parentAuthorName: null,
},
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
expect(await screen.findByText('允许全开放共创')).toBeTruthy();
expect(screen.getByText('2 代三创 · 原作品已不可用')).toBeTruthy();
expect(screen.queryByText(/改编自/u)).toBeNull();
// 血缘作者行(卡内 `.game-fork-lineage__author`)不该出现;hero 里的
// 「由 测试作者 制作」是作品自身署名,不在断言范围内。
expect(document.querySelector('.game-fork-lineage__author')).toBeNull();
});
it('付费已购买时显示立即玩并可进入游玩页', async () => {
const onPlay = vi.fn();
getGameMock.mockResolvedValueOnce(
createGame({ priceMudPoints: 120, purchased: true }),
);
render(<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={onPlay} />);
const playButton = await screen.findByRole('button', { name: /立即玩/u });
expect((playButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByRole('button', { name: /泥点购买/u })).toBeNull();
expect(screen.getByText('120 泥点')).toBeTruthy();
fireEvent.click(playButton);
expect(onPlay).toHaveBeenCalledWith('game-1');
});
it('付费未购买但服务端已下发公开入口(作者/管理员)时显示立即玩而不是购买', async () => {
const onPlay = vi.fn();
// 作者本人与管理员不会被记为已购买(purchased=false),但后端仍授予可玩入口。
getGameMock.mockResolvedValueOnce(
createGame({ priceMudPoints: 120, purchased: false }),
);
render(<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={onPlay} />);
const playButton = await screen.findByRole('button', { name: /立即玩/u });
expect((playButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByRole('button', { name: /泥点购买/u })).toBeNull();
expect(screen.getByText('120 泥点')).toBeTruthy();
fireEvent.click(playButton);
expect(onPlay).toHaveBeenCalledWith('game-1');
});
function createLockedPaidGame(): GameDistributionGame {
return createGame({
priceMudPoints: 120,
purchased: false,
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
});
}
it('付费未购买时展示价格与购买主动作,不再给出游玩入口', async () => {
getGameMock.mockResolvedValueOnce(createLockedPaidGame());
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const buyButton = await screen.findByRole('button', {
name: /120 泥点购买/u,
});
expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull();
// 定价资料卡展示价格;入口为空是服务端口径,不是「入口地址无效」。
expect(screen.getByText('120 泥点')).toBeTruthy();
expect(screen.queryByText('游戏入口地址无效')).toBeNull();
expect((buyButton as HTMLButtonElement).disabled).toBe(false);
});
it('购买走二次确认并展示余额,成功后刷新钱包并回到可玩状态', async () => {
getGameMock
.mockResolvedValueOnce(createLockedPaidGame())
.mockResolvedValueOnce(
createGame({ priceMudPoints: 120, purchased: true }),
);
purchaseGameMock.mockResolvedValue({
purchase: {
purchaseId: 'gdpurchase_1',
gameId: 'game-1',
priceMudPoints: 120,
createdAt: '2026-10-05T00:00:00Z',
},
walletBalance: 380,
replayed: false,
});
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
fireEvent.click(
await screen.findByRole('button', { name: /120 泥点购买/u }),
);
const dialog = await screen.findByRole('dialog', {
name: '确认消耗泥点',
});
expect(within(dialog).getByText('消耗 120 泥点')).toBeTruthy();
expect(within(dialog).getByText('当前泥点余额:500 泥点')).toBeTruthy();
const confirmButton = within(dialog).getByRole('button', {
name: '确认购买',
});
// 双击必须只扣一次:第二次点击落在同一次购买的请求窗口内,不得再发一次购买请求。
fireEvent.click(confirmButton);
fireEvent.click(confirmButton);
await waitFor(() => expect(purchaseGameMock).toHaveBeenCalledTimes(1));
expect(purchaseGameMock.mock.calls[0]?.[0]).toBe('game-1');
expect(purchaseGameMock.mock.calls[0]?.[1]).toEqual({
expectedPriceMudPoints: 120,
});
// 同一次购买只用一个幂等键:键为空或每次点击换键都意味着服务端会当新购买扣费。
const purchaseKeys = purchaseGameMock.mock.calls.map((call) =>
String(call[2] ?? ''),
);
expect(purchaseKeys).toEqual([
expect.stringMatching(/^web-game-purchase-[0-9a-f-]{36}$/u),
]);
await waitFor(() =>
expect(walletMocks.onWalletBalanceMayHaveChanged).toHaveBeenCalled(),
);
expect(
await screen.findByText('购买成功,现在可以直接开始游玩。'),
).toBeTruthy();
expect(await screen.findByRole('button', { name: /立即玩/u })).toBeTruthy();
});
it('余额不足时失败关闭并给出充值入口', async () => {
const onOpenRecharge = vi.fn();
getGameMock.mockResolvedValue(createLockedPaidGame());
purchaseGameMock.mockRejectedValueOnce(
new ApiClientError({
message: '泥点余额不足',
status: 400,
code: 'INSUFFICIENT_MUD_POINTS',
}),
);
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenRecharge={onOpenRecharge}
/>,
);
fireEvent.click(
await screen.findByRole('button', { name: /120 泥点购买/u }),
);
fireEvent.click(await screen.findByRole('button', { name: '确认购买' }));
expect(
await screen.findByText('泥点不足,充值后即可购买这款游戏。'),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '去充值' }));
expect(onOpenRecharge).toHaveBeenCalledTimes(1);
// 失败关闭:既没有游玩入口,也没有伪造成功。
expect(screen.queryByRole('button', { name: /立即玩/u })).toBeNull();
expect(screen.queryByText('购买成功,现在可以直接开始游玩。')).toBeNull();
expect(screen.getByRole('button', { name: /120 泥点购买/u })).toBeTruthy();
});
it('服务端返回 409 价格已变化时提示已刷新并保留购买入口', async () => {
getGameMock.mockResolvedValue(createLockedPaidGame());
purchaseGameMock.mockRejectedValueOnce(
new ApiClientError({
message: '价格已变化,请按最新价格重新确认',
status: 409,
code: 'CONFLICT',
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
fireEvent.click(
await screen.findByRole('button', { name: /120 泥点购买/u }),
);
fireEvent.click(await screen.findByRole('button', { name: '确认购买' }));
expect(
await screen.findByText(
'价格已更新,已为你刷新最新价格,请重新确认购买。',
),
).toBeTruthy();
// CAS 失败必须回读详情拿最新价,但绝不按旧价扣费,也不伪造购买成功。
await waitFor(() => expect(getGameMock).toHaveBeenCalledTimes(2));
expect(screen.queryByText('购买成功,现在可以直接开始游玩。')).toBeNull();
expect(screen.getByRole('button', { name: /120 泥点购买/u })).toBeTruthy();
});
it('旧响应缺价格且没有可玩入口时说明「立即玩」为何不可用', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
// 旧响应没有买断价:按免费兜底会被判成可玩,但服务端并没有下发入口。
priceMudPoints: undefined,
purchased: false,
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const playButton = await screen.findByRole('button', { name: /立即玩/u });
await waitFor(() =>
expect((playButton as HTMLButtonElement).disabled).toBe(true),
);
// 禁用按钮必须带可操作说明,否则用户只看到一个点不动的按钮。
// 可见提示走 toast(不占操作行布局);行内那份保留 id 供 aria-describedby 关联,但视觉隐藏。
expect(
within(await screen.findByTestId('platform-toast-host')).getByText(
'暂时拿不到可玩入口,请刷新页面后重试。',
),
).toBeTruthy();
const reason = document.getElementById('game-entry-missing-warning');
expect(reason?.className).toContain('game-device-warning--sr');
expect(playButton.getAttribute('aria-describedby')).toBe(
'game-entry-missing-warning',
);
});
});
describe('GameDetailPage 作者视角', () => {
const reviewVersion: GameDistributionVersionStatusEntry = {
versionId: 'version-2',
gameId: 'game-1',
versionNumber: 2,
packageSha256: 'b'.repeat(64),
packageBytes: 2048,
status: 'pending_review',
publicationRevision: 6,
reviewReason: '封面需要替换',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
};
const publishedVersion: GameDistributionVersionStatusEntry = {
versionId: 'version-1',
gameId: 'game-1',
versionNumber: 1,
packageSha256: 'c'.repeat(64),
packageBytes: 1024,
status: 'published',
publicationRevision: 5,
reviewReason: null,
createdAt: '2026-09-19T08:00:00Z',
updatedAt: '2026-09-19T09:00:00Z',
};
function createOwnerGame(
overrides: Partial<GameDistributionMyGame> = {},
): GameDistributionMyGame {
return {
...createGame({ currentVersion: null, status: 'unpublished' }),
latestVersion: reviewVersion,
versions: [reviewVersion],
...overrides,
} as GameDistributionMyGame;
}
it('走 owner 路由并展示未公开作品的私有状态,不再请求公开详情', async () => {
getMyGameMock.mockResolvedValue(createOwnerGame());
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
expect(await screen.findByText('作品状态')).toBeTruthy();
expect(screen.getByText('未公开')).toBeTruthy();
expect(
screen.getByText('驳回理由:封面需要替换', {
selector: '.game-owner-panel__reason',
}),
).toBeTruthy();
expect(getMyGameMock).toHaveBeenCalledWith('game-1');
expect(getGameMock).not.toHaveBeenCalled();
const playButton = screen.getByRole('button', { name: /立即玩/u });
expect((playButton as HTMLButtonElement).disabled).toBe(true);
expect(
within(await screen.findByTestId('platform-toast-host')).getByText(
'作品尚未公开,暂时没有在线可玩版本',
),
).toBeTruthy();
});
it('未公开作品的封面显示审核中,且不请求评价与评分接口', async () => {
getMyGameMock.mockResolvedValue(createOwnerGame());
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
await screen.findByText('作品状态');
// 封面位按最新版本状态给出「审核中」,不再只剩手柄占位图。
const cover = document.querySelector('.game-detail-cover') as HTMLElement;
expect(within(cover).getByText('审核中')).toBeTruthy();
// 两栏评价都是空态:没有错误块、没有重试、没有评分加载失败。
expect(screen.getAllByText('暂无评价')).toHaveLength(2);
expect(screen.queryByText('评分加载失败')).toBeNull();
expect(screen.queryByText('评分加载中…')).toBeNull();
expect(screen.queryByRole('button', { name: '重试' })).toBeNull();
expect(screen.queryByRole('textbox')).toBeNull();
// 代码级门禁:非公开作品一个评价/评分请求都不发。
expect(listGameReviewsMock).not.toHaveBeenCalled();
expect(getMyGameReviewMock).not.toHaveBeenCalled();
});
it('还没有提交过版本的非公开作品封面显示未公开', async () => {
getMyGameMock.mockResolvedValue(
createOwnerGame({ latestVersion: null, versions: [] }),
);
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
await screen.findByText('还没有提交过版本');
expect(
within(
document.querySelector('.game-detail-cover') as HTMLElement,
).getByText('未公开'),
).toBeTruthy();
expect(screen.queryByText('评分加载失败')).toBeNull();
expect(listGameReviewsMock).not.toHaveBeenCalled();
expect(getMyGameReviewMock).not.toHaveBeenCalled();
});
it.each<[string, GameDistributionVersionStatus | null, string]>([
['unpublished', null, '未公开'],
['unpublished', 'awaiting_upload', '未公开'],
['unpublished', 'pending_review', '审核中'],
['unpublished', 'rejected', '审核未通过'],
['suspended', 'pending_review', '审核中'],
['suspended', null, '已被封禁'],
])('封面文案 %s + %s -> %s', (gameStatus, versionStatus, expected) => {
expect(resolveNonPublicCoverLabel(gameStatus, versionStatus)).toBe(
expected,
);
});
it('审核未通过的作品封面用更准确的驳回文案', async () => {
const rejected: GameDistributionVersionStatusEntry = {
...reviewVersion,
status: 'rejected',
};
getMyGameMock.mockResolvedValue(
createOwnerGame({ latestVersion: rejected, versions: [rejected] }),
);
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
await screen.findByText('作品状态');
expect(
within(
document.querySelector('.game-detail-cover') as HTMLElement,
).getByText('审核未通过'),
).toBeTruthy();
expect(screen.queryByText('评分加载失败')).toBeNull();
});
it('以已发布版本作为在线版本,并为未完成版本回读下一步动作', async () => {
getMyGameMock.mockResolvedValue(
createOwnerGame({
status: 'published',
versions: [reviewVersion, publishedVersion],
}),
);
loadVersionRecoveryActionsMock.mockResolvedValue({ 'version-2': 'wait' });
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
expect(await screen.findByText('下一步:等待审核结果')).toBeTruthy();
expect(loadVersionRecoveryActionsMock).toHaveBeenCalledWith(['version-2']);
expect(within(screen.getByRole('list')).getByText('v1')).toBeTruthy();
// 已公开作品照旧请求评价与评分,门禁只作用于非公开作品。
await waitFor(() =>
expect(listGameReviewsMock).toHaveBeenCalledWith('game-1', 1),
);
expect(getMyGameReviewMock).toHaveBeenCalledWith('game-1');
const playButton = screen.getByRole('button', { name: /立即玩/u });
expect((playButton as HTMLButtonElement).disabled).toBe(false);
});
it('作者读不到自己名下作品时按找不到处理', async () => {
getMyGameMock.mockResolvedValue(null);
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
expect(await screen.findByText('找不到这款游戏')).toBeTruthy();
});
});
describe('GamePlayPage', () => {
it('waits for an explicit start before mounting the sandbox iframe', async () => {
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
await screen.findByRole('button', { name: /开始游戏/u });
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /开始游戏/u }));
const iframe = await screen.findByTitle('星轨防线 在线游玩');
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
expect(recordGamePlayMock).toHaveBeenCalledWith('game-1');
});
it('shows the shared loading surface until the release frame is ready', async () => {
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
await screen.findByRole('button', { name: /开始游戏/u });
fireEvent.click(screen.getByRole('button', { name: /开始游戏/u }));
const iframe = await screen.findByTitle('星轨防线 在线游玩');
// 发行包首屏要下完整个引擎:加载期必须有看得见的加载面,而不是一行小字或黑屏。
const loadingSurface = screen.getByRole('status');
expect(loadingSurface.textContent).toContain('星轨防线');
expect(
loadingSurface.classList.contains('platform-game-loading--player'),
).toBe(true);
expect(
loadingSurface.querySelector('.platform-game-loading__track'),
).not.toBeNull();
fireEvent.load(iframe);
expect(screen.queryByRole('status')).toBeNull();
expect(iframe.className.includes('game-player-frame--ready')).toBe(true);
});
it('rejects an unsafe release entry URL before rendering an iframe', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
currentVersion: {
...createGame().currentVersion!,
entryUrl: 'javascript:alert(1)',
},
}),
);
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
expect(await screen.findByText('游戏入口地址无效')).toBeTruthy();
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
});
it('免费游戏不请求播放会话', async () => {
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
await screen.findByTitle('星轨防线 在线游玩');
expect(createGamePlaySessionMock).not.toHaveBeenCalled();
});
/** 付费未购买:服务端不下发公开入口,只能靠播放会话判定能否游玩。 */
function createLockedPlayGame(): GameDistributionGame {
return createGame({
priceMudPoints: 120,
purchased: false,
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
});
}
it('付费游戏先签发播放会话再挂载 iframe 并计数', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
priceMudPoints: 120,
purchased: true,
// 已购买时公开入口也在,但付费作品的发行网关只认会话,必须走播放会话。
currentVersion: { ...createGame().currentVersion! },
}),
);
createGamePlaySessionMock.mockResolvedValue({
playUrl: '/api/game-distribution/play-sessions/token-1/',
expiresAt: '2026-10-05T00:05:00Z',
});
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
const iframe = await screen.findByTitle('星轨防线 在线游玩');
expect(iframe.getAttribute('src')).toBe(
new URL(
'/api/game-distribution/play-sessions/token-1/',
window.location.origin,
).href,
);
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
expect(createGamePlaySessionMock).toHaveBeenCalledWith('game-1');
expect(recordGamePlayMock).toHaveBeenCalledWith('game-1');
});
it('未登录直接进游玩页时提示登录且不挂载 iframe', async () => {
getGameMock.mockResolvedValueOnce(createLockedPlayGame());
createGamePlaySessionMock.mockRejectedValueOnce(
new ApiClientError({
message: '播放付费作品需要先登录',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
expect(
await screen.findByText(
'这款游戏需要先登录才能游玩,请返回详情页登录后再试。',
),
).toBeTruthy();
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
expect(recordGamePlayMock).not.toHaveBeenCalled();
});
it('未购买时播放会话被拒,不挂载 iframe 也不上报游玩', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
priceMudPoints: 120,
purchased: false,
currentVersion: { ...createGame().currentVersion!, entryUrl: null },
}),
);
createGamePlaySessionMock.mockRejectedValueOnce(
new ApiClientError({
message: '尚未购买这款游戏',
status: 403,
code: 'FORBIDDEN',
}),
);
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
expect(
await screen.findByText(
'这款游戏需要先购买才能游玩,请返回详情页完成购买。',
),
).toBeTruthy();
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
expect(recordGamePlayMock).not.toHaveBeenCalled();
});
it('付费作品在会话签发期间显示共享加载面,签发完成后才挂载 iframe', async () => {
const pendingSession: {
resolve: (session: GameDistributionPlaySessionResponse) => void;
} = { resolve: () => {} };
getGameMock.mockResolvedValueOnce(createLockedPlayGame());
createGamePlaySessionMock.mockImplementationOnce(
() =>
new Promise<GameDistributionPlaySessionResponse>((resolve) => {
pendingSession.resolve = resolve;
}),
);
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: /开始游戏/u }));
// 会话签发期间不能只留空白:这是付费作品唯一的“正在准备”反馈。
const loadingSurface = await screen.findByRole('status');
expect(
loadingSurface.classList.contains('platform-game-loading--player'),
).toBe(true);
expect(loadingSurface.textContent).toContain('正在准备游戏…');
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
expect(recordGamePlayMock).not.toHaveBeenCalled();
await act(async () => {
pendingSession.resolve({
playUrl: '/api/game-distribution/play-sessions/token-9/',
expiresAt: '2026-10-05T00:05:00Z',
});
});
const iframe = await screen.findByTitle('星轨防线 在线游玩');
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
expect(recordGamePlayMock).toHaveBeenCalledWith('game-1');
});
});
describe('GameGalleryPage 筛选与返回上下文', () => {
function resetGalleryContext() {
window.sessionStorage.clear();
window.history.replaceState({}, '', '/games');
}
function openGame(title: string) {
fireEvent.click(screen.getByText(title).closest('button')!);
}
it('设备筛选按真实 deviceSupport 过滤,并写回地址栏', async () => {
resetGalleryContext();
listGamesMock.mockResolvedValue({
games: [
createGame({
id: 'game-desktop',
title: '桌面专属',
deviceSupport: { desktop: true, mobile: false, touch: false },
}),
createGame({
id: 'game-mobile',
title: '移动可玩',
deviceSupport: { desktop: false, mobile: true, touch: true },
}),
],
nextCursor: null,
});
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
expect(await screen.findByText('桌面专属')).toBeTruthy();
const deviceTabs = within(
screen.getByRole('tablist', { name: '设备筛选' }),
);
fireEvent.click(deviceTabs.getByRole('tab', { name: /移动/u }));
await waitFor(() => expect(screen.queryByText('桌面专属')).toBeNull());
expect(screen.getByText('移动可玩')).toBeTruthy();
expect(window.location.search).toContain('device=');
expect(decodeURIComponent(window.location.search)).toContain('device=移动');
});
it('筛选结果为空时提供重置筛选,并清理地址栏条件', async () => {
resetGalleryContext();
listGamesMock.mockResolvedValue({
games: [
createGame({
id: 'game-desktop',
title: '桌面专属',
deviceSupport: { desktop: true, mobile: false, touch: false },
}),
],
nextCursor: null,
});
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
expect(await screen.findByText('桌面专属')).toBeTruthy();
fireEvent.click(
within(screen.getByRole('tablist', { name: '设备筛选' })).getByRole(
'tab',
{ name: /移动/u },
),
);
expect(await screen.findByText('还没有找到对应游戏')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '重置筛选' }));
expect(await screen.findByText('桌面专属')).toBeTruthy();
expect(window.location.search).not.toContain('device=');
});
it('从地址栏恢复筛选条件', async () => {
resetGalleryContext();
window.history.replaceState(
{},
'',
'/games?keyword=%E6%98%9F%E8%BD%A8&category=%E5%8A%A8%E4%BD%9C&device=%E7%A7%BB%E5%8A%A8',
);
listGamesMock.mockResolvedValue({
games: [createGame({ title: '星轨防线' })],
nextCursor: null,
});
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
await waitFor(() =>
expect(listGamesMock).toHaveBeenCalledWith({
keyword: '星轨',
category: '动作',
}),
);
const search = screen.getByLabelText('搜索游戏') as HTMLInputElement;
expect(search.value).toBe('星轨');
const deviceTabs = within(
screen.getByRole('tablist', { name: '设备筛选' }),
);
expect(
deviceTabs
.getByRole('tab', { name: /移动/u })
.getAttribute('aria-selected'),
).toBe('true');
});
it('返回游戏广场时按上次筛选恢复列表与滚动位置', async () => {
resetGalleryContext();
window.sessionStorage.setItem(
'game-gallery-filters',
JSON.stringify({ keyword: '', category: '全部', device: '移动' }),
);
window.sessionStorage.setItem('game-gallery-scroll:|全部|移动', '320');
listGamesMock.mockResolvedValue({
games: [
createGame({
id: 'game-desktop',
title: '桌面专属',
deviceSupport: { desktop: true, mobile: false, touch: false },
}),
createGame({
id: 'game-mobile',
title: '移动可玩',
deviceSupport: { desktop: false, mobile: true, touch: true },
}),
],
nextCursor: null,
});
const { container } = render(
<div className="platform-tab-panel">
<GameGalleryPage onOpenDetail={vi.fn()} />
</div>,
);
const scroller = container.querySelector(
'.platform-tab-panel',
) as HTMLElement;
expect(await screen.findByText('移动可玩')).toBeTruthy();
await waitFor(() => expect(scroller.scrollTop).toBe(320));
expect(screen.queryByText('桌面专属')).toBeNull();
});
it('打开详情时保存目录滚动位置,返回后按同一容器复位', async () => {
resetGalleryContext();
const onOpenDetail = vi.fn();
listGamesMock.mockResolvedValue({
games: [createGame({ title: '星轨防线' })],
nextCursor: null,
});
const { container } = render(
<div className="platform-tab-panel">
<GameGalleryPage onOpenDetail={onOpenDetail} />
</div>,
);
const scroller = container.querySelector(
'.platform-tab-panel',
) as HTMLElement;
expect(await screen.findByText('星轨防线')).toBeTruthy();
scroller.scrollTop = 180;
openGame('星轨防线');
expect(onOpenDetail).toHaveBeenCalledWith('game-1');
await waitFor(() =>
expect(
window.sessionStorage.getItem('game-gallery-scroll:|全部|全部'),
).toBe('180'),
);
});
it('首屏「立即试玩」滚动到列表,而不是打开第一款游戏详情', async () => {
resetGalleryContext();
const onOpenDetail = vi.fn();
listGamesMock.mockResolvedValue({
games: [
createGame({ id: 'game-1', title: '星轨防线' }),
createGame({ id: 'game-2', title: '第二款' }),
],
nextCursor: null,
});
const scrollTo = vi.fn();
const originalScrollTo = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollTo',
);
Object.defineProperty(Element.prototype, 'scrollTo', {
configurable: true,
writable: true,
value: scrollTo,
});
try {
const { container } = render(
<div className="platform-tab-panel">
<GameGalleryPage onOpenDetail={onOpenDetail} />
</div>,
);
expect(await screen.findByText('星轨防线')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /立即试玩/u }));
expect(onOpenDetail).not.toHaveBeenCalled();
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(scrollTo.mock.instances[0]).toBe(
container.querySelector('.platform-tab-panel'),
);
expect(scrollTo.mock.calls[0]?.[0]).toMatchObject({
behavior: 'smooth',
});
} finally {
if (originalScrollTo) {
Object.defineProperty(Element.prototype, 'scrollTo', originalScrollTo);
} else {
delete (Element.prototype as { scrollTo?: unknown }).scrollTo;
}
}
});
});
describe('GamePlayPage 启动超时与旋转提示', () => {
it('启动超时后给出重试与返回,重试可恢复可玩', async () => {
vi.useFakeTimers();
try {
const onBack = vi.fn();
getGameMock.mockResolvedValue(createGame());
render(<GamePlayPage gameId="game-1" onBack={onBack} />);
await act(async () => {
await Promise.resolve();
});
fireEvent.click(screen.getByRole('button', { name: /开始游戏/u }));
const firstFrame = screen.getByTitle('星轨防线 在线游玩');
act(() => {
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS);
});
expect(screen.getByText(/启动超时/u)).toBeTruthy();
expect(screen.getByRole('button', { name: /^返回$/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /^重试$/u }));
const retriedFrame = screen.getByTitle('星轨防线 在线游玩');
expect(retriedFrame).not.toBe(firstFrame);
expect(screen.queryByText(/启动超时/u)).toBeNull();
fireEvent.load(retriedFrame);
expect(retriedFrame.className.includes('game-player-frame--ready')).toBe(
true,
);
act(() => {
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS);
});
expect(screen.queryByText(/启动超时/u)).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /星轨防线/u }));
expect(onBack).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it('横屏游戏只在竖屏视口提示旋转', async () => {
setViewportWidth(390);
getGameMock.mockResolvedValue(createGame({ orientation: 'landscape' }));
const { unmount } = render(
<GamePlayPage gameId="game-1" onBack={vi.fn()} />,
);
expect(await screen.findByText(/旋转设备/u)).toBeTruthy();
unmount();
setViewportWidth(1280);
getGameMock.mockResolvedValue(createGame({ orientation: 'landscape' }));
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
await screen.findByRole('button', { name: /开始游戏/u });
expect(screen.queryByText(/旋转设备/u)).toBeNull();
});
});
describe('游戏封面与截图展示', () => {
function resetGalleryContext() {
window.sessionStorage.clear();
window.history.replaceState({}, '', '/games');
}
it('广场卡片用签名地址展示真实封面', async () => {
resetGalleryContext();
listGamesMock.mockResolvedValue({
games: [
createGame({ coverObjectKey: 'game-distribution/cover/alpha.png' }),
],
nextCursor: null,
});
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
const card = (await screen.findByText('星轨防线')).closest('button');
const cover = card?.querySelector('img.game-cover__image');
expect(cover?.getAttribute('src')).toBe(
'https://assets.example.test/game-distribution/cover/alpha.png',
);
expect(cover?.getAttribute('loading')).toBe('lazy');
});
it('没有封面或换签失败时保留占位而不渲染图片', async () => {
resetGalleryContext();
resolvedAssetUrlMock.mockImplementation((objectKey: string) =>
objectKey && objectKey !== 'game-distribution/cover/broken.png'
? `https://assets.example.test/${objectKey}`
: '',
);
listGamesMock.mockResolvedValue({
games: [
createGame({
id: 'game-none',
title: '没有封面',
coverObjectKey: null,
}),
createGame({
id: 'game-broken',
title: '换签失败',
coverObjectKey: 'game-distribution/cover/broken.png',
}),
],
nextCursor: null,
});
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
expect(await screen.findByText('没有封面')).toBeTruthy();
expect(screen.getByText('换签失败')).toBeTruthy();
expect(document.querySelectorAll('img.game-cover__image')).toHaveLength(0);
expect(document.querySelectorAll('.game-cover__icon')).toHaveLength(2);
});
it('详情页展示封面与截图缩略图,并可切换 hero', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
coverObjectKey: 'game-distribution/cover/alpha.png',
screenshots: [
'game-distribution/screenshot/one.png',
'game-distribution/screenshot/two.png',
],
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
await screen.findByRole('heading', { name: '星轨防线' });
const heroImage = () =>
document.querySelector('.game-detail-cover img')?.getAttribute('src');
expect(heroImage()).toBe(
'https://assets.example.test/game-distribution/cover/alpha.png',
);
const thumbs = screen.getAllByRole('button', { name: /(查看大图)/u });
expect(thumbs).toHaveLength(3);
expect(thumbs[0]?.getAttribute('aria-pressed')).toBe('true');
expect(thumbs[2]?.getAttribute('aria-pressed')).toBe('false');
fireEvent.click(thumbs[2]!);
await waitFor(() =>
expect(heroImage()).toBe(
'https://assets.example.test/game-distribution/screenshot/two.png',
),
);
expect(thumbs[2]?.getAttribute('aria-pressed')).toBe('true');
expect(thumbs[0]?.getAttribute('aria-pressed')).toBe('false');
});
it('只有封面时不渲染缩略图条', async () => {
getGameMock.mockResolvedValueOnce(
createGame({ coverObjectKey: 'game-distribution/cover/only.png' }),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
await screen.findByRole('heading', { name: '星轨防线' });
expect(
screen.queryAllByRole('button', { name: /(查看大图)/u }),
).toHaveLength(0);
expect(screen.queryByRole('group', { name: '游戏截图' })).toBeNull();
});
it('既没有封面也没有截图时退回原占位视觉', async () => {
getGameMock.mockResolvedValueOnce(
createGame({ coverObjectKey: null, screenshots: [] }),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
await screen.findByRole('heading', { name: '星轨防线' });
expect(document.querySelectorAll('.game-detail-cover img')).toHaveLength(0);
expect(document.querySelectorAll('.game-detail-cover__icon')).toHaveLength(
1,
);
});
});
function createLineageNode(
overrides: Partial<GameDistributionLineageNode> = {},
): GameDistributionLineageNode {
return {
gameId: 'game-1',
title: '作品',
authorName: '作者',
generation: 1,
parentGameId: null,
playCount: 0,
status: 'published',
...overrides,
};
}
describe('GameLineagePage', () => {
it('渲染整棵树、高亮来源作品,并把父作品不可用的节点降级展示', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
playCount: 30,
}),
createLineageNode({
gameId: 'game-mid',
title: '中间代',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-leaf',
title: '叶节点',
generation: 2,
parentGameId: 'game-removed',
}),
],
truncated: false,
});
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-leaf"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
expect(await findTreeText('叶节点')).toBeTruthy();
expect(screen.getByText('中间代')).toBeTruthy();
// 当前作品高亮在它自己的节点卡片上。
expect(
screen.getByText('叶节点').closest('.game-lineage-node')?.className,
).toContain('game-lineage-node--anchor');
// 父作品不在可见集合里:只标注「原作品已不可用」,不回显父作品内容。
expect(screen.getByText('原作品已不可用')).toBeTruthy();
// 任一公开节点都能进入对应详情页。
fireEvent.click(openButton('母版'));
expect(onOpenDetail).toHaveBeenCalledWith('game-root');
});
it('没有衍生作品时给出明确空态', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
],
truncated: false,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(
await screen.findByText('这部作品还没有衍生作品,欢迎从它开始共创。'),
).toBeTruthy();
});
it('没有任何可见节点时只渲染一条整体空态,不叠加衍生空态', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: null,
nodes: [],
truncated: false,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(
await screen.findByText('这部作品还没有可展示的族谱节点。'),
).toBeTruthy();
// 双重空态回归:整体空态已覆盖「没有节点」,不能再叠一条衍生空态。
expect(
screen.queryByText('这部作品还没有衍生作品,欢迎从它开始共创。'),
).toBeNull();
expect(document.querySelectorAll('.game-lineage-tree')).toHaveLength(0);
});
it('只有母版时渲染母版节点并只给衍生空态,不给整体空态', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
],
truncated: false,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(await cardOf('母版')).toBeTruthy();
expect(
screen.getByText('这部作品还没有衍生作品,欢迎从它开始共创。'),
).toBeTruthy();
expect(screen.queryByText('这部作品还没有可展示的族谱节点。')).toBeNull();
});
it('超出节点上限时如实标注截断,不静默丢弃', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
}),
createLineageNode({ gameId: 'game-2', title: '第二部', generation: 1 }),
],
truncated: true,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(
await screen.findByText('这部作品衍生较多,这里只展示前 2 个节点。'),
).toBeTruthy();
});
it('根作品不可用时按代际回退展示,不空白也不报错', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: null,
nodes: [
createLineageNode({
gameId: 'game-leaf',
title: '叶节点',
generation: 1,
parentGameId: 'game-root',
}),
],
truncated: false,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(
await screen.findByText(
'母版已不可用,下面按代际关系展示仍可阅读的衍生作品。',
),
).toBeTruthy();
expect((await treeTexts('叶节点')).length).toBeGreaterThan(0);
expect(screen.getByText('原作品已不可用')).toBeTruthy();
});
it('未公开作品的详情页不渲染族谱入口', async () => {
getMyGameMock.mockResolvedValueOnce({
...createGame({ status: 'unpublished' }),
latestVersion: null,
versions: [],
} as GameDistributionMyGame);
render(
<GameDetailPage
gameId="game-1"
ownerView
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenLineage={vi.fn()}
/>,
);
await screen.findByText('作品状态');
expect(screen.queryByRole('button', { name: '查看创作族谱' })).toBeNull();
});
it('加载失败走平台错误态并可重试', async () => {
getGameLineageMock
.mockRejectedValueOnce(new Error('网络错误'))
.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({ gameId: 'game-root', title: '母版' }),
nodes: [
createLineageNode({ gameId: 'game-root', title: '母版' }),
createLineageNode({ gameId: 'game-2', title: '第二部' }),
],
truncated: false,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(await screen.findByText('网络错误')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '重试' }));
expect(await screen.findByText('第二部')).toBeTruthy();
});
it('缺少根作品编号时不发请求并提示编号无效', async () => {
render(
<GameLineagePage rootGameId="" onBack={vi.fn()} onOpenDetail={vi.fn()} />,
);
expect(await screen.findByText('游戏编号无效')).toBeTruthy();
expect(getGameLineageMock).not.toHaveBeenCalled();
});
it('详情页共创卡里的族谱入口带上根作品与当前作品', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
lineage: {
generation: 1,
rootGameId: 'game-root',
rootTitle: '母版',
parentGameId: 'game-root',
parentTitle: '母版',
parentAuthorName: '原作者',
},
}),
);
const onOpenLineage = vi.fn();
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenLineage={onOpenLineage}
/>,
);
fireEvent.click(
await screen.findByRole('button', { name: '查看创作族谱' }),
);
expect(onOpenLineage).toHaveBeenCalledWith('game-root', 'game-1');
});
it('没有族谱导航回调时不渲染族谱入口', async () => {
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
await screen.findByRole('heading', { name: '星轨防线' });
expect(screen.queryByRole('button', { name: '查看创作族谱' })).toBeNull();
});
});
describe('GameDetailPage 开始共创入口', () => {
it('已公开且授权开放时出现入口;唤起没被接手时降级给出可复制的作品 ID 与指引', async () => {
getGameMock.mockResolvedValueOnce(
createGame({ forkAuthorization: 'nonCommercial' }),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const entry = await screen.findByRole('button', { name: '一键开始共创' });
// 未展开前不露出获取方式。
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
// 进入唤起流程后再切假定时器:先让详情页的异步加载在真实定时器下完成。
vi.useFakeTimers();
fireEvent.click(entry);
// 主路径是唤起客户端,而不是先给手动方式。
expect(openForkProtocolMock).toHaveBeenCalledWith('game-1');
expect(screen.getByText('正在唤起陶泥儿客户端…')).toBeTruthy();
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
// 页面没有失焦 → 没有客户端接手 → 经过降级窗口后给手动路径。
await act(async () => {
vi.advanceTimersByTime(FORK_LAUNCH_FALLBACK_DELAY_MS + 10);
});
expect(screen.getByText(/没有检测到陶泥儿客户端/u)).toBeTruthy();
expect(
screen.getByText(
'在陶泥儿客户端里打开「从平台作品开始创作」,粘贴下面的作品 ID 或作品链接。',
),
).toBeTruthy();
expect(screen.getByText('game-1')).toBeTruthy();
expect(screen.getByText(/\/games\/detail\?id=game-1$/u)).toBeTruthy();
// 冻结口径:开始共创 = 复制整个工程继续改造;只保留一句话告知(对他人可见 + 不可撤销)。
expect(
screen.getByText(/整个工程(含素材与文档)复制到你的项目里继续改造/u),
).toBeTruthy();
expect(screen.getByText(/复制后对他人可见,且不可撤销/u)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '复制作品 ID' }));
await act(async () => {});
expect(copyTextToClipboardMock).toHaveBeenCalledWith('game-1');
expect(screen.getByRole('button', { name: '作品 ID 已复制' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '复制作品链接' }));
await act(async () => {});
expect(copyTextToClipboardMock).toHaveBeenCalledWith(
expect.stringContaining('/games/detail?id=game-1'),
);
expect(screen.getByRole('button', { name: '作品链接已复制' })).toBeTruthy();
vi.useRealTimers();
});
it('桌面端唤起后页面失焦即认为客户端接手:只给状态反馈,不出降级面板', async () => {
getGameMock.mockResolvedValueOnce(
createGame({ forkAuthorization: 'full' }),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const entry = await screen.findByRole('button', { name: '一键开始共创' });
vi.useFakeTimers();
fireEvent.click(entry);
expect(openForkProtocolMock).toHaveBeenCalledWith('game-1');
// 失焦 = 浏览器把焦点交给了外部协议处理器。
act(() => {
window.dispatchEvent(new Event('blur'));
});
await act(async () => {
vi.advanceTimersByTime(FORK_LAUNCH_FALLBACK_DELAY_MS + 10);
});
expect(screen.getByText(/已唤起陶泥儿客户端/u)).toBeTruthy();
// 已接手就不算降级:不出现手动获取的作品 ID 与复制按钮。
expect(screen.queryByText(/作品 ID:/u)).toBeNull();
expect(screen.queryByRole('button', { name: '复制作品 ID' })).toBeNull();
vi.useRealTimers();
});
it('移动端不尝试唤起,直接给手动路径', async () => {
setViewportWidth(375);
getGameMock.mockResolvedValueOnce(
createGame({ forkAuthorization: 'nonCommercial' }),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
fireEvent.click(
await screen.findByRole('button', { name: '一键开始共创' }),
);
expect(openForkProtocolMock).not.toHaveBeenCalled();
expect(screen.getByText(/当前设备无法直接唤起客户端/u)).toBeTruthy();
expect(screen.getByText('game-1')).toBeTruthy();
expect(screen.getByRole('button', { name: '复制作品 ID' })).toBeTruthy();
});
it('授权为禁止共创时主操作仍在但置灰,点击给出原因', async () => {
getGameMock.mockResolvedValueOnce(
createGame({ forkAuthorization: 'forbidden' }),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const blocked = await screen.findByRole('button', {
name: '一键开始共创',
});
// 不静默隐藏:按钮在,但是 aria-disabled + 有原因(悬停 title)。
expect(blocked.getAttribute('aria-disabled')).toBe('true');
expect(blocked.getAttribute('title')).toContain('禁止共创');
// 点击给出可读原因(不发起唤起)。
fireEvent.click(blocked);
expect(
await screen.findByText(
/作者未开放共创授权(禁止共创),无法开始共创这部作品。/u,
),
).toBeTruthy();
expect(openForkProtocolMock).not.toHaveBeenCalled();
});
it('未公开作品不出现开始共创入口', async () => {
getMyGameMock.mockResolvedValueOnce({
...createGame({ status: 'unpublished', forkAuthorization: 'full' }),
latestVersion: null,
versions: [],
} as GameDistributionMyGame);
render(
<GameDetailPage
gameId="game-1"
ownerView
onBack={vi.fn()}
onPlay={vi.fn()}
/>,
);
await screen.findByText('作品状态');
expect(screen.queryByRole('button', { name: '一键开始共创' })).toBeNull();
expect(
screen.queryByRole('button', { name: '登录后一键开始共创' }),
).toBeNull();
});
it('未安装客户端:唤起后没被接管 → 打开同一个下载入口 + 给出人话提示', async () => {
getGameMock.mockResolvedValueOnce(
createGame({ id: 'game-1', forkAuthorization: 'full' }),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
// 先拿到按钮再开假定时器:findBy* 依赖真实定时器。
const entry = await screen.findByRole('button', { name: '一键开始共创' });
vi.useFakeTimers();
fireEvent.click(entry);
// 唤起已发出,但还没有任何「客户端接手」信号。
expect(openForkProtocolMock).toHaveBeenCalledWith('game-1');
expect(requestClientDownloadMock).not.toHaveBeenCalled();
expect(screen.getByText('正在唤起陶泥儿客户端…')).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(FORK_LAUNCH_FALLBACK_DELAY_MS + 50);
});
// 超时 = 没有客户端接手:打开与顶栏「下载客户端」**同一个**入口 + 保留手动复制路径。
expect(requestClientDownloadMock).toHaveBeenCalledTimes(1);
expect(
screen.getByText(/没有检测到陶泥儿客户端,已打开下载入口/u),
).toBeTruthy();
expect(screen.getByRole('button', { name: '复制作品 ID' })).toBeTruthy();
vi.useRealTimers();
});
it('已安装客户端:失焦被接管 → 不打开下载入口', async () => {
getGameMock.mockResolvedValueOnce(
createGame({ id: 'game-1', forkAuthorization: 'full' }),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const entry = await screen.findByRole('button', { name: '一键开始共创' });
vi.useFakeTimers();
fireEvent.click(entry);
// 浏览器把焦点交给外部协议处理器 → 视为客户端接手。
act(() => {
window.dispatchEvent(new Event('blur'));
});
await act(async () => {
vi.advanceTimersByTime(FORK_LAUNCH_FALLBACK_DELAY_MS + 50);
});
expect(requestClientDownloadMock).not.toHaveBeenCalled();
expect(screen.getByText(/已唤起陶泥儿客户端/u)).toBeTruthy();
vi.useRealTimers();
});
it('移动端:不尝试唤起,直接给出下载入口与手动复制路径', async () => {
setViewportWidth(390);
getGameMock.mockResolvedValueOnce(
createGame({ id: 'game-1', forkAuthorization: 'full' }),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
fireEvent.click(
await screen.findByRole('button', { name: '一键开始共创' }),
);
expect(openForkProtocolMock).not.toHaveBeenCalled();
expect(requestClientDownloadMock).toHaveBeenCalledTimes(1);
expect(screen.getByText(/当前设备无法直接唤起客户端/u)).toBeTruthy();
});
it('游客(未登录)入口是次要小按钮「登录后开始共创」,点击走仓库既有登录门禁而不是静默隐藏', async () => {
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
getGameMock.mockResolvedValueOnce(
createGame({ forkAuthorization: 'full' }),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const gate = await screen.findByRole('button', {
name: '登录后开始共创',
});
// 文案如实:不给游客喊「一键开始共创」这种大口号。
expect(screen.queryByRole('button', { name: '一键开始共创' })).toBeNull();
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
fireEvent.click(gate);
expect(authUiMock.value.openLoginModal).toHaveBeenCalledTimes(1);
// 未登录既不唤起客户端,也不展开获取方式。
expect(openForkProtocolMock).not.toHaveBeenCalled();
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
// 角色差异可观测:根节点带 `data-viewer-role`,按钮走「游客」变体(不占重点色)。
expect(
document
.querySelector('.game-detail-page')
?.getAttribute('data-viewer-role'),
).toBe('guest');
expect(gate.getAttribute('data-viewer-role')).toBe('guest');
expect(gate.className).toContain('game-fork-remix-guest');
expect(gate.className).not.toContain('co-creation-fork-primary');
});
it('游客变体的度量与配色:次要描边 + 32px/0.78rem,明显小于共创者的主操作(48px/0.94rem)', async () => {
// jsdom 不算几何:用 CSS 源码契约把「主次差异」钉死(真机几何由 smoke 记录)。
const css = readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
);
expect(css).toMatch(
/\.game-fork-remix-link\.game-fork-remix-guest \{[\s\S]{0,400}?min-height: 2rem;/u,
);
expect(css).toMatch(
/\.game-fork-remix-link\.game-fork-remix-guest \{[\s\S]{0,400}?font-size: 0\.78rem;/u,
);
expect(css).toMatch(
/\.game-fork-remix-link\.game-fork-remix-guest \{[\s\S]{0,500}?background: var\(--platform-button-secondary-fill\);/u,
);
// 主操作(共创者那份)仍是实心强调 + lg 尺寸,两者不是同一套度量。
expect(css).toMatch(
/\.game-fork-remix-link\.co-creation-fork-primary \{[\s\S]{0,400}?min-height: 3rem;/u,
);
expect(css).toMatch(/background: var\(--platform-button-primary-solid\);/u);
});
});
describe('原版与二创对比入口', () => {
it('二创作品详情页给出「与原版对比」,带上原版与二创 id', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
lineage: {
generation: 1,
rootGameId: 'game-parent',
rootTitle: '原版作品',
parentGameId: 'game-parent',
parentTitle: '原版作品',
parentAuthorName: '原作者',
},
}),
);
const onOpenCompare = vi.fn();
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenCompare={onOpenCompare}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '与原版对比' }));
expect(onOpenCompare).toHaveBeenCalledWith('game-parent', 'game-1');
});
it('母版(无父作品)不出现对比入口', async () => {
getGameMock.mockResolvedValueOnce(createGame());
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenCompare={vi.fn()}
/>,
);
await screen.findByRole('heading', { name: '星轨防线' });
expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull();
});
it('树节点:显示游玩次数**子树合计**角标(▶ N),仍不渲染「与原版对比」', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
playCount: 1,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
playCount: 1,
}),
createLineageNode({
gameId: 'game-leaf',
title: '叶节点',
generation: 1,
parentGameId: 'game-root',
playCount: 4,
}),
createLineageNode({
gameId: 'game-orphan',
title: '孤儿节点',
generation: 1,
parentGameId: 'game-missing',
playCount: 7,
}),
],
truncated: false,
});
const onOpenCompare = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
onOpenCompare={onOpenCompare}
/>,
);
expect(await findTreeText('叶节点')).toBeTruthy();
// 口径变更(2026-10-07):节点显示**子树合计**(`collectLineagePlayCountTotals().total`),
// 不是自身值;但**只显示一次** —— 卡面本体(广场卡)的「N 次游玩」就是它,
// 角标里那枚重复的 `▶ N` 已删除(截图反馈「▶N 同时出现在角标与 meta 行」)。
const rootCard = await cardOf('母版');
// 母版合计 = 自身 1 + 叶节点 4 = 5(孤儿节点挂在缺失的父下,不算它的后代)。
expect(rootCard.querySelector('.game-lineage-node__play-count')).toBeNull();
expect(
cardButtonOf(rootCard)?.querySelector('.game-card__play-count')
?.textContent,
).toBe('5 次游玩');
const leafCard = await cardOf('叶节点');
expect(leafCard.querySelector('.game-lineage-node__play-count')).toBeNull();
expect(
cardButtonOf(leafCard)?.querySelector('.game-card__play-count')
?.textContent,
).toBe('4 次游玩');
// 「含衍生」的口径说明挂在节点容器上(卡面那一行没法挂 title),悬停仍能看到分解。
expect(rootCard.getAttribute('title')).toContain('含衍生作品');
// 「与原版对比」仍然留在详情页,不在树里重复渲染。
expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull();
// 卡面**没有**动作按钮(整卡即入口);游玩数由卡片自身承载(子树合计口径)。
expect(screen.queryByRole('button', { name: '打开作品' })).toBeNull();
expect(
screen.queryByRole('button', { name: '开始共创 叶节点' }),
).toBeNull();
expect(within(await cardOf('叶节点')).getByText(/次游玩/u)).toBeTruthy();
});
});
describe('GameComparePage', () => {
it('左右并排渲染两侧作品并复用游玩页的同源 iframe', async () => {
getGameMock.mockImplementation((gameId: string) =>
Promise.resolve(
gameId === 'game-parent'
? createGame({ id: 'game-parent', title: '原版作品' })
: createGame({
id: 'game-child',
title: '二创作品',
lineage: {
generation: 1,
rootGameId: 'game-parent',
rootTitle: '原版作品',
parentGameId: 'game-parent',
parentTitle: '原版作品',
parentAuthorName: '原作者',
},
}),
),
);
render(
<GameComparePage
leftGameId="game-parent"
rightGameId="game-child"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(await screen.findByText('原版作品')).toBeTruthy();
expect(screen.getByText('二创作品')).toBeTruthy();
expect(screen.getByText('母版')).toBeTruthy();
expect(screen.getByText('第 1 代')).toBeTruthy();
const frames = document.querySelectorAll('iframe.game-player-frame');
expect(frames).toHaveLength(2);
// 复用游玩页的嵌入属性:只允许脚本、不给同源、不带 referrer。
expect(frames[0]?.getAttribute('sandbox')).toBe('allow-scripts');
expect(frames[0]?.getAttribute('referrerpolicy')).toBe('no-referrer');
expect(frames[0]?.getAttribute('src')).toContain('/releases/version-1/');
});
it('任一侧不可读时只降级那一侧,另一侧照常渲染', async () => {
getGameMock.mockImplementation((gameId: string) =>
gameId === 'game-parent'
? Promise.reject(new Error('作品不存在或未公开'))
: Promise.resolve(createGame({ id: 'game-child', title: '二创作品' })),
);
render(
<GameComparePage
leftGameId="game-parent"
rightGameId="game-child"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(await screen.findByText('作品不存在或未公开')).toBeTruthy();
expect(screen.getByText('二创作品')).toBeTruthy();
// 降级那一侧不渲染 iframe,另一侧照常。
expect(document.querySelectorAll('iframe.game-player-frame')).toHaveLength(
1,
);
});
});
describe('GameThemesPage', () => {
it('渲染主题卡片(名称 / 角标 / 可见成员数)并可进入主题页', async () => {
listThemesMock.mockResolvedValue({
themes: [
{
themeId: 'theme-1',
name: '精选共创',
summary: '运营精选的共创主题',
badge: '精选',
memberCount: 2,
},
],
nextCursor: null,
});
const onOpenTheme = vi.fn();
render(<GameThemesPage onBack={vi.fn()} onOpenTheme={onOpenTheme} />);
expect(await screen.findByText('精选共创')).toBeTruthy();
expect(screen.getByText('精选')).toBeTruthy();
expect(screen.getByText('2 棵作品树')).toBeTruthy();
// 末页 nextCursor 为 null:不渲染「加载更多」。
expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /精选共创/u }));
expect(onOpenTheme).toHaveBeenCalledWith('theme-1');
});
it('有 nextCursor 时渲染「加载更多」并用游标追加下一页', async () => {
listThemesMock
.mockResolvedValueOnce({
themes: [
{
themeId: 'theme-1',
name: '第一页主题',
summary: '',
badge: '',
memberCount: 1,
},
],
nextCursor: '100:theme-1',
})
.mockResolvedValueOnce({
themes: [
{
themeId: 'theme-2',
name: '第二页主题',
summary: '',
badge: '',
memberCount: 3,
},
],
nextCursor: null,
});
render(<GameThemesPage onBack={vi.fn()} onOpenTheme={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
await waitFor(() =>
expect(listThemesMock).toHaveBeenLastCalledWith('100:theme-1'),
);
expect(await screen.findByText('第二页主题')).toBeTruthy();
// 追加而不是替换。
expect(screen.getByText('第一页主题')).toBeTruthy();
expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull();
});
it('没有主题时给出空态,失败时给出错误与重试', async () => {
listThemesMock.mockResolvedValueOnce({ themes: [], nextCursor: null });
render(<GameThemesPage onBack={vi.fn()} onOpenTheme={vi.fn()} />);
expect(
await screen.findByText('还没有已发布的共创主题,稍后再来看看。'),
).toBeTruthy();
cleanup();
listThemesMock.mockRejectedValueOnce(new Error('网络错误'));
render(<GameThemesPage onBack={vi.fn()} onOpenTheme={vi.fn()} />);
expect(await screen.findByText('网络错误')).toBeTruthy();
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy();
});
});
describe('GameThemePage', () => {
const rootGame = (id: string, title: string) =>
createGame({
id,
title,
lineage: {
generation: 0,
rootGameId: id,
rootTitle: title,
parentGameId: id,
parentTitle: title,
parentAuthorName: '原作者',
},
});
it('母版行的「N 次游玩」用子树合计(复用内嵌树那一次 /lineage,不多发请求)', async () => {
getThemeMock.mockResolvedValue({
themeId: 'theme-1',
name: '精选共创',
badge: null,
summary: null,
memberCount: 1,
roots: [
createGame({
id: 'game-root-1',
title: '根一',
playCount: 3,
lineage: { generation: 0 },
}),
],
});
getGameLineageMock.mockResolvedValue({
rootGameId: 'game-root-1',
root: createLineageNode({
gameId: 'game-root-1',
title: '根一',
generation: 0,
parentGameId: null,
playCount: 3,
}),
nodes: [
createLineageNode({
gameId: 'game-root-1',
title: '根一',
generation: 0,
parentGameId: null,
playCount: 3,
}),
createLineageNode({
gameId: 'game-child-1',
title: '衍生一',
generation: 1,
parentGameId: 'game-root-1',
playCount: 4,
}),
],
truncated: false,
});
render(
<GameThemePage
themeId="theme-1"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
// 合计 = 3 + 4 = 7(不是服务端的自身值 3),并给出分解。
const total = await screen.findByLabelText('游玩次数合计');
await waitFor(() =>
expect(total.textContent).toBe('7 次游玩(自身 3 + 衍生 4)'),
);
expect(screen.queryByText('3 次游玩')).toBeNull();
// 合计来自内嵌树那一次请求:主题页自己没有额外再拉一份 lineage。
expect(getGameLineageMock).toHaveBeenCalledTimes(1);
expect(getGameLineageMock).toHaveBeenCalledWith('game-root-1');
});
it('渲染主题头与逐根的内嵌作品树(复用族谱页)', async () => {
getThemeMock.mockResolvedValue({
themeId: 'theme-1',
name: '精选共创',
summary: '运营精选的共创主题',
badge: '精选',
memberCount: 2,
roots: [rootGame('game-root-1', '根一'), rootGame('game-root-2', '根二')],
});
getGameLineageMock.mockImplementation((rootGameId: string) =>
Promise.resolve({
rootGameId,
root: createLineageNode({
gameId: rootGameId,
title: `树 ${rootGameId}`,
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: rootGameId,
title: `树 ${rootGameId}`,
generation: 0,
parentGameId: null,
}),
],
truncated: false,
}),
);
const onOpenLineage = vi.fn();
render(
<GameThemePage
themeId="theme-1"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
onOpenLineage={onOpenLineage}
/>,
);
expect(await screen.findByText('精选共创')).toBeTruthy();
expect(screen.getByText('运营精选的共创主题')).toBeTruthy();
// 两个根各内嵌一棵树:树数据来自同一个 `/lineage` 查询。
expect(await screen.findByText('树 game-root-1')).toBeTruthy();
expect(screen.getByText('树 game-root-2')).toBeTruthy();
await waitFor(() => expect(getGameLineageMock).toHaveBeenCalledTimes(2));
expect(getGameLineageMock).toHaveBeenCalledWith('game-root-1');
expect(getGameLineageMock).toHaveBeenCalledWith('game-root-2');
// 内嵌模式不渲染族谱页外壳(页头/返回按钮),但保留「全屏查看」入口。
expect(
screen.getAllByRole('button', { name: '全屏查看族谱' }),
).toHaveLength(2);
fireEvent.click(
screen.getAllByRole('button', { name: '全屏查看族谱' })[0]!,
);
expect(onOpenLineage).toHaveBeenCalledWith('game-root-1', 'game-root-1');
});
it('已发布但没有可见成员时给出空态而不是错误', async () => {
getThemeMock.mockResolvedValue({
themeId: 'theme-2',
name: '空主题',
summary: '',
badge: '',
memberCount: 0,
roots: [],
});
render(
<GameThemePage
themeId="theme-2"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(
await screen.findByText('这个主题还没有可展示的作品树。'),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '重试' })).toBeNull();
});
it('主题不存在或未发布时走错误态(服务端 404 不返回空壳)', async () => {
getThemeMock.mockRejectedValueOnce(new Error('主题不存在'));
render(
<GameThemePage
themeId="theme-missing"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(await screen.findByText('主题不存在')).toBeTruthy();
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy();
});
it('roots 被截断时给出明确提示,且总数用 memberCount 而不是 roots 长度', async () => {
getThemeMock.mockResolvedValue({
themeId: 'theme-3',
name: '大主题',
summary: '',
badge: '',
memberCount: 120,
rootsTruncated: true,
roots: [rootGame('game-root-1', '根一')],
});
getGameLineageMock.mockResolvedValue({
rootGameId: 'game-root-1',
root: null,
nodes: [],
truncated: false,
});
render(
<GameThemePage
themeId="theme-3"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
// 总数是真实可见成员数(120),不是这一页拿到的 1 个。
expect(await screen.findByText('共 120 棵作品树')).toBeTruthy();
expect(screen.getByText(/这里只显示前 1 个,共 120 个/u)).toBeTruthy();
expect(screen.queryByText('共 1 棵作品树')).toBeNull();
});
});
describe('GameDetailPage 共创主题入口', () => {
it('渲染所属共创主题角标并可进入主题页', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
themes: [{ themeId: 'theme-1', name: '精选共创', badge: '精选' }],
}),
);
const onOpenTheme = vi.fn();
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenTheme={onOpenTheme}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: /精选共创/u }));
expect(onOpenTheme).toHaveBeenCalledWith('theme-1');
});
it('没有所属主题时不渲染主题容器(空数组不是缺键)', async () => {
getGameMock.mockResolvedValueOnce(createGame({ themes: [] }));
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenTheme={vi.fn()}
/>,
);
await screen.findByRole('heading', { name: '星轨防线' });
expect(document.querySelector('.game-fork-theme-list')).toBeNull();
});
});
/** 4 节点、3 代的血缘链数据集(根 → 1 代 → 2 代 → 当前),供详情页血缘链用例共用。 */
function buildChainDataset() {
return {
rootGameId: 'game-root',
root: null,
nodes: [
createLineageNode({
gameId: 'game-root',
title: '根原创',
authorName: '根作者',
generation: 0,
parentGameId: null,
}),
createLineageNode({
gameId: 'game-mid',
title: '一代作品',
authorName: '一代作者',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-current',
title: '当前作品',
authorName: '当前作者',
generation: 2,
parentGameId: 'game-mid',
}),
],
truncated: false,
};
}
/**
* 父代 / 子代数据集:root → mid → current(锚点)→ child1/child2(直接子代),
* child1 → grandchild(孙代,不算「直接子代」);root → uncle 是旁支。
*/
/** 演示树形状(`共创演示·A0`):A0 → A1/A2/A3;A1 → A11/A12;A11 → A111;A2 → A21,全部 0 热度。 */
const demoShapeTree = {
rootGameId: 'A0',
root: createLineageNode({
gameId: 'A0',
title: '母版甲',
generation: 0,
parentGameId: null,
playCount: 0,
}),
nodes: [
['A0', '母版甲', 0, null],
['A1', '一代甲', 1, 'A0'],
['A2', '一代乙', 1, 'A0'],
['A3', '一代丙', 1, 'A0'],
['A11', '二代甲', 2, 'A1'],
['A12', '二代乙', 2, 'A1'],
['A21', '二代丙', 2, 'A2'],
['A111', '三代甲', 3, 'A11'],
].map(([gameId, title, generation, parentGameId]) =>
createLineageNode({
gameId: gameId as string,
title: title as string,
generation: generation as number,
parentGameId: parentGameId as string | null,
playCount: 0,
}),
),
truncated: false,
};
const relationTree = {
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '根原创',
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '根原创',
generation: 0,
parentGameId: null,
}),
createLineageNode({
gameId: 'game-mid',
title: '中间代',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-uncle',
title: '旁支叔伯',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-current',
title: '当前作品',
generation: 2,
parentGameId: 'game-mid',
}),
createLineageNode({
gameId: 'game-child-1',
title: '子代甲',
generation: 3,
parentGameId: 'game-current',
}),
createLineageNode({
gameId: 'game-child-2',
title: '子代乙',
generation: 3,
parentGameId: 'game-current',
}),
createLineageNode({
gameId: 'game-grandchild',
title: '孙代作品',
generation: 4,
parentGameId: 'game-child-1',
}),
],
truncated: false,
};
/** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器:多个 describe 共用。 */
/**
* 「树里的元素」判定:**图下方的父代/子代块**(`.lineage-relations`)不算树。
*
* 为什么不用「先拿容器再查」:容器(画布/大纲)是异步挂载的,基于容器的查询可能早于挂载而回退到
* body,于是撞上关系块里的同名文案。这里改成「全页查 + 过滤掉关系块」,既不用等容器也不会撞。
*/
/**
* 树里的元素判定:图下方的父代/子代清单不算树。
*/
const isInTree = (element: Element) =>
element.closest('.lineage-relations') === null;
const treeTexts = async (text: string | RegExp) =>
(await screen.findAllByText(text)).filter(isInTree);
/** 树里的节点卡(外层定位/状态 wrapper)。 */
const treeNodeCards = () =>
[...document.querySelectorAll('.game-lineage-node')].filter(
(node) => !node.closest('.lineage-relations'),
);
/** 卡内标题文本(卡现在就是广场卡组件:标题在 `.game-card__title-row > strong`)。 */
const cardTitleOf = (node: Element) =>
(
node.querySelector('.game-card__title-row > strong')?.textContent ?? ''
).trim();
/** 按标题找树里的节点卡(wrapper)——等它渲染出来。 */
const cardOf = async (title: string) =>
waitFor(() => {
const found = treeNodeCards().find((node) => cardTitleOf(node) === title);
if (!found) throw new Error(`树里还没有《${title}》`);
return found as HTMLElement;
});
/**
* 节点卡**本身就是一个按钮**(广场卡的整体点击入口)——没有「打开作品」这类卡内按钮。
* 需要「点开详情」时点它即可。
*/
const cardButtonOf = (node: Element) =>
node.querySelector('.game-card') as HTMLElement | null;
const openButton = (title: string) => {
const node = treeNodeCards().find((item) => cardTitleOf(item) === title);
const button = node ? cardButtonOf(node) : null;
if (!button) throw new Error(`树里找不到《${title}》卡片`);
return button;
};
/** 在树区域里按文本查找(等挂载 + 排除关系块)。 */
const findTreeText = async (text: string) => {
const [element] = await waitFor(async () => {
const found = await treeTexts(text);
if (found.length === 0) throw new Error(`树里还没有「${text}」`);
return found;
});
return element;
};
describe('GameLineagePage 可视化与交互', () => {
const treeWithSibling = {
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
createLineageNode({
gameId: 'game-mid',
title: '中间代',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-leaf',
title: '叶节点',
generation: 2,
parentGameId: 'game-mid',
}),
createLineageNode({
gameId: 'game-other',
title: '旁支',
generation: 1,
parentGameId: 'game-root',
}),
],
truncated: false,
};
it('画布节点卡片显示作者名(与血缘链/大纲同信息密度)', async () => {
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await cardOf('根原创');
// 画布(stacked)卡片必须有作者行:抽出 LineageNodeContent 时作者行一度只留在 inline
// 布局,族谱树页面因此丢了作者名(并被 check-game-distribution-lineage-e2e 抓到)。
const canvas = document.querySelector(
'.game-lineage-canvas',
) as HTMLElement;
expect(
Array.from(canvas.querySelectorAll('.game-card__meta'))
.map((element) => element.textContent)
.sort(),
).toEqual(['一代作者', '当前作者', '根作者']);
});
/** 画布上所有节点卡片的标题(排序后比较,避免依赖布局顺序)。 */
const canvasTitles = (canvas: HTMLElement) =>
[...canvas.querySelectorAll('.game-lineage-node')]
.map(
(card) =>
card.querySelector('.game-card__title-row > strong')?.textContent ??
'',
)
.sort();
it('区分父代 / 子代 / 自己 / 旁支:data-relation + 可读角标', async () => {
getGameLineageMock.mockResolvedValueOnce(relationTree);
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const currentCard = await cardOf('当前作品');
// 锚点 = 自己:不给「父代/子代」角标(它就是「当前作品」)。
expect(currentCard.getAttribute('data-relation')).toBe('self');
expect(
currentCard.querySelector('.game-lineage-node__relation'),
).toBeNull();
// 祖先链 = 父代。
for (const title of ['根原创', '中间代']) {
const card = await cardOf(title);
expect(card.getAttribute('data-relation')).toBe('ancestor');
expect(
within(card).getByText('父代', {
selector: '.game-lineage-node__relation',
}),
).toBeTruthy();
}
// 后代(含孙代)= 子代。
for (const title of ['子代甲', '子代乙', '孙代作品']) {
const card = await cardOf(title);
expect(card.getAttribute('data-relation')).toBe('descendant');
expect(
within(card).getByText('子代', {
selector: '.game-lineage-node__relation',
}),
).toBeTruthy();
}
// 旁支不冒充直系。
const uncleCard = await cardOf('旁支叔伯');
expect(uncleCard.getAttribute('data-relation')).toBe('side');
expect(uncleCard.querySelector('.game-lineage-node__relation')).toBeNull();
});
it('「只看父代与子代」:只留祖先链 + 自己 + 直接子代,再点一次恢复整棵树', async () => {
getGameLineageMock.mockResolvedValueOnce(relationTree);
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const canvas = (await screen.findByLabelText('创作族谱树')) as HTMLElement;
// 默认关:整棵树(含旁支与孙代)。
expect(canvasTitles(canvas)).toEqual(
[
'当前作品',
'中间代',
'子代乙',
'子代甲',
'旁支叔伯',
'根原创',
'孙代作品',
].sort(),
);
const toggle = screen.getByRole('button', { name: '只看父代与子代' });
expect(toggle.getAttribute('aria-pressed')).toBe('false');
fireEvent.click(toggle);
// 开:父代(祖先链)+ 自己 + **直接**子代——不多不少。
expect(canvasTitles(canvas)).toEqual(
['当前作品', '根原创', '子代乙', '子代甲', '中间代'].sort(),
);
expect(canvasTitles(canvas)).not.toContain('孙代作品');
expect(canvasTitles(canvas)).not.toContain('旁支叔伯');
// 状态有明确的可视/可读反馈:按钮文案 + aria-pressed + 说明行。
expect(
screen
.getByRole('button', { name: '只看父代与子代(已开启)' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByText(
'只显示当前作品的父代(祖先链)与直接子代,再点一次恢复整棵树',
),
).toBeTruthy();
// 取消:回到整棵树。
fireEvent.click(
screen.getByRole('button', { name: '只看父代与子代(已开启)' }),
);
expect(canvasTitles(canvas)).toEqual(
[
'当前作品',
'中间代',
'子代乙',
'子代甲',
'旁支叔伯',
'根原创',
'孙代作品',
].sort(),
);
expect(
screen
.getByRole('button', { name: '只看父代与子代' })
.getAttribute('aria-pressed'),
).toBe('false');
});
it('子树高亮恒生效、开关已删除(不保留恒为 true 的控件)', async () => {
getGameLineageMock.mockResolvedValueOnce(relationTree);
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await cardOf('当前作品');
// ⑥ 复选框不存在(不是禁用、不是隐藏文案)。
expect(screen.queryByText('高亮整棵子树')).toBeNull();
expect(screen.queryByLabelText('高亮整棵子树')).toBeNull();
// 其他筛选控件还在,说明删的是这一个而不是整行。
expect(screen.getByLabelText('按热度排序')).toBeTruthy();
// 行为恒生效:hover 一个中间节点 → 它的**后代**(孙代)不降透明,旁支才降透明。
// 没有开关可关掉它,因为这段行为不再依赖任何状态。
const midCard = await cardOf('中间代');
fireEvent.mouseEnter(midCard);
expect((await cardOf('孙代作品')).className).not.toContain(
'game-lineage-node--dimmed',
);
expect((await cardOf('旁支叔伯')).className).toContain(
'game-lineage-node--dimmed',
);
// 子树里的节点带上「子树」强化类,与祖先路径(path)区分开。
expect((await cardOf('孙代作品')).className).toContain(
'game-lineage-node--subtree',
);
fireEvent.mouseLeave(midCard);
expect((await cardOf('旁支叔伯')).className).not.toContain(
'game-lineage-node--dimmed',
);
});
it('大纲行角标成组、绝不叠印(代际/主干/当前作品/关系同组,CSS 里不再有各自的绝对定位)', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
render(
<GameLineagePage
rootGameId="A0"
highlightGameId="A111"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
const rows = [...outline.querySelectorAll('.lineage-outline__row')];
expect(rows.length).toBe(8);
for (const row of rows) {
const badges = row.querySelector('.game-lineage-node__badges');
expect(badges).toBeTruthy();
// 角标组的直接子元素 = 全部角标(没有任何角标被留在封面里各排各的)。
for (const chip of [...badges!.children]) {
expect(chip.parentElement).toBe(badges);
}
expect(
row.querySelector(
'.game-lineage-node__cover > .game-lineage-node__badge',
),
).toBeNull();
expect(
row.querySelector(
'.game-lineage-node__cover > .game-lineage-node__trunk',
),
).toBeNull();
}
// 主干在大纲里也出现(A0 / A1 / A11 / A111 是各自代的主干),且不再与代际角标各占各的绝对位置。
expect(
[...outline.querySelectorAll('.game-lineage-node__trunk')].length,
).toBe(4);
// jsdom 不做布局(拿不到真实几何),所以这里钉的是**结构 + CSS 契约**:
// 角标组内一律静态定位,旧的「代际左上 / 主干右上」绝对定位必须已被删除。
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
expect(css).toContain('.game-lineage-node__badges {');
expect(css).toMatch(
/\.game-lineage-node__badges \.game-lineage-node__badge,[\s\S]{0,600}?position: static;/u,
);
expect(css).not.toMatch(
/\.game-lineage-node__badge \{[^}]*?position: absolute/u,
);
expect(css).not.toMatch(
/\.game-lineage-node__trunk \{[^}]*?position: absolute/u,
);
});
it('大纲限宽居中、操作留在内容列右端;缩略图与画布统一 16:9', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
render(
<GameLineagePage
rootGameId="A0"
highlightGameId="A111"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
const rows = [...outline.querySelectorAll('.lineage-outline__row')];
expect(rows.length).toBe(8);
// 操作是行的**最后一个子元素**(在行内 = 内容列右端),不会飘到屏幕最右。
for (const row of rows) {
expect(row.lastElementChild?.className).toContain(
'lineage-outline__actions',
);
}
// 内容列限宽 + 居中:宽屏不再整屏出血。
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
expect(css).toMatch(/\.lineage-outline \{[\s\S]{0,500}?max-width: 54rem;/u);
expect(css).toMatch(/\.lineage-outline \{[\s\S]{0,500}?margin: 0 auto;/u);
// 缩略图 16:9(与画布同一比例),不再被角标撑成窄竖条。
expect(css).toMatch(
/\.game-lineage-node__content--inline \.game-lineage-node__cover \{[\s\S]{0,300}?aspect-ratio: 16 \/ 9;/u,
);
expect(LINEAGE_COVER_ASPECT).toBeCloseTo(16 / 9, 5);
});
it('初始适配:装得下就全览、装不下就停在可读下限(横向铺满)并明确提示', async () => {
// 情形 1:视口够大 → 宽高同时 fit,所有可见节点都在视口内、无提示。
const wideWidth = vi
.spyOn(Element.prototype, 'clientWidth', 'get')
.mockReturnValue(1600);
const wideHeight = vi
.spyOn(Element.prototype, 'clientHeight', 'get')
// 卡片改成信息区版式后整棵树更高(4 层 × ~400 + 间距 ≈ 1900),
// 要装下并在可读下限之上,视口就得足够高。
.mockReturnValue(1500);
try {
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
const first = render(
<GameLineagePage
rootGameId="A0"
highlightGameId="A111"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const canvas = (await screen.findByLabelText(
'创作族谱树',
)) as HTMLElement;
await waitFor(() =>
expect(canvas.querySelectorAll('.game-lineage-node').length).toBe(8),
);
const view = readCanvasViewport(canvas);
expect(visibleCanvasCards(canvas, view).length).toBe(8);
expect(screen.queryByText(/其余代际在视口外/u)).toBeNull();
first.unmount();
// 情形 2:视口装不下全览(低于可读下限)→ 停在可读下限 + 明确提示,不静默丢代际。
wideWidth.mockReturnValue(420);
wideHeight.mockReturnValue(320);
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
render(
<GameLineagePage
rootGameId="A0"
highlightGameId="A111"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const smallCanvas = (await screen.findByLabelText(
'创作族谱树',
)) as HTMLElement;
await waitFor(() =>
expect(smallCanvas.querySelectorAll('.game-lineage-node').length).toBe(
8,
),
);
const smallView = readCanvasViewport(smallCanvas);
const visible = visibleCanvasCards(smallCanvas, smallView);
const floored = Math.abs(smallView.scale - 0.8) < 0.001;
// 二选一(都不允许「既没全览又没提示」):要么全部可见,要么停在可读下限 + 提示出现。
expect(floored || visible.length === 8).toBe(true);
if (floored) {
expect(
screen.getByText(
'树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「总览」看全树。',
),
).toBeTruthy();
// 停在可读下限时仍然把当前作品放进视口(不是把它也丢出去)。
expect(visible).toContain(
smallCanvas.querySelector('.game-lineage-node--anchor'),
);
}
} finally {
wideWidth.mockRestore();
wideHeight.mockRestore();
}
});
it('停在可读下限时横向铺满视口:树又宽又高时不留左右大片空白', async () => {
// 宽而矮的视口:按宽度适配(>1)比按高度全览(<1)大得多 —— 旧口径会取 0.6 的可读下限,
// 于是内容只占视口宽度的一半(截图反馈「留白过多」);新口径取「宽度适配」把横向铺满。
const widthSpy = vi
.spyOn(Element.prototype, 'clientWidth', 'get')
.mockReturnValue(1600);
const heightSpy = vi
.spyOn(Element.prototype, 'clientHeight', 'get')
.mockReturnValue(380);
try {
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
render(
<GameLineagePage
rootGameId="A0"
highlightGameId="A111"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const canvas = (await screen.findByLabelText(
'创作族谱树',
)) as HTMLElement;
await waitFor(() =>
expect(canvas.querySelectorAll('.game-lineage-node').length).toBe(8),
);
const content = canvas.querySelector(
'.game-lineage-canvas__content',
) as HTMLElement;
const layoutWidth = Number.parseFloat(content.style.width);
const view = readCanvasViewport(canvas);
// 可读下限之上的横向铺满 = 按宽度适配(FIT_PADDING=24 ⇒ 可用宽度 1600-48)。
const fitWidth = (1600 - 48) / layoutWidth;
expect(view.scale).toBeGreaterThan(0.8);
expect(view.scale).toBeCloseTo(fitWidth, 3);
// 内容宽度(含 FIT_PADDING 的一侧)铺到视口边缘附近:空白 ≤ 上下留白量级。
expect(layoutWidth * view.scale).toBeGreaterThan(1500);
expect(
screen.getByText(
'树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「总览」看全树。',
),
).toBeTruthy();
} finally {
widthSpy.mockRestore();
heightSpy.mockRestore();
}
});
it('画布卡片就是广场卡组件:角标走卡面 badge 槽、卡内没有动作按钮、整卡点击进详情', async () => {
getGameLineageMock.mockResolvedValueOnce(relationTree);
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const card = await cardOf('当前作品');
const cardButton = cardButtonOf(card)!;
// ① 卡面 = 广场卡组件本身(同一个 `.game-card`),角标走它的 badge 槽。
expect(cardButton.className).toContain('game-card');
const badges = cardButton.querySelector(
'.game-card__badges',
) as HTMLElement;
expect(badges).toBeTruthy();
expect(within(badges).getByText('当前作品')).toBeTruthy();
// ①' 角标**不再压在封面上**:角标槽排在封面之后、信息区之前(DOM 顺序即渲染顺序)。
const cover = cardButton.querySelector('.game-cover') as HTMLElement;
const body = cardButton.querySelector('.game-card__body') as HTMLElement;
expect(cover.compareDocumentPosition(badges)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(badges.compareDocumentPosition(body)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
// ①'' 竖版画布有左侧代际轴 → 卡上不再重复「第 N 代」;玩数由卡面承载 → 角标里没有 ▶N。
expect(cardButton.querySelector('.game-lineage-node__generation')).toBeNull();
expect(cardButton.querySelector('.game-lineage-node__play-count')).toBeNull();
// ② 卡内**没有任何动作按钮**(打开作品 / 开始共创 都已从卡面移除)。
expect(within(cardButton).queryByRole('button')).toBeNull();
expect(within(cardButton).queryByText('打开作品')).toBeNull();
expect(within(cardButton).queryByText('开始共创')).toBeNull();
// ③ 作者走广场卡的 meta 行。
expect(cardButton.querySelector('.game-card__meta')?.textContent).toMatch(
/作者/u,
);
// ④ 整卡点击 = 进详情。
fireEvent.click(cardButton);
expect(onOpenDetail).toHaveBeenCalledWith('game-current');
// ⑤ 卡面适配与角标配色(沿用上一轮的变体色,未新增色值)。
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
expect(css).toMatch(
/\.game-card--lineage \.game-cover \{[\s\S]{0,200}?aspect-ratio: 16 \/ 9;/u,
);
// 角标槽必须离开「卡内绝对定位浮层」(共享卡默认位置)→ 静态流式、排在封面下方。
expect(css).toMatch(
/\.game-card--lineage \.game-card__badges \{[\s\S]{0,300}?position: static;/u,
);
expect(css).toMatch(
/\.game-card--lineage \.game-card__badges > \* \{[\s\S]{0,300}?position: static;/u,
);
// 窄卡上标题独占一行、游玩数不再和标题抢行宽。
expect(css).toMatch(
/\.game-card--lineage \.game-card__title-row \{[\s\S]{0,200}?flex-direction: column;/u,
);
expect(css).toMatch(
/\.game-lineage-node__trunk \{[\s\S]{0,300}?--platform-warm-bg/u,
);
expect(css).toMatch(
/\.game-lineage-node__anchor \{[\s\S]{0,300}?background: var\(--platform-accent\)/u,
);
expect(css).toMatch(
/\.game-lineage-node__relation--ancestor \{[\s\S]{0,300}?--platform-cool-bg/u,
);
expect(css).toMatch(
/\.game-lineage-node__relation--descendant \{[\s\S]{0,300}?--platform-success-bg/u,
);
});
it('大纲行:按钮改名「打开作品 / 开始共创」,共创按钮用主要(填充)样式', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(relationTree);
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
const row = outline.querySelector('.lineage-outline__row')!;
const actions = row.querySelector('.lineage-outline__actions')!;
expect(actions.querySelectorAll('button')).toHaveLength(2);
expect(
within(actions as HTMLElement).getByRole('button', { name: /打开/u })
.textContent,
).toBe('打开作品');
const coCreate = within(actions as HTMLElement).getByRole('button', {
name: /开始共创/u,
});
expect(coCreate.textContent).toBe('开始共创');
expect(coCreate.className).toContain('lineage-outline__action--co-create');
// 主要/次要的视觉区分同样落在大纲行(不只画布卡片)。
const outlineCss = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
expect(outlineCss).toMatch(
/\.lineage-outline__action--co-create \{[\s\S]{0,200}?--platform-button-primary-solid/u,
);
// 动作名用**精确**断言(不扫全页文本):大纲行里恰好这两个可见名,且行内没有任何以 Fork
// 命名的可交互元素。
expect(
within(actions as HTMLElement)
.getAllByRole('button')
.map((button) => button.textContent),
).toEqual(['打开作品', '开始共创']);
expect(
within(actions as HTMLElement).queryByRole('button', { name: /Fork/u }),
).toBeNull();
});
it('滚轮 = 竖向平移(不缩放);Ctrl/⌘+滚轮 = 缩放;Shift+滚轮 = 横向平移', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const canvas = (await screen.findByLabelText('创作族谱树')) as HTMLElement;
const content = canvas.querySelector(
'.game-lineage-canvas__content',
) as HTMLElement;
const read = () => {
const m = /translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([\d.]+)\)/u.exec(
content.style.transform,
);
return {
x: Number(m?.[1]),
y: Number(m?.[2]),
scale: Number(m?.[3]),
};
};
const wheel = (init: Record<string, unknown>) =>
fireEvent.wheel(canvas, init); // 返回 false 表示事件被 preventDefault
// 先等初始适配稳定(jsdom 下退到可读缩放下限 80%),否则会把「适配后的首次写入」误当成滚轮的效果。
await waitFor(() =>
expect(document.querySelector('.game-lineage-zoom')?.textContent).toBe(
'80%',
),
);
const start = read();
// 裸滚轮:**只竖向平移**,缩放不变,且事件被 preventDefault(页面不会滚)。
expect(wheel({ deltaY: 120 })).toBe(false); // 事件被 preventDefault(页面不会跟着滚)
const afterPlain = read();
expect(afterPlain.y).not.toBe(start.y);
expect(afterPlain.x).toBe(start.x);
expect(afterPlain.scale).toBe(start.scale);
// Ctrl + 滚轮:缩放(口径写明在报告与规范里)。
const beforeZoom = read();
wheel({ deltaY: -120, ctrlKey: true });
const afterZoom = read();
expect(afterZoom.scale).not.toBe(beforeZoom.scale);
// 缩放以视口中心为锚点(jsdom 视口为 0 时中心就是 0,位移会跟着变),所以这里只断言缩放变了。
// Shift + 滚轮:横向平移。
const beforeShift = read();
wheel({ deltaY: 120, shiftKey: true });
const afterShift = read();
expect(afterShift.x).not.toBe(beforeShift.x);
expect(afterShift.y).toBe(beforeShift.y);
});
it('滚轮监听仍是非 passive 且绑在画布元素上(不许退回 React onWheel / window)', async () => {
const source = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/GameLineagePage.tsx',
'utf8',
),
);
// React 的 onWheel 是 passive 的(preventDefault 无效、页面照样滚)→ 画布上不能再用它。
expect(source).not.toContain('onWheel={handleWheel}');
expect(source).toContain("addEventListener('wheel'");
expect(source).toContain('{ passive: false }');
expect(source).toContain('viewportRef.current');
expect(source).not.toMatch(/window\.addEventListener\('wheel'/u);
// 平移必须过边界钳制,且**不再**是缩放。
expect(source).toContain('clampLineagePan');
expect(source).toMatch(/offsetY: clampLineagePan\(/u);
});
it('拖动平移:超过阈值才算拖动(禁选中/拖文本),未拖动时点击照常触发', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const canvas = (await screen.findByLabelText('创作族谱树')) as HTMLElement;
const content = canvas.querySelector(
'.game-lineage-canvas__content',
) as HTMLElement;
// 拖动前:不是 panning 态、全局也允许选择。
expect(canvas.className).not.toContain('game-lineage-canvas--panning');
expect(document.body.style.userSelect).toBe('');
// ① 只按下、不移动:不算拖动(不捕获、不拦),**节点按钮的 click 照常触发**。
fireEvent.pointerDown(canvas, { pointerId: 1, clientX: 100, clientY: 100 });
expect(canvas.className).not.toContain('game-lineage-canvas--panning');
expect(document.body.style.userSelect).toBe('');
const cardButton = cardButtonOf(await cardOf('叶节点'));
expect(cardButton).toBeTruthy();
fireEvent.click(cardButton as HTMLElement);
expect(onOpenDetail).toHaveBeenCalledTimes(1);
fireEvent.pointerUp(canvas, { pointerId: 1, clientX: 100, clientY: 100 });
// ② 移动超过阈值:进入拖动(panning 态 + 全局禁选 + transform 变化)。
const before = content.style.transform;
fireEvent.pointerDown(canvas, { pointerId: 2, clientX: 100, clientY: 100 });
fireEvent.pointerMove(canvas, { pointerId: 2, clientX: 160, clientY: 130 });
expect(canvas.className).toContain('game-lineage-canvas--panning');
expect(document.body.style.userSelect).toBe('none');
expect(content.style.transform).not.toBe(before);
// 拖动中的 selectstart / dragstart 被阻止(双保险)。
canvas.dispatchEvent(
new Event('selectstart', { bubbles: true, cancelable: true }),
);
canvas.dispatchEvent(
new Event('dragstart', { bubbles: true, cancelable: true }),
);
fireEvent.pointerUp(canvas, { pointerId: 2, clientX: 160, clientY: 130 });
expect(canvas.className).not.toContain('game-lineage-canvas--panning');
expect(document.body.style.userSelect).toBe('');
});
it('画布交互样式契约:禁选中/禁原生拖拽/滚轮到边界不外溢、图片不可拖', async () => {
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
const canvasRule = css.slice(
css.indexOf('.game-lineage-canvas {'),
css.indexOf('.game-lineage-canvas {') + 1200,
);
expect(canvasRule).toContain('user-select: none');
expect(canvasRule).toContain('touch-action: none');
expect(canvasRule).toContain('overscroll-behavior: contain');
expect(css).toContain('.game-lineage-canvas--panning');
expect(css).toMatch(
/\.game-lineage-canvas img,[\s\S]{0,200}?-webkit-user-drag: none;/u,
);
});
it('节点是真实 DOM 卡片:主体点击=选中、标题/打开作品=进详情、当前作品有标记且父在上子在下', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-leaf"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const leafCard = await cardOf('叶节点');
const rootCard = await cardOf('母版');
// 当前作品标记
expect(leafCard.getAttribute('aria-current')).toBe('true');
expect(rootCard.getAttribute('aria-current')).toBeNull();
expect(within(leafCard).getByText('当前作品')).toBeTruthy();
// 分层:子节点在父节点下方
expect(parseFloat(leafCard.style.top)).toBeGreaterThan(
parseFloat(rootCard.style.top),
);
// 正交 elbow 连线
const paths = document.querySelectorAll('path.game-lineage-edge');
expect(paths.length).toBeGreaterThan(0);
const firstEdgePath = paths[0]!.getAttribute('d') ?? '';
expect(firstEdgePath).toContain(' V ');
expect(firstEdgePath).not.toContain(' C ');
// 主行动作是**真实 button**:键盘(Tab + Enter)与点击都能打开详情。
const leafOpen = openButton('叶节点');
expect(leafOpen.tagName).toBe('BUTTON');
leafOpen.focus();
expect(document.activeElement).toBe(leafOpen);
fireEvent.click(leafOpen);
expect(onOpenDetail).toHaveBeenCalledWith('game-leaf');
// 卡面没有动作按钮:整卡(`.game-card`)点击就是「进详情」。
expect(
within(leafCard).queryByRole('button', { name: '打开作品' }),
).toBeNull();
expect(
within(leafCard).queryByRole('button', { name: /开始共创/u }),
).toBeNull();
// 整卡点击 = 进详情(选中/高亮改由悬停或键盘聚焦驱动)。
fireEvent.click(cardButtonOf(leafCard) as HTMLElement);
expect(onOpenDetail).toHaveBeenCalledTimes(2);
// 选中/高亮是 hover 语义:移入出现、移开消失(点击不再兼做选中)。
fireEvent.mouseEnter(leafCard);
expect(leafCard.className).toContain('game-lineage-node--pinned');
fireEvent.mouseLeave(leafCard);
expect(leafCard.className).not.toContain('game-lineage-node--pinned');
});
it('hover 一个节点高亮祖先路径与子树,其余节点降透明;移开后恢复', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const midCard = await cardOf('中间代');
const rootCard = await cardOf('母版');
const leafCard = await cardOf('叶节点');
const otherCard = await cardOf('旁支');
fireEvent.mouseEnter(midCard);
// 祖先(母版)+ 自身 + 子树(叶节点)都不降透明,旁支降透明。
expect(rootCard.className).not.toContain('game-lineage-node--dimmed');
expect(midCard.className).not.toContain('game-lineage-node--dimmed');
expect(leafCard.className).not.toContain('game-lineage-node--dimmed');
expect(otherCard.className).toContain('game-lineage-node--dimmed');
// 连线同样有活跃/降透明区分。
expect(
document.querySelectorAll('path.game-lineage-edge--active').length,
).toBeGreaterThan(0);
expect(
document.querySelectorAll('path.game-lineage-edge--dimmed').length,
).toBeGreaterThan(0);
fireEvent.mouseLeave(midCard);
expect(otherCard.className).not.toContain('game-lineage-node--dimmed');
expect(
document.querySelectorAll('path.game-lineage-edge--dimmed').length,
).toBe(0);
});
it('初次进入满足宽高两个维度并带可读下限;总览按钮给全览', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await cardOf('母版');
// 初次适配在 effect 里落地:先等它生效(jsdom 无布局尺寸 → min(scaleX, scaleY) 远小于 1,
// 因此命中可读下限 80%)。
await waitFor(() =>
expect(document.querySelector('.game-lineage-zoom')?.textContent).toBe(
'80%',
),
);
expect(screen.getByText(/已按可读缩放显示/u)).toBeTruthy();
// 主动点「总览」才做真正的全览(不受可读下限约束),提示随之收起。
fireEvent.click(screen.getByRole('button', { name: '总览' }));
expect(document.querySelector('.game-lineage-zoom')?.textContent).toBe(
'35%',
);
expect(screen.queryByText(/已按可读缩放显示/u)).toBeNull();
});
it('代际轴在固定层上(不随内容层缩放平移),放大后仍在画布左侧', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await cardOf('母版');
const axisLayer = document.querySelector('.game-lineage-canvas__axis');
expect(axisLayer).not.toBeNull();
expect(axisLayer?.querySelectorAll('.game-lineage-axis').length).toBe(3);
// 轴不在内容层里:不会跟着 transform 缩放/平移。
expect(
document.querySelector(
'.game-lineage-canvas__content .game-lineage-axis',
),
).toBeNull();
expect(axisLayer?.textContent).toContain('母版');
fireEvent.click(screen.getByRole('button', { name: '放大' }));
expect(document.querySelector('.game-lineage-canvas__axis')).not.toBeNull();
expect(axisLayer?.querySelectorAll('.game-lineage-axis').length).toBe(3);
});
it('节点封面:有 coverObjectKey 时用 GameCover 渲染缩略图,没有时不渲染封面块', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: null,
nodes: [
{
...createLineageNode({
gameId: 'game-with-cover',
title: '有封面',
generation: 0,
parentGameId: null,
}),
coverObjectKey: 'game-distribution/cover/one.png',
},
createLineageNode({
gameId: 'game-without-cover',
title: '没封面',
generation: 1,
parentGameId: 'game-with-cover',
}),
],
truncated: false,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await screen.findByText('有封面');
// 封面是卡片主视觉:**每个节点都有封面位**;没有 coverObjectKey 的节点走 GameCover
// 自带的「底色 + 图标」降级,所以只有带 key 的那个节点出现真实图片。
expect(
document.querySelectorAll('.game-lineage-node .game-cover'),
).toHaveLength(2);
expect(document.querySelectorAll('img.game-cover__image')).toHaveLength(1);
const withoutCover = await cardOf('没封面');
expect(withoutCover.querySelector('.game-cover')).not.toBeNull();
expect(withoutCover.querySelector('img.game-cover__image')).toBeNull();
});
it('选中路径后祖先链持续强化并给出面包屑;取消选中后复原', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const leafCard = await cardOf('叶节点');
// 选中/高亮由悬停或键盘聚焦驱动(点击现在是「进详情」)。
fireEvent.mouseEnter(leafCard);
// 面包屑:母版 → 中间代 → 叶节点,祖先移出视口也能读。
const breadcrumb = screen.getByLabelText('已选中的路径');
expect(breadcrumb.textContent).toContain('母版');
expect(breadcrumb.textContent).toContain('中间代');
expect(breadcrumb.textContent).toContain('叶节点');
// 祖先链与目标持续强化(不依赖 hover),旁支仍是被压暗的一方。
expect((await cardOf('母版')).className).toContain(
'game-lineage-node--path',
);
expect((await cardOf('中间代')).className).toContain(
'game-lineage-node--path',
);
expect(leafCard.className).toContain('game-lineage-node--pinned');
expect((await cardOf('旁支')).className).toContain(
'game-lineage-node--dimmed',
);
// 祖先链的连线也进入强化态(这正是「共创路径」的可视表达)。
expect(
document.querySelectorAll('path.game-lineage-edge--active').length,
).toBeGreaterThan(0);
fireEvent.mouseLeave(leafCard);
expect(screen.queryByLabelText('已选中的路径')).toBeNull();
expect((await cardOf('旁支')).className).not.toContain(
'game-lineage-node--dimmed',
);
});
it('缩放按钮改变百分比,「总览」重新适配', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await cardOf('母版');
const zoomLabel = () =>
document.querySelector('.game-lineage-zoom')?.textContent ?? '';
// 初次适配是 effect 里跑的:先等它落定(jsdom 无布局尺寸 → 命中可读下限 80%)。
await waitFor(() => expect(zoomLabel()).toBe('80%'));
fireEvent.click(screen.getByRole('button', { name: '放大' }));
expect(zoomLabel()).toBe('96%');
fireEvent.click(screen.getByRole('button', { name: '缩小' }));
expect(zoomLabel()).toBe('80%');
// 总览(强制全览)在 jsdom 下退到最小缩放下限。
fireEvent.click(screen.getByRole('button', { name: '总览' }));
expect(zoomLabel()).toBe('35%');
});
it('图谱卡不再打印动作:卡内没有「打开作品 / 开始共创」,整卡点击只进详情', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const leafCard = await cardOf('叶节点');
const cardButton = cardButtonOf(leafCard)!;
expect(within(cardButton).queryByRole('button')).toBeNull();
expect(within(cardButton).queryByText('开始共创')).toBeNull();
expect(within(cardButton).queryByText('打开作品')).toBeNull();
// 整卡点击 = 进详情(唤起客户端的主入口在详情页主操作行 / 大纲行)。
fireEvent.click(cardButton);
expect(onOpenDetail).toHaveBeenCalledWith('game-leaf');
expect(openForkProtocolMock).not.toHaveBeenCalled();
});
it('不可用父节点:可聚焦、不可点进详情,且不出现被删作品的标题与作者名', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: null,
nodes: [
createLineageNode({
gameId: 'game-leaf',
title: '叶节点',
generation: 1,
parentGameId: 'game-deleted',
}),
],
truncated: false,
});
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const stubTitle = await screen.findByText('原作品已不可用');
const stubCard = stubTitle.closest('.game-lineage-node') as HTMLElement;
expect(stubCard.getAttribute('tabindex')).toBe('0');
expect(stubCard.getAttribute('role')).toBeNull();
expect(stubCard.getAttribute('aria-disabled')).toBe('true');
// 被删作品只以「不可用」形式出现:没有标题、没有作者名、也没有开始共创/对比动作。
expect(document.querySelector('.game-lineage-node--stub')).toBeTruthy();
expect(screen.queryByText(/作者 game-deleted/u)).toBeNull();
// 占位节点渲染的仍是广场卡(本身是 button),但点击不导航。
fireEvent.click(cardButtonOf(stubCard) as HTMLElement);
expect(onOpenDetail).not.toHaveBeenCalled();
});
});
describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)', () => {
const tree = {
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '根原创',
generation: 0,
parentGameId: null,
playCount: 5,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '根原创',
generation: 0,
parentGameId: null,
playCount: 5,
}),
createLineageNode({
gameId: 'hot',
title: '热门二创',
generation: 1,
parentGameId: 'game-root',
playCount: 999,
}),
createLineageNode({
gameId: 'cold',
title: '冷门二创',
generation: 1,
parentGameId: 'game-root',
playCount: 1,
}),
createLineageNode({
gameId: 'deep',
title: '深代作品',
generation: 2,
parentGameId: 'hot',
playCount: 3,
}),
],
truncated: false,
};
it('顶部筛选器:世代筛选只留该代及祖先,改动类型禁用并说明原因', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await screen.findByText('深代作品');
const changeType = screen.getByLabelText('按改动类型筛选');
expect((changeType as HTMLSelectElement).disabled).toBe(true);
expect(screen.getByText('改动说明是自由文本,暂无可筛选分类')).toBeTruthy();
fireEvent.change(screen.getByLabelText('按世代筛选'), {
target: { value: '1' },
});
// 保留第 0、1 代(树不断链),第 2 代被筛掉。
expect(screen.queryByText('深代作品')).toBeNull();
expect(await cardOf('热门二创')).toBeTruthy();
expect(await cardOf('根原创')).toBeTruthy();
});
it('按热度排序:同代内 playCount 高的排在前面', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await findTreeText('热门二创');
fireEvent.click(screen.getByRole('checkbox', { name: '按热度排序' }));
const coldCard = await cardOf('冷门二创');
const hotCard = await cardOf('热门二创');
expect(parseFloat(hotCard.style.left)).toBeLessThan(
parseFloat(coldCard.style.left),
);
});
it('热度排序判据 = 自身 playCount(子树合计更高的兄弟排后面)', async () => {
// self-hot:自身 100、无子代(合计 100)。deep-hot:自身 5、但子代 200(合计 205)。
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
playCount: 1,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
playCount: 1,
}),
createLineageNode({
gameId: 'self-hot',
title: '自身热',
generation: 1,
parentGameId: 'game-root',
playCount: 100,
}),
createLineageNode({
gameId: 'deep-hot',
title: '后代热',
generation: 1,
parentGameId: 'game-root',
playCount: 5,
}),
createLineageNode({
gameId: 'deep-child',
title: '爆款孙代',
generation: 2,
parentGameId: 'deep-hot',
playCount: 200,
}),
],
truncated: false,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await findTreeText('自身热');
fireEvent.click(screen.getByRole('checkbox', { name: '按热度排序' }));
// 判据是自身:自身热(100)排在 后代热(自身 5 / 合计 205)**前面**。
expect(parseFloat((await cardOf('自身热')).style.left)).toBeLessThan(
parseFloat((await cardOf('后代热')).style.left),
);
// 主干线同理,仍然选「自身热」。
expect(
(await cardOf('自身热')).querySelector('.game-lineage-node__trunk'),
).not.toBeNull();
expect(
(await cardOf('后代热')).querySelector('.game-lineage-node__trunk'),
).toBeNull();
});
it('主干线:每代热度最高的节点带「主干」角标,主干边单独标记', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await findTreeText('热门二创');
// 第 1 代主干 = 热门二创(999 > 1);冷门二创不是主干。
expect(
(await cardOf('热门二创')).querySelector('.game-lineage-node__trunk'),
).not.toBeNull();
expect(
(await cardOf('冷门二创')).querySelector('.game-lineage-node__trunk'),
).toBeNull();
// 主干线:root→hot 与 hot→deep 两条。
expect(
document.querySelectorAll('path.game-lineage-edge--trunk').length,
).toBe(2);
});
it('选中节点后右侧信息面板给出简介/授权与「前往体验 / 开始共创此版本」', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
getGameMock.mockResolvedValueOnce(
createGame({
summary: '这是一个二创作品的简介',
forkAuthorization: 'nonCommercial',
currentVersion: {
id: 'v1',
versionNumber: 1,
changeSummary: '把跳台改成三段,并重画全部背景',
},
}),
);
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const hotCard = await cardOf('热门二创');
// 面板只在选中后出现。
expect(screen.queryByLabelText('选中作品信息')).toBeNull();
fireEvent.mouseEnter(hotCard);
const panel = await screen.findByLabelText('选中作品信息');
expect(
await within(panel).findByText('这是一个二创作品的简介'),
).toBeTruthy();
expect(within(panel).getByText(/允许非商用共创/u)).toBeTruthy();
// 「核心改动说明」字段还没上:不渲染这一行(不假装有数据)。
// 核心改动说明来自版本摘要层(selectedGame.currentVersion.changeSummary)。
expect(
within(panel).getByText(/核心改动说明:把跳台改成三段/u),
).toBeTruthy();
fireEvent.click(within(panel).getByRole('button', { name: '前往体验' }));
expect(onOpenDetail).toHaveBeenCalledWith('hot');
expect(
within(panel).getByRole('button', { name: '开始共创此版本' }),
).toBeTruthy();
// 动作名精确到**按钮**(不扫全页文本):面板里没有任何以 Fork 命名的可交互元素,
// 可见名就是上面钉住的那一个。
expect(within(panel).queryByRole('button', { name: /Fork/u })).toBeNull();
fireEvent.click(
within(panel).getByRole('button', { name: '开始共创此版本' }),
);
expect(openForkProtocolMock).toHaveBeenCalledWith('hot');
});
it('窄屏默认大纲模式:缩进层级、顺序、主干导线、无横向溢出', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
// 移动端默认不再用画布。
expect(document.querySelector('.game-lineage-canvas')).toBeNull();
// 顺序:根(0 代)→ 1 代 → 1 代 → 2 代。
const rows = [...outline.querySelectorAll('.lineage-outline__row')];
expect(rows.map((row) => row.getAttribute('data-generation'))).toEqual([
'0',
'1',
'1',
'2',
]);
const indentOf = (index: number) =>
parseFloat((rows[index] as HTMLElement).style.paddingLeft);
expect(indentOf(0)).toBe(0);
expect(indentOf(1)).toBeGreaterThan(indentOf(0));
expect(indentOf(3)).toBeGreaterThan(indentOf(1));
// 主干导线标记存在(热门二创是该代热度最高者)。
expect(
outline.querySelectorAll('.lineage-outline__guide--trunk').length,
).toBeGreaterThan(0);
// 无横向溢出:大纲是全宽行 + 左缩进,绝不横向滚。
expect(outline.scrollWidth <= outline.clientWidth).toBe(true);
});
it('大纲行:点击行是选中(不导航),行内「打开」才导航,键盘可达', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
getGameMock.mockResolvedValueOnce(createGame({ summary: '选中后的简介' }));
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
const rowButton = (await within(outline).findByRole('button', {
name: /第 1 代《热门二创》/u,
})) as HTMLElement;
expect(rowButton.tagName).toBe('BUTTON');
rowButton.focus();
expect(document.activeElement).toBe(rowButton);
fireEvent.click(rowButton);
// 点击行 = 选中,不导航。
expect(onOpenDetail).not.toHaveBeenCalled();
expect(await screen.findByLabelText('选中作品信息')).toBeTruthy();
fireEvent.click(
within(outline).getByRole('button', { name: '打开《热门二创》详情' }),
);
expect(onOpenDetail).toHaveBeenCalledWith('hot');
});
it('窄屏选中节点后用 bottom sheet 展示简介/授权/改动说明与两个操作', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
getGameMock.mockResolvedValueOnce(
createGame({
summary: '这是一个二创作品的简介',
forkAuthorization: 'nonCommercial',
currentVersion: {
id: 'v1',
versionNumber: 1,
changeSummary: '把跳台改成三段,并重画全部背景',
},
}),
);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
fireEvent.click(
await within(outline).findByRole('button', {
name: /第 1 代《热门二创》/u,
}),
);
const sheet = await screen.findByLabelText('选中作品信息');
expect(sheet.className).toContain('game-lineage-panel--sheet');
expect(within(sheet).getByText('这是一个二创作品的简介')).toBeTruthy();
expect(within(sheet).getByText(/允许非商用共创/u)).toBeTruthy();
expect(
within(sheet).getByText(/核心改动说明:把跳台改成三段/u),
).toBeTruthy();
expect(
within(sheet).getByRole('button', { name: '前往体验' }),
).toBeTruthy();
expect(
within(sheet).getByRole('button', { name: '开始共创此版本' }),
).toBeTruthy();
// 窄屏 bottom sheet 与桌面浮层同一组件:动作名同样精确到按钮(不扫全页文本)。
expect(within(sheet).queryByRole('button', { name: /Fork/u })).toBeNull();
// 遮罩点击关闭。
fireEvent.click(screen.getByLabelText('关闭信息面板'));
await waitFor(() =>
expect(screen.queryByLabelText('选中作品信息')).toBeNull(),
);
});
it('面包屑在选中后出现,当前项在链尾(横向滚动容器里常驻可见)', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
getGameMock.mockResolvedValueOnce(createGame({ summary: '简介' }));
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
fireEvent.click(
await within(outline).findByRole('button', {
name: /第 2 代《深代作品》/u,
}),
);
const breadcrumb = await screen.findByLabelText('已选中的路径');
const crumbs = [
...breadcrumb.querySelectorAll('.game-lineage-breadcrumb__crumb'),
];
expect(crumbs.map((crumb) => crumb.textContent)).toEqual([
'母版《根原创》',
'第 1 代《热门二创》',
'第 2 代《深代作品》',
]);
expect(crumbs[crumbs.length - 1]?.textContent).toContain('深代作品');
});
it('视图形态是分段控件:当前形态与 aria-pressed 一一对应(不再「画面是画布、按钮却是大纲已按下」)', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const canvasButton = await screen.findByRole('button', {
name: '图谱视图',
});
const outlineButton = screen.getByRole('button', { name: '大纲视图' });
// 桌面默认画布 → 「图谱视图」按下、「大纲视图」未按下,且两个选项常驻(不是一个翻转按钮)。
expect(document.querySelector('.game-lineage-canvas')).not.toBeNull();
expect(canvasButton.getAttribute('aria-pressed')).toBe('true');
expect(outlineButton.getAttribute('aria-pressed')).toBe('false');
expect(
document.querySelector('.game-lineage-filter__group')?.getAttribute(
'aria-label',
),
).toBe('视图形态');
fireEvent.click(outlineButton);
expect(await screen.findByLabelText('创作族谱大纲')).toBeTruthy();
expect(
screen.getByRole('button', { name: '大纲视图' }).getAttribute(
'aria-pressed',
),
).toBe('true');
expect(
screen.getByRole('button', { name: '图谱视图' }).getAttribute(
'aria-pressed',
),
).toBe('false');
});
it('画布控件栏:缩小 / 读数 / 放大 / 总览 / 刷新 五件齐全,且没有会被读成「搜索」的放大镜图标', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const controls = await screen.findByLabelText('画布控件');
const names = [...controls.querySelectorAll('button')].map((button) =>
button.getAttribute('aria-label'),
);
expect(names).toEqual(['缩小', '放大', '总览', '刷新']);
expect(controls.querySelector('.game-lineage-zoom')?.textContent).toMatch(
/^\d+%$/u,
);
// 图标是「−/+」:放大镜(lucide ZoomIn/ZoomOut)在控件栏里会被读成「搜索」,
// 而这里根本没有搜索按钮 —— 用图标名钉住不许退回。
const source = readFileSync(
'src/components/game-distribution/GameLineagePage.tsx',
'utf8',
);
expect(source).toContain('<Minus aria-hidden="true" />');
expect(source).toContain('<Plus aria-hidden="true" />');
expect(source).not.toContain('ZoomIn');
expect(source).not.toContain('ZoomOut');
});
it('图谱视图开关:移动端可切到画布(形态 B)并仍能选中节点', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
getGameMock.mockResolvedValueOnce(createGame({ summary: '简介' }));
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await screen.findByLabelText('创作族谱大纲');
fireEvent.click(screen.getByRole('button', { name: '图谱视图' }));
await waitFor(() =>
expect(document.querySelector('.game-lineage-canvas')).not.toBeNull(),
);
expect(screen.queryByLabelText('创作族谱大纲')).toBeNull();
expect(screen.getByLabelText('画布控件')).toBeTruthy();
fireEvent.mouseEnter(await cardOf('热门二创'));
expect(await screen.findByLabelText('选中作品信息')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '大纲视图' }));
expect(await screen.findByLabelText('创作族谱大纲')).toBeTruthy();
});
it('图谱视图初次进入即适配并定位当前作品(不是 zoom 100% 的空屏)', async () => {
setViewportWidth(390);
getGameLineageMock.mockResolvedValueOnce(tree);
// jsdom 不做布局:给画布一个「真机尺寸」,适配与定位才有可断言的数值。
const widthSpy = vi
.spyOn(Element.prototype, 'clientWidth', 'get')
.mockReturnValue(390);
const heightSpy = vi
.spyOn(Element.prototype, 'clientHeight', 'get')
.mockReturnValue(667);
try {
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await screen.findByLabelText('创作族谱大纲');
fireEvent.click(screen.getByRole('button', { name: '图谱视图' }));
const canvas = (await screen.findByLabelText(
'创作族谱树',
)) as HTMLElement;
const view = readCanvasViewport(canvas);
// 窄屏图谱视图是**横版**:代际沿 x 推进(同代同 left、兄弟靠 top 分开),
// 且不再渲染左侧代际轴(角标承担代际信息 —— 因此卡上**保留**世代角标)。
expect(canvas.querySelector('.game-lineage-canvas__axis')).toBeNull();
expect(
canvas.querySelector('.game-lineage-node__generation')?.textContent,
).toBe('母版');
const cards = [...canvas.querySelectorAll('.game-lineage-node')].map(
(card) => card as HTMLElement,
);
const generationOne = cards.filter(
(card) => card.getAttribute('data-generation') === '1',
);
expect(generationOne.length).toBeGreaterThanOrEqual(2);
expect(new Set(generationOne.map((card) => card.style.left)).size).toBe(
1,
);
expect(new Set(generationOne.map((card) => card.style.top)).size).toBe(
generationOne.length,
);
expect(Math.round(view.scale * 100)).not.toBe(100);
expect(canvas.querySelector('.game-lineage-zoom')?.textContent).toBe(
`${Math.round(view.scale * 100)}%`,
);
const visible = visibleCanvasCards(canvas, view);
// 「一个节点都看不到」是最初的回归:这里至少要看到 3 个,且当前作品在视口内。
expect(visible.length).toBeGreaterThanOrEqual(3);
expect(visible).toContain(
canvas.querySelector('.game-lineage-node--anchor'),
);
expect(
visible.some((card) => card.getAttribute('data-generation') === '0'),
).toBe(true);
// 移动端紧凑卡片按既有设计**省掉作者行**(CSS `.game-lineage-node--compact`:卡片更小、
// 字段更少)。作者名在桌面画布、大纲行与详情页都有;这条断言把这个差异固定下来,
// 避免它再次变成「实现与断言不同步」。
expect(
canvas.querySelectorAll('.game-lineage-node--compact').length,
).toBe(cards.length);
// 卡就是广场卡:紧凑态也显示作者行(不再省行)。
expect(canvas.querySelectorAll('.game-card__meta').length).toBe(
cards.length,
);
} finally {
widthSpy.mockRestore();
heightSpy.mockRestore();
}
});
it('图谱视图缩放触底时把当前作品滚进视口(保上下文,不只剩空屏)', async () => {
setViewportWidth(320);
getGameLineageMock.mockResolvedValueOnce(tree);
const widthSpy = vi
.spyOn(Element.prototype, 'clientWidth', 'get')
.mockReturnValue(200);
const heightSpy = vi
.spyOn(Element.prototype, 'clientHeight', 'get')
.mockReturnValue(420);
try {
render(
<GameLineagePage
rootGameId="game-root"
// 锚点是**最右代**:树比视口宽、缩放已触底,必须靠位移把它拉进来。
highlightGameId="deep"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await screen.findByLabelText('创作族谱大纲');
fireEvent.click(screen.getByRole('button', { name: '图谱视图' }));
const canvas = (await screen.findByLabelText(
'创作族谱树',
)) as HTMLElement;
const view = readCanvasViewport(canvas);
expect(view.scale).toBeGreaterThan(0);
expect(Math.round(view.scale * 100)).not.toBe(100);
const visible = visibleCanvasCards(canvas, view);
expect(visible).toContain(
canvas.querySelector('.game-lineage-node--anchor'),
);
// 当前作品进来了,但不是「只剩它」:父代仍在视口里,用户看得到上下文。
expect(visible.length).toBeGreaterThanOrEqual(2);
expect(
visible.some((card) => card.getAttribute('data-generation') === '1'),
).toBe(true);
} finally {
widthSpy.mockRestore();
heightSpy.mockRestore();
}
});
it('移动端:「只看父代与子代」开关可用,大纲行也标出父代 / 子代', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(relationTree);
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
// 默认关:整棵树(7 个节点)。
expect(outline.querySelectorAll('.lineage-outline__row').length).toBe(7);
fireEvent.click(screen.getByRole('button', { name: '只看父代与子代' }));
const rows = [...outline.querySelectorAll('.lineage-outline__row')];
// 开:父代链(根原创 / 中间代)+ 自己 + 直接子代(子代甲 / 子代乙)——不多不少。
expect(rows.length).toBe(5);
expect(rows.map((row) => row.getAttribute('data-relation')).sort()).toEqual(
['ancestor', 'ancestor', 'descendant', 'descendant', 'self'],
);
// 大纲行里同样是**可读角标**(不是只靠描边颜色)。
expect(
[...outline.querySelectorAll('.game-lineage-node__relation')]
.map((element) => element.textContent)
.sort(),
).toEqual(['子代', '子代', '父代', '父代']);
// 取消:回到整棵树。
fireEvent.click(
screen.getByRole('button', { name: '只看父代与子代(已开启)' }),
);
expect(outline.querySelectorAll('.lineage-outline__row').length).toBe(7);
});
it('大纲行显示游玩次数**子树合计**(▶ N),与角标同行、不进文字块、不额外占行', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
const first = render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
const rowOf = (title: string) =>
[...outline.querySelectorAll('.lineage-outline__row')].find((row) =>
row.textContent?.includes(title),
) as HTMLElement;
const chipOf = (title: string) =>
rowOf(title).querySelector(
'.game-lineage-node__badges > .game-lineage-node__play-count',
) as HTMLElement;
// 显示的是**合计**(自身 + 全部后代),不是自身值:
// 热门二创自身 999、子代 深代作品 3 → ▶ 1002;冷门二创自身 1 → ▶ 1;母版 5+1002+1 → ▶ 1008。
expect(chipOf('热门二创').textContent).toBe('▶ 1002');
expect(chipOf('冷门二创').textContent).toBe('▶ 1');
expect(chipOf('根原创').textContent).toBe('▶ 1008');
expect(chipOf('深代作品').textContent).toBe('▶ 3');
// 角标说明含衍生(读屏 + 悬停都能看到口径)。
expect(chipOf('热门二创').getAttribute('title')).toContain('含衍生作品');
expect(chipOf('热门二创').getAttribute('aria-label')).toBe(
'游玩次数(含衍生作品)1002',
);
// 与角标同一组(同一行),**不在**文字块里 → 不会给它单开一行。
expect(
rowOf('热门二创').querySelector(
'.game-lineage-node__text .game-lineage-node__play-count',
),
).toBeNull();
// 全 0 也显示(列稳定、不会一会儿有一会儿没有),并且同样只在角标组里。
first.unmount();
getGameLineageMock.mockResolvedValueOnce(demoShapeTree);
const zero = render(
<GameLineagePage
rootGameId="A0"
highlightGameId="A111"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const zeroOutline = await zero.findByLabelText('创作族谱大纲');
const zeroChips = [
...zeroOutline.querySelectorAll(
'.lineage-outline__row .game-lineage-node__badges > .game-lineage-node__play-count',
),
];
expect(zeroChips.length).toBe(8);
for (const chip of zeroChips) {
expect(chip.textContent).toBe('▶ 0');
}
zero.unmount();
});
it('窄屏仍能点进详情页与查看完整族谱入口(大纲模式不丢入口)', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(tree);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const outline = await screen.findByLabelText('创作族谱大纲');
expect(
within(outline).getByRole('button', { name: '打开《根原创》详情' }),
).toBeTruthy();
expect(
within(outline).getByRole('button', { name: '开始共创 根原创' }),
).toBeTruthy();
});
});
/** 读画布视口状态:内容层的 `transform: translate(...) scale(...)`。 */
function readCanvasViewport(canvas: HTMLElement) {
const content = canvas.querySelector(
'.game-lineage-canvas__content',
) as HTMLElement;
const match = /translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([\d.]+)\)/u.exec(
content.style.transform,
);
if (!match) {
throw new Error(`无法解析画布 transform: ${content.style.transform}`);
}
return {
offsetX: Number.parseFloat(match[1]!),
offsetY: Number.parseFloat(match[2]!),
scale: Number.parseFloat(match[3]!),
};
}
/** 视口内可见的节点卡片:把内容层的 transform 应用到卡片矩形,再与画布矩形求交。 */
function visibleCanvasCards(
canvas: HTMLElement,
view: { offsetX: number; offsetY: number; scale: number },
) {
return [...canvas.querySelectorAll('.game-lineage-node')].filter((node) => {
const card = node as HTMLElement;
const left = view.offsetX + Number.parseFloat(card.style.left) * view.scale;
const top = view.offsetY + Number.parseFloat(card.style.top) * view.scale;
const width = Number.parseFloat(card.style.width) * view.scale;
const height = Number.parseFloat(card.style.minHeight) * view.scale;
return (
left + width > 0 &&
top + height > 0 &&
left < canvas.clientWidth &&
top < canvas.clientHeight
);
});
}
describe('GameDetailPage 共创模块(文档对齐)', () => {
it('世代/授权标签按文档口径渲染,并显示累计衍生作品数', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
forkAuthorization: 'nonCommercial',
forkCount: 3,
lineage: {
generation: 2,
rootGameId: 'game-root',
rootTitle: '根原创',
parentGameId: 'game-parent',
parentTitle: '原初之光',
parentAuthorName: '原作者',
},
}),
);
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
expect(await screen.findByText('2 代三创')).toBeTruthy();
expect(screen.getByText('允许非商用共创')).toBeTruthy();
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy();
// 主操作按文档叫「一键开始共创」。
expect(screen.getByRole('button', { name: '一键开始共创' })).toBeTruthy();
});
it('累计创作世代数字段缺失时不渲染该概览项(不拿别的数字凑数)', async () => {
getGameMock.mockResolvedValueOnce(createGame({ forkCount: 2 }));
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
expect(await screen.findByText('累计衍生作品 2')).toBeTruthy();
expect(screen.queryByText(/累计创作世代数/u)).toBeNull();
});
it('血缘图搬到正文之后的整宽区块:侧栏共创卡里不再有图', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
id: 'game-current',
lineage: {
rootGameId: 'game-root',
parentGameId: 'game-mid',
parentTitle: '一代作品',
parentAuthorName: '一代作者',
generation: 2,
},
}),
);
getGameLineageMock.mockResolvedValue(buildChainDataset());
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
onOpenLineage={vi.fn()}
/>,
);
const block = (await screen.findByLabelText('创作血缘')).closest(
'.game-lineage-block',
) as HTMLElement;
// ① 图不在侧栏:共创卡里没有任何树渲染(画布 / 节点卡片 / 大纲都没有)。
const card = document.querySelector(
'.game-detail-side .game-detail-info-card .game-fork-card',
) as HTMLElement;
expect(card).toBeTruthy();
expect(card.querySelector('.game-lineage-canvas')).toBeNull();
expect(card.querySelector('.game-lineage-node')).toBeNull();
expect(card.querySelector('.lineage-outline')).toBeNull();
// 侧栏卡保留:世代 / 授权标签、概览数据、四个入口。
expect(within(card).getByText('2 代三创')).toBeTruthy();
expect(
within(card).getByRole('button', { name: '查看创作族谱' }),
).toBeTruthy();
// 2026-10-07:主操作「一键开始共创」已搬到**主操作行**,共创卡内不再重复出现。
expect(
within(card).queryByRole('button', { name: '一键开始共创' }),
).toBeNull();
const actionsRow = card.closest('.game-detail-display') as HTMLElement;
expect(
within(actionsRow).getByRole('button', { name: '一键开始共创' }),
).toBeTruthy();
// ② 图在**正文之后**:`.game-detail-page` 的直接子级,且排在正文容器之后。
const page = document.querySelector('.game-detail-page') as HTMLElement;
const children = [...page.children];
expect(children.indexOf(block)).toBeGreaterThan(
children.findIndex((element) =>
element.classList.contains('game-detail-display'),
),
);
// 内嵌族谱页自己拉一次 /lineage:等树真的渲染出来再断言归属。
const blockCanvas = (await within(block).findByLabelText(
'创作族谱树',
)) as HTMLElement;
expect(blockCanvas.querySelectorAll('.game-lineage-node').length).toBe(3);
expect(
within(block).getByRole('heading', { name: '这部作品的血缘图' }),
).toBeTruthy();
expect(
within(block).getByRole('button', { name: '全屏查看族谱' }),
).toBeTruthy();
});
it('游客与共创者(同一份数据):游客看不到血缘图/族谱入口/共创标签,共创者完全保留', async () => {
const gameWithLineage = () =>
createGame({
id: 'game-current',
forkAuthorization: 'nonCommercial',
forkCount: 3,
themes: [{ themeId: 'theme-1', name: '精选共创', badge: '精选' }],
lineage: {
generation: 2,
rootGameId: 'game-root',
rootTitle: '根原创',
parentGameId: 'game-mid',
parentTitle: '原初之光',
parentAuthorName: '原作者',
},
});
const renderDetail = () =>
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
onOpenLineage={vi.fn()}
onOpenCompare={vi.fn()}
onOpenTheme={vi.fn()}
/>,
);
// ① 游客(未登录):血缘相关内容与共创标签一律不渲染,但公开数据与主题入口保留。
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
getGameMock.mockResolvedValueOnce(gameWithLineage());
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
const guest = renderDetail();
await screen.findByText('累计衍生作品 3');
expect(screen.queryByLabelText('创作血缘')).toBeNull();
expect(screen.queryByRole('button', { name: '全屏查看族谱' })).toBeNull();
expect(screen.queryByRole('button', { name: '查看创作族谱' })).toBeNull();
expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull();
expect(screen.queryByText('2 代三创')).toBeNull();
expect(screen.queryByText('允许非商用共创')).toBeNull();
expect(screen.queryByText(/改编自父代/u)).toBeNull();
expect(document.querySelector('.game-fork-lineage__author')).toBeNull();
// 公开数据与主题(浏览入口,不是血缘结构)仍在。
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
expect(screen.getByRole('button', { name: /精选共创/u })).toBeTruthy();
expect(
document
.querySelector('.game-detail-page')
?.getAttribute('data-viewer-role'),
).toBe('guest');
// 图谱页自己的「图下方父代/子代清单」也不会出现(整块不渲染)。
expect(document.querySelector('.lineage-relation__grid')).toBeNull();
guest.unmount();
// ② 共创者(已登录,默认 mock):与改动前一致 —— 图、入口、标签都在。
authUiMock.value.user = { id: 'author-1' };
authUiMock.value.canAccessProtectedData = true;
getGameMock.mockResolvedValueOnce(gameWithLineage());
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
renderDetail();
expect(await screen.findByLabelText('创作血缘')).toBeTruthy();
expect(screen.getByText('2 代三创')).toBeTruthy();
expect(screen.getByText('允许非商用共创')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy();
expect(screen.getByText('父代作者 原作者')).toBeTruthy();
expect(screen.getByRole('button', { name: '查看创作族谱' })).toBeTruthy();
expect(screen.getByRole('button', { name: '与原版对比' })).toBeTruthy();
expect(
document
.querySelector('.game-detail-page')
?.getAttribute('data-viewer-role'),
).toBe('co-creator');
});
it('「全屏查看族谱」= 真·全屏 overlay(portal 到 body / Esc 与关闭按钮 / 焦点与滚动恢复)', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
id: 'game-current',
lineage: { rootGameId: 'game-root', parentGameId: 'game-mid' },
}),
);
getGameLineageMock.mockResolvedValue(buildChainDataset());
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
);
const trigger = await screen.findByRole('button', {
name: '全屏查看族谱',
});
expect(document.body.style.overflow).toBe('');
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog', {
name: '创作族谱(全屏)',
});
// portal 到 `document.body`(不在详情页/平台容器的 DOM 里)⇒ 不受容器宽度与 overflow 约束。
expect(dialog.parentElement).toBe(document.body);
expect(dialog.getAttribute('aria-modal')).toBe('true');
expect(dialog.className).toContain('lineage-fullscreen');
// 打开:锁 body 滚动 + 焦点移入 overlay。
expect(document.body.style.overflow).toBe('hidden');
expect(document.activeElement?.getAttribute('aria-label')).toBe(
'关闭全屏族谱',
);
// overlay 里就是同一套画布(不是第二套实现),且内嵌那棵树已卸载(DOM 里只剩一个画布)。
expect(within(dialog).getByLabelText('创作族谱树')).toBeTruthy();
expect(document.querySelectorAll('.game-lineage-canvas')).toHaveLength(1);
// Esc 关闭:overlay 消失、滚动锁恢复、焦点回到触发按钮、内嵌树回来。
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '创作族谱(全屏)' }),
).toBeNull(),
);
expect(document.body.style.overflow).toBe('');
expect(document.activeElement).toBe(trigger);
expect(document.querySelectorAll('.game-lineage-canvas')).toHaveLength(1);
// 关闭按钮同样能关。
fireEvent.click(screen.getByRole('button', { name: '全屏查看族谱' }));
fireEvent.click(
await screen.findByRole('button', { name: '关闭全屏族谱' }),
);
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '创作族谱(全屏)' }),
).toBeNull(),
);
});
it('封面契约:容器强制 16:9 且不被拉伸,图片 100%×100% + object-fit: cover(三处同一套)', async () => {
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
// 同一规则里必须同时具备 16:9 + 不被 flex 拉伸 + 裁切(历史上有多条同名片段,
// 所以用「同一规则内」的前瞻断言,不去猜是哪一条)。
expect(css).toMatch(
/\.game-lineage-node__cover \{(?=[^}]*aspect-ratio: 16 \/ 9)(?=[^}]*flex: 0 0 auto)(?=[^}]*overflow: hidden)[^}]*\}/u,
);
expect(css).toMatch(
/\.game-lineage-node__cover \.game-cover,[\s\S]{0,240}?height: 100%;/u,
);
expect(css).toMatch(
/\.game-lineage-node__cover img \{[\s\S]{0,120}?object-fit: cover;/u,
);
});
it('主操作行不再内联状态文本:行内不换行/不被挤扁,面板改 portal 浮层', async () => {
// 窄屏:唤起直接进入「手动获取作品 ID」的降级面板(无需等失焦超时)。
setViewportWidth(390);
getGameMock.mockResolvedValueOnce(
createGame({ id: 'game-current', forkAuthorization: 'full' }),
);
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
);
const playButton = await screen.findByRole('button', { name: /立即玩/u });
const actionsRow = playButton.closest(
'.game-detail-hero__actions',
) as HTMLElement;
expect(actionsRow).toBeTruthy();
// 结构契约:行内**没有**任何内联状态/提示节点(历史类名都不该再出现在行里)。
expect(actionsRow.querySelector('.game-collection-notice')).toBeNull();
expect(actionsRow.querySelector('.game-fork-remix-status')).toBeNull();
// 警告节点仍在(`aria-describedby` 关联要它),但视觉隐藏、不占行内空间。
for (const warning of actionsRow.querySelectorAll('.game-device-warning')) {
expect(warning.className).toContain('game-device-warning--sr');
}
// 触发唤起 → 面板必须是 portal 到 body 的浮层(不参与行布局),且可关。
fireEvent.click(screen.getByRole('button', { name: '一键开始共创' }));
const panel = await screen.findByRole('dialog', { name: '手动开始共创' });
expect(panel.parentElement).toBe(document.body);
expect(panel.className).toContain('game-fork-remix-panel--portal');
expect(
within(panel).getByRole('button', { name: '复制作品 ID' }),
).toBeTruthy();
// CSS 契约:行内子项不收缩、不折行(无论旁边再加什么元素都不会把按钮压成两行)。
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
expect(css).toMatch(
/\.game-detail-hero__actions > \* \{[^}]*flex: 0 0 auto;[^}]*white-space: nowrap;/u,
);
expect(css).toMatch(
/\.game-fork-remix-panel--portal \{[^}]*position: fixed;/u,
);
});
it('区块默认以根为作用域,并把当前作品定位进视口', async () => {
// jsdom 不做布局:给画布一个真机尺寸,初始适配与定位才有可断言的数值。
const widthSpy = vi
.spyOn(Element.prototype, 'clientWidth', 'get')
.mockReturnValue(1280);
const heightSpy = vi
.spyOn(Element.prototype, 'clientHeight', 'get')
.mockReturnValue(720);
try {
getGameMock.mockResolvedValueOnce(
createGame({
id: 'game-current',
lineage: { rootGameId: 'game-root', parentGameId: 'game-mid' },
}),
);
getGameLineageMock.mockResolvedValue(buildChainDataset());
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
);
const block = (await screen.findByLabelText('创作血缘')) as HTMLElement;
const canvas = (await within(block).findByLabelText(
'创作族谱树',
)) as HTMLElement;
await within(
block.querySelector('.game-lineage-canvas') as HTMLElement,
).findByText('根原创');
// 作用域是**根**:母版节点就在树里(不是以当前作品为顶的局部树)。
expect(canvas.textContent).toContain('根原创');
const view = readCanvasViewport(canvas);
expect(Math.round(view.scale * 100)).not.toBe(100);
const visible = visibleCanvasCards(canvas, view);
expect(visible).toContain(
canvas.querySelector('.game-lineage-node--anchor'),
);
expect(visible.length).toBeGreaterThanOrEqual(2);
} finally {
widthSpy.mockRestore();
heightSpy.mockRestore();
}
});
it('窄断点:正文区块走大纲形态且不横向溢出', async () => {
setViewportWidth(390);
getGameMock.mockResolvedValueOnce(
createGame({
id: 'game-current',
lineage: { rootGameId: 'game-root', parentGameId: 'game-mid' },
}),
);
getGameLineageMock.mockResolvedValue(buildChainDataset());
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
);
const block = (await screen.findByLabelText('创作血缘')) as HTMLElement;
const outline = await within(block).findByLabelText('创作族谱大纲');
expect(block.querySelector('.game-lineage-canvas')).toBeNull();
// 大纲是全宽行 + 左缩进:绝不横向滚。
expect(outline.scrollWidth <= outline.clientWidth).toBe(true);
// 图仍然只有一套渲染:大纲行里的节点内容复用同一个组件。
expect(
outline.querySelectorAll('.game-lineage-node__content--inline').length,
).toBeGreaterThan(0);
});
/**
* 游玩次数合计的数据集:root(10) → mid(20) → current(7) → child(5) → grandchild(3)。
* 「当前作品」的合计 = 7 + 5 + 3 = 15(自身 7 + 衍生 8)。
*/
const playCountTree = {
rootGameId: 'game-root',
root: null,
nodes: [
createLineageNode({
gameId: 'game-root',
title: '根原创',
generation: 0,
parentGameId: null,
playCount: 10,
}),
createLineageNode({
gameId: 'game-mid',
title: '中间代',
generation: 1,
parentGameId: 'game-root',
playCount: 20,
}),
createLineageNode({
gameId: 'game-current',
title: '当前作品',
generation: 2,
parentGameId: 'game-mid',
playCount: 7,
}),
createLineageNode({
gameId: 'game-child',
title: '子代',
generation: 3,
parentGameId: 'game-current',
playCount: 5,
}),
createLineageNode({
gameId: 'game-grandchild',
title: '孙代',
generation: 4,
parentGameId: 'game-child',
playCount: 3,
}),
],
truncated: false,
};
it('游玩次数按「子代叠加到父代」显示:卡里给合计 + 分解,hero 同口径', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
id: 'game-current',
playCount: 7,
forkCount: 3,
lineage: {
rootGameId: 'game-root',
parentGameId: 'game-mid',
parentTitle: '中间代',
parentAuthorName: '原作者',
generation: 2,
},
}),
);
getGameLineageMock.mockResolvedValue(playCountTree);
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
);
// 卡里的概览:合计 + 分解(不是只给一个会让人误解的数字)。
const breakdown = await screen.findByLabelText('游玩次数合计');
expect(breakdown.textContent).toBe('游玩 15 次(自身 7 + 衍生 8)');
// 自身值只出现在分解里,不再单独显示成「7 次游玩」。
expect(screen.queryByText('7 次游玩')).toBeNull();
// hero 也是同一口径(子树合计),与卡里一致。
const hero = document.querySelector('.game-detail-hero') as HTMLElement;
expect(await within(hero).findByText('15 次游玩')).toBeTruthy();
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
});
it('共创卡只留标签 + 数据 + 入口:不再渲染派生作品卡列表(热门衍生栏已并入图谱)', async () => {
getGameMock.mockResolvedValueOnce(
createGame({ id: 'game-current', forkCount: 3, lineage: null }),
);
getGameLineageMock.mockResolvedValue(buildChainDataset());
getDerivedGamesMock.mockResolvedValueOnce({
gameId: 'game-current',
nodes: [
createLineageNode({
gameId: 'child-1',
title: '被改编出来的作品',
playCount: 12,
generation: 1,
parentGameId: 'game-current',
}),
],
truncated: false,
});
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
onOpenLineage={vi.fn()}
/>,
);
const card = (await screen.findByText('累计衍生作品 3')).closest(
'.game-fork-card',
) as HTMLElement;
// 卡里保留:世代/授权标签、数据概览、查看创作族谱、主题 tag(若有)。
expect(within(card).getByText('累计衍生作品 3')).toBeTruthy();
expect(
within(card).getByRole('button', { name: '查看创作族谱' }),
).toBeTruthy();
// 卡里**不再有**派生作品卡列表:`CoCreationHotDerived` 组件与它的 `.co-creation-hot*` 样式
// 已于 2026-10-07 删除(零引用),这里钉住「它不会再回来」。
expect(card.querySelector('.co-creation-hot')).toBeNull();
expect(screen.queryByLabelText('热门衍生作品')).toBeNull();
expect(within(card).queryByText('被改编出来的作品')).toBeNull();
});
it('图谱页:图下方给出「父代」「子代」两块清单,子代按热度降序、点击可进详情', async () => {
getGameLineageMock.mockResolvedValue({
rootGameId: 'game-root',
root: null,
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
playCount: 1,
}),
createLineageNode({
gameId: 'game-mid',
title: '中间代',
generation: 1,
parentGameId: 'game-root',
playCount: 2,
}),
createLineageNode({
gameId: 'game-current',
title: '当前作品',
generation: 2,
parentGameId: 'game-mid',
playCount: 3,
}),
createLineageNode({
gameId: 'child-cold',
title: '冷门子代',
generation: 3,
parentGameId: 'game-current',
playCount: 1,
}),
createLineageNode({
gameId: 'child-hot',
title: '热门子代',
generation: 3,
parentGameId: 'game-current',
playCount: 99,
}),
createLineageNode({
gameId: 'grandchild',
title: '孙代作品',
generation: 4,
parentGameId: 'child-hot',
playCount: 5,
}),
],
truncated: false,
});
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
highlightGameId="game-current"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
// 父代 = 祖先链(根 → 直接父),不含当前作品。
const ancestors = await screen.findByRole('region', { name: '父代' });
const ancestorNames = [
...ancestors.querySelectorAll('.game-card__title-row > strong'),
].map((element) => element.textContent);
expect(ancestorNames).toEqual(['母版', '中间代']);
// 子代 = **直接子代**(不含孙代),按热度(自身 playCount)降序。
const children = screen.getByRole('region', { name: '子代' });
expect(
[...children.querySelectorAll('.game-card__title-row > strong')].map(
(element) => element.textContent,
),
).toEqual(['热门子代', '冷门子代']);
expect(within(children).queryByText('孙代作品')).toBeNull();
// 点击进详情(清单里也是广场卡:整卡就是按钮)。
fireEvent.click(
children.querySelector(
'.lineage-relation__item .game-card',
) as HTMLElement,
);
expect(onOpenDetail).toHaveBeenCalledWith('child-hot');
});
it('图谱页:没有父代/子代时两块给空态说明(不空白)', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'solo',
root: null,
nodes: [
createLineageNode({
gameId: 'solo',
title: '孤作品',
generation: 0,
parentGameId: null,
playCount: 0,
}),
],
truncated: false,
});
render(
<GameLineagePage
rootGameId="solo"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(
await screen.findByText(
'这部作品是母版,没有父代(它的后代都在上面的树里)。',
),
).toBeTruthy();
expect(screen.getByText('这部作品还没有直接子代。')).toBeTruthy();
});
it('可判定为已收藏时显示「已收藏」,点击取消收藏', async () => {
getGameMock.mockResolvedValueOnce(createGame({ collected: true }));
setGameCollectionMock.mockResolvedValueOnce({ collected: false });
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const button = await screen.findByRole('button', { name: '已收藏' });
fireEvent.click(button);
await waitFor(() =>
expect(setGameCollectionMock).toHaveBeenCalledWith(
'game-1',
false,
expect.stringContaining('game-1'),
),
);
// 以服务端返回为准:取消后回到「收藏」。
expect(await screen.findByRole('button', { name: '收藏' })).toBeTruthy();
});
it('可判定为未收藏时显示「收藏」,点击收藏并乐观切到已收藏', async () => {
getGameMock.mockResolvedValueOnce(createGame({ collected: false }));
setGameCollectionMock.mockResolvedValueOnce({ collected: true });
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const button = await screen.findByRole('button', { name: '收藏' });
fireEvent.click(button);
await waitFor(() =>
expect(setGameCollectionMock).toHaveBeenCalledWith(
'game-1',
true,
expect.stringContaining('game-1'),
),
);
expect(await screen.findByRole('button', { name: '已收藏' })).toBeTruthy();
});
it('缺 collected 字段(匿名按不返回该键)时不渲染成未收藏,点击走登录门禁', async () => {
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
// 匿名响应完全没有 collected 键——不能当 false 渲染。
getGameMock.mockResolvedValueOnce(createGame());
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const button = await screen.findByRole('button', { name: '收藏' });
expect(screen.queryByRole('button', { name: '已收藏' })).toBeNull();
expect(button.getAttribute('title')).toBe('登录后可收藏');
fireEvent.click(button);
expect(authUiMock.value.openLoginModal).toHaveBeenCalledTimes(1);
// 未登录不发收藏请求,也不伪造已收藏态。
expect(setGameCollectionMock).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '已收藏' })).toBeNull();
});
it('收藏失败时回滚乐观态并提示服务端文案', async () => {
getGameMock.mockResolvedValueOnce(createGame({ collected: false }));
setGameCollectionMock.mockRejectedValueOnce(new Error('作品未公开'));
render(
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
fireEvent.click(await screen.findByRole('button', { name: '收藏' }));
expect(await screen.findByText('作品未公开')).toBeTruthy();
// 回滚:不能停在乐观的「已收藏」。
expect(screen.getByRole('button', { name: '收藏' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '已收藏' })).toBeNull();
});
});
describe('PlatformToastHost(轻提示宿主)', () => {
afterEach(() => {
vi.useRealTimers();
});
it('portal 到 body、`role=status` + `aria-live=polite`,可手动关闭', async () => {
render(<PlatformToastHost />);
expect(screen.queryByTestId('platform-toast-host')).toBeNull();
act(() => {
showPlatformToast({ text: '作品 ID 已复制' });
});
const host = await screen.findByTestId('platform-toast-host');
// portal 到 body:不在渲染它的容器里,因此不参与任何页面布局。
expect(host.parentElement).toBe(document.body);
const toast = within(host).getByRole('status');
expect(toast.getAttribute('aria-live')).toBe('polite');
expect(toast.textContent).toContain('作品 ID 已复制');
// 键盘可达的关闭按钮。
fireEvent.click(
screen.getByRole('button', { name: '关闭提示:作品 ID 已复制' }),
);
expect(screen.queryByTestId('platform-toast-host')).toBeNull();
});
it('错误级用 `role=alert` + `aria-live=assertive`,且默认不自动消失', async () => {
vi.useFakeTimers();
render(<PlatformToastHost />);
act(() => {
showPlatformToast({ text: '开始共创失败', tone: 'error' });
});
const host = screen.getByTestId('platform-toast-host');
expect(within(host).getByRole('alert').getAttribute('aria-live')).toBe(
'assertive',
);
act(() => {
vi.advanceTimersByTime(PLATFORM_TOAST_DEFAULT_DURATION_MS * 4);
});
// 错误不会自己消失,必须用户关闭。
expect(screen.getByTestId('platform-toast-host')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /关闭提示/ }));
expect(screen.queryByTestId('platform-toast-host')).toBeNull();
});
it('普通提示自动消失(默认 5s),多条可堆叠', async () => {
vi.useFakeTimers();
render(<PlatformToastHost />);
act(() => {
showPlatformToast({ text: '第一条' });
showPlatformToast({ text: '第二条' });
});
const host = screen.getByTestId('platform-toast-host');
expect(within(host).getAllByRole('status')).toHaveLength(2);
act(() => {
vi.advanceTimersByTime(PLATFORM_TOAST_DEFAULT_DURATION_MS + 50);
});
// 两条都到点 → 宿主整块收起。
expect(screen.queryByTestId('platform-toast-host')).toBeNull();
void host;
});
it('normalizePlatformToast:默认 tone 与时长规则稳定', () => {
expect(normalizePlatformToast({ text: 'a' })).toEqual({
text: 'a',
tone: 'info',
durationMs: PLATFORM_TOAST_DEFAULT_DURATION_MS,
});
expect(
normalizePlatformToast({ text: 'a', tone: 'warning' }).durationMs,
).toBe(null);
expect(
normalizePlatformToast({ text: 'a', tone: 'error', durationMs: 800 })
.durationMs,
).toBe(800);
});
});