Files
Genarrative/src/components/game-distribution/GameDistributionPages.test.tsx
T
suzmii 475acf0f41
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
共创模块A: 详情页世代/授权标签 + 一键 Fork 主操作 + 概览 + 溯源条 + 热门衍生
2026-10-06 17:06:50 +08:00

2948 lines
102 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import { 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 { 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';
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,
}));
/** Fork 唤起:只打桩「发出唤起」这一动作,协议常量仍用真实值(测试按同一阈值推进定时器)。 */
const openForkProtocolMock = vi.hoisted(() => vi.fn());
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();
setGameCollectionMock.mockReset();
setGameCollectionMock.mockResolvedValue({ collected: true });
listThemesMock.mockReset();
listThemesMock.mockResolvedValue({ themes: [], nextCursor: null });
getThemeMock.mockReset();
getThemeMock.mockResolvedValue(null);
setCreatorViewer(null);
getCreatorRelationshipMock.mockResolvedValue({
relationship: { isSelf: false, isFollowing: false, isFollowedBy: false },
});
changeCreatorRelationshipMock.mockResolvedValue({
relationship: { isSelf: false, isFollowing: true, isFollowedBy: false },
});
setViewportWidth(1024);
listGamesMock.mockResolvedValue([]);
getGameMock.mockResolvedValue(createGame());
getGameLineageMock.mockResolvedValue(null);
getDerivedGamesMock.mockReset();
getDerivedGamesMock.mockResolvedValue(null);
getMyGameMock.mockResolvedValue(null);
loadVersionRecoveryActionsMock.mockResolvedValue({});
listGameReviewsMock.mockResolvedValue({
reviews: [],
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
ratingSummary: { averageScore: null, ratingCount: 0 },
});
getMyGameReviewMock.mockResolvedValue({ review: null });
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([createGame({ ratingSummary })]);
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
expect(await screen.findByText(expected)).toBeTruthy();
});
it('ignores a stale response after search keyword changes', async () => {
const first = deferred<GameDistributionGame[]>();
const second = deferred<GameDistributionGame[]>();
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([newerGame]);
await screen.findByText('新搜索结果');
first.resolve([createGame({ id: 'game-old', title: '旧搜索结果' })]);
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(screen.getByText('请在电脑上游玩')).toBeTruthy();
});
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('展示共创授权、代际与溯源信息,并给出 Fork 入口', 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();
// 已公开 + 授权开放 → 「一键 Fork 共创」入口出现(M1 时它还不存在,这里钉住新契约)。
expect(screen.getByRole('button', { name: '一键 Fork 共创' })).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),
);
// 禁用按钮必须带可操作说明,否则用户只看到一个点不动的按钮。
const reason = screen.getByText('暂时拿不到可玩入口,请刷新页面后重试。');
expect(reason.id).toBe('game-entry-missing-warning');
expect(playButton.getAttribute('aria-describedby')).toBe(reason.id);
});
});
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(screen.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([
createGame({
id: 'game-desktop',
title: '桌面专属',
deviceSupport: { desktop: true, mobile: false, touch: false },
}),
createGame({
id: 'game-mobile',
title: '移动可玩',
deviceSupport: { desktop: false, mobile: true, touch: true },
}),
]);
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([
createGame({
id: 'game-desktop',
title: '桌面专属',
deviceSupport: { desktop: true, mobile: false, touch: false },
}),
]);
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([createGame({ title: '星轨防线' })]);
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([
createGame({
id: 'game-desktop',
title: '桌面专属',
deviceSupport: { desktop: true, mobile: false, touch: false },
}),
createGame({
id: 'game-mobile',
title: '移动可玩',
deviceSupport: { desktop: false, mobile: true, touch: true },
}),
]);
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([createGame({ title: '星轨防线' })]);
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([
createGame({ id: 'game-1', title: '星轨防线' }),
createGame({ id: 'game-2', title: '第二款' }),
]);
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([
createGame({ coverObjectKey: 'game-distribution/cover/alpha.png' }),
]);
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([
createGame({ id: 'game-none', title: '没有封面', coverObjectKey: null }),
createGame({
id: 'game-broken',
title: '换签失败',
coverObjectKey: 'game-distribution/cover/broken.png',
}),
]);
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 screen.findByText('叶节点')).toBeTruthy();
expect(screen.getByText('中间代')).toBeTruthy();
// 当前作品高亮在它自己的节点卡片上。
expect(
screen.getByText('叶节点').closest('.game-lineage-node')?.className,
).toContain('game-lineage-node--anchor');
// 父作品不在可见集合里:只标注「原作品已不可用」,不回显父作品内容。
expect(screen.getByText('原作品已不可用')).toBeTruthy();
// 任一公开节点都能进入对应详情页。
fireEvent.click(screen.getByRole('button', { name: '打开《母版》详情' }));
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 screen.findByRole('button', { name: '打开《母版》详情' }),
).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(screen.getByText('叶节点')).toBeTruthy();
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 Fork 入口', () => {
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: '一键 Fork 共创' });
// 未展开前不露出获取方式。
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();
// 冻结口径:Fork = 复制整个工程继续改造;只保留一句话告知(对他人可见 + 不可撤销)。
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: '一键 Fork 共创' });
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: '一键 Fork 共创' }),
);
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: '一键 Fork 共创' });
// 不静默隐藏:按钮在,但是 aria-disabled + 有原因(悬停 title)。
expect(blocked.getAttribute('aria-disabled')).toBe('true');
expect(blocked.getAttribute('title')).toContain('禁止共创');
// 点击给出可读原因(不发起唤起)。
fireEvent.click(blocked);
expect(
await screen.findByText(/作者未开放共创授权(禁止共创),无法 Fork 这部作品。/u),
).toBeTruthy();
expect(openForkProtocolMock).not.toHaveBeenCalled();
});
it('未公开作品不出现 Fork 入口', 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: '一键 Fork 共创' })).toBeNull();
expect(screen.queryByRole('button', { name: '登录后一键 Fork' })).toBeNull();
});
it('未登录时入口显示「登录后一键 Fork」,点击走仓库既有登录门禁而不是静默隐藏', 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: '登录后一键 Fork' });
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
fireEvent.click(gate);
expect(authUiMock.value.openLoginModal).toHaveBeenCalledTimes(1);
// 未登录既不唤起客户端,也不展开获取方式。
expect(openForkProtocolMock).not.toHaveBeenCalled();
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
});
});
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('树节点只保留代际/标题/作者/Fork:不渲染游玩数与对比入口', 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,
}),
createLineageNode({
gameId: 'game-leaf',
title: '叶节点',
generation: 1,
parentGameId: 'game-root',
}),
createLineageNode({
gameId: 'game-orphan',
title: '孤儿节点',
generation: 1,
parentGameId: 'game-missing',
}),
],
truncated: false,
});
const onOpenCompare = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
onOpenCompare={onOpenCompare}
/>,
);
expect(await screen.findByText('叶节点')).toBeTruthy();
// 树节点只保留「代际 + 标题 + 作者 + Fork」:游玩数与「与原版对比」留在详情页,
// 树里不再重复渲染(6 个节点还行,多了会吵)。
expect(screen.queryByRole('button', { name: '与原版对比' })).toBeNull();
expect(screen.queryByText(/次游玩/u)).toBeNull();
expect(screen.getByRole('button', { name: 'Fork 叶节点' })).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()}
onOpenGallery={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()}
onOpenGallery={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()}
onOpenGallery={vi.fn()}
onOpenTheme={vi.fn()}
/>,
);
expect(
await screen.findByText('还没有已发布的共创主题,稍后再来看看。'),
).toBeTruthy();
cleanup();
listThemesMock.mockRejectedValueOnce(new Error('网络错误'));
render(
<GameThemesPage
onBack={vi.fn()}
onOpenGallery={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('渲染主题头与逐根的内嵌作品树(复用族谱页)', 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();
});
});
describe('GameLineagePage 可视化与交互', () => {
/** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器。 */
const openButton = (title: string) =>
screen.getByRole('button', { name: `打开《${title}》详情` });
const cardOf = async (title: string) =>
(
await screen.findByRole('button', { name: `打开《${title}》详情` })
).closest('.game-lineage-node') as HTMLElement;
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('节点是真实 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');
fireEvent.click(within(leafCard).getByRole('button', { name: '打开作品' }));
expect(onOpenDetail).toHaveBeenCalledWith('game-leaf');
// 卡片主体点击 = 选中(不是打开详情),再点一次取消。
fireEvent.click(leafCard);
expect(onOpenDetail).toHaveBeenCalledTimes(2);
expect(leafCard.className).toContain('game-lineage-node--pinned');
fireEvent.click(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 screen.findByRole('button', { name: '打开《母版》详情' });
// 初次适配在 effect 里落地:先等它生效(jsdom 无布局尺寸 → min(scaleX, scaleY) 远小于 1,
// 因此命中可读下限 60%)。
await waitFor(() =>
expect(document.querySelector('.game-lineage-zoom')?.textContent).toBe(
'60%',
),
);
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 screen.findByRole('button', { name: '打开《母版》详情' });
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.click(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.click(leafCard);
expect(screen.queryByLabelText('已选中的路径')).toBeNull();
expect((await cardOf('旁支')).className).not.toContain(
'game-lineage-node--dimmed',
);
});
it('窄屏用紧凑卡片:少一行信息、卡片标记为 compact', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const leafCard = await cardOf('叶节点');
expect(leafCard.className).toContain('game-lineage-node--compact');
// 紧凑模式不渲染作者行,但标题/Fork/锁定仍在(可访问性不降级)。
expect(within(leafCard).queryByText('作者 作者 game-leaf')).toBeNull();
expect(
within(leafCard).getByRole('button', { name: 'Fork 叶节点' }),
).toBeTruthy();
// 卡片上只保留「打开作品 / Fork」两个动作按钮(第三个次要动作已按复验意见移除),
// 封面+标题本身也是一个打开详情的按钮。
expect(
within(leafCard).getByRole('button', { name: '打开作品' }),
).toBeTruthy();
expect(
within(leafCard).getByRole('button', { name: 'Fork 叶节点' }),
).toBeTruthy();
expect(
within(leafCard).queryByRole('button', { name: /选中|锁定/u }),
).toBeNull();
});
it('缩放按钮改变百分比,「适应窗口」重新适配', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
await screen.findByRole('button', { name: '打开《母版》详情' });
const zoomLabel = () =>
document.querySelector('.game-lineage-zoom')?.textContent ?? '';
// 初次适配是 effect 里跑的:先等它落定(jsdom 无布局尺寸 → 命中可读下限 60%)。
await waitFor(() => expect(zoomLabel()).toBe('60%'));
fireEvent.click(screen.getByRole('button', { name: '放大' }));
expect(zoomLabel()).toBe('72%');
fireEvent.click(screen.getByRole('button', { name: '缩小' }));
expect(zoomLabel()).toBe('60%');
// 适应窗口(强制全览)在 jsdom 下退到最小缩放下限。
fireEvent.click(screen.getByRole('button', { name: '适应窗口' }));
expect(zoomLabel()).toBe('35%');
});
it('节点的 Fork 次要动作不触发导航;未失焦时给出降级提示与复制入口', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const leafCard = await cardOf('叶节点');
const forkButton = within(leafCard).getByRole('button', {
name: 'Fork 叶节点',
});
vi.useFakeTimers();
fireEvent.click(forkButton);
// 次要动作直通唤起,且不抢主点击。
expect(openForkProtocolMock).toHaveBeenCalledWith('game-leaf');
expect(onOpenDetail).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(FORK_LAUNCH_FALLBACK_DELAY_MS + 10);
});
expect(screen.getByText(/没有检测到陶泥儿客户端/u)).toBeTruthy();
fireEvent.click(
screen.getByRole('button', { name: /复制 game-leaf 去 Fork/u }),
);
await act(async () => {});
expect(copyTextToClipboardMock).toHaveBeenCalledWith('game-leaf');
vi.useRealTimers();
});
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');
// 被删作品只以「不可用」形式出现:没有标题、没有作者名、也没有 Fork/对比动作。
expect(document.querySelector('.game-lineage-node--stub')).toBeTruthy();
expect(screen.queryByText(/作者 game-deleted/u)).toBeNull();
expect(within(stubCard).queryByRole('button')).toBeNull();
fireEvent.click(stubCard);
expect(onOpenDetail).not.toHaveBeenCalled();
});
});
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();
// 主操作按文档叫「一键 Fork 共创」。
expect(
screen.getByRole('button', { name: '一键 Fork 共创' }),
).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('血缘溯源条按「根 → 1 代 → 当前」渲染,点中间一代可跳该作品', async () => {
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' }));
getGameLineageMock.mockResolvedValueOnce({
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,
});
const onOpenGameDetail = vi.fn();
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={onOpenGameDetail}
/>,
);
const strip = await screen.findByLabelText('血缘溯源');
expect(strip.textContent).toContain('根原创');
expect(strip.textContent).toContain('一代作品');
expect(strip.textContent).toContain('当前作品');
// 头像位是姓名首字(链路节点没有头像字段,不伪造头像图)。
expect(strip.textContent).toContain('根');
// 当前作品标记为 current 且点击不跳转;点中间一代跳该作品。
fireEvent.click(
screen.getByRole('button', { name: /0 代作品《根原创》/u }),
);
expect(onOpenGameDetail).toHaveBeenCalledWith('game-root');
fireEvent.click(screen.getByRole('button', { name: /2 代作品《当前作品》/u }));
expect(onOpenGameDetail).toHaveBeenCalledTimes(1);
});
it('热门衍生栏按 playCount 倒序取前 5 个并可在点击后进入作品', async () => {
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-1' }));
getDerivedGamesMock.mockResolvedValueOnce({
gameId: 'game-1',
nodes: [
createLineageNode({ gameId: 'child-low', title: '低热度', playCount: 1, generation: 1, parentGameId: 'game-1' }),
createLineageNode({ gameId: 'child-hot', title: '高热度', playCount: 99, generation: 1, parentGameId: 'game-1' }),
createLineageNode({ gameId: 'child-mid', title: '中热度', playCount: 50, generation: 1, parentGameId: 'game-1' }),
],
truncated: false,
});
const onOpenGameDetail = vi.fn();
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={onOpenGameDetail}
/>,
);
const hot = await screen.findByLabelText('热门衍生作品');
const titles = ['高热度', '中热度', '低热度'];
for (const title of titles) {
expect(hot.textContent).toContain(title);
}
expect(hot.textContent?.indexOf('高热度')).toBeLessThan(
hot.textContent?.indexOf('中热度') ?? -1,
);
fireEvent.click(screen.getByRole('button', { name: /热门衍生《高热度》/u }));
expect(onOpenGameDetail).toHaveBeenCalledWith('child-hot');
});
});
describe('GameDetailPage 收藏按钮', () => {
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();
});
});