5614 lines
201 KiB
TypeScript
5614 lines
201 KiB
TypeScript
/* @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);
|
||
});
|
||
});
|