Files
Genarrative/src/components/game-distribution/GameDistributionPages.test.tsx
T
lhk229 51cb05f418
Project CI / AI game creator shell Rust crates (push) Successful in 5m56s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m36s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 7m37s
Project CI / Backend tests (push) Successful in 9m22s
Project CI / Frontend tests (push) Successful in 4m40s
Project CI / AI game creator shell web tests (push) Successful in 3m50s
Project CI / Repository checks (push) Successful in 6m30s
Project CI / Native shell tests (push) Successful in 8m32s
补充创作者主页与关注粉丝工程文档 (#638)
本 PR 补齐创作者主页与关注粉丝功能的产品合同和工程设计,供后续按里程碑实施;本次只修改文档。

桌面导航第四项进入自己的创作者主页,“我的”移到第五项。游戏详情可进入作者主页并关注/取消关注;自己或他人的关注、粉丝列表均公开可查看,点击用户头像或昵称可进入其主页。本人支持取消/重新关注、回关和移除粉丝,并明确两个关注方向独立。

工程设计包含关系表与 DTO、认证和事务边界、跨页状态同步、共享组件、深链及验收要求,拆分关系查询和页面交互两个里程碑。按用户要求,游戏列表沿用最多 48 项的现有限制,不增加游戏目录分页改造。

验证:
- 已合入最新 origin/master(21c79f54a);共享决策记录冲突保留双方新增内容。
- npm run check:encoding 通过。
- npm run check:doc-index 通过。
- git diff --check 与暂存差异检查通过。
- 相对 master 仅六份文档发生变化;没有业务代码或 schema 修改,未执行业务测试或部署。

Reviewed-on: #638
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-10-05 22:05:11 +08:00

944 lines
32 KiB
TypeScript

/* @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 { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution';
import type {
GameDistributionGame,
GameDistributionMyGame,
GameDistributionVersionStatusEntry,
} from '../../services/gameDistributionClient';
import { setCreatorViewer } from '../creator/creatorRelationshipState';
import { GameDetailPage } from './GameDetailPage';
import { GameGalleryPage } from './GameGalleryPage';
import { GamePlayPage } from './GamePlayPage';
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
const listGamesMock = vi.hoisted(() => vi.fn());
const getGameMock = 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 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,
getGame: getGameMock,
getMyGame: getMyGameMock,
loadVersionRecoveryActions: loadVersionRecoveryActionsMock,
recordGamePlay: recordGamePlayMock,
listGameReviews: listGameReviewsMock,
getMyGameReview: getMyGameReviewMock,
}));
// 作者视角详情页要按登录身份渲染评价区,这里固定一个可访问受保护数据的用户。
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => ({
user: { id: 'author-1' },
canAccessProtectedData: true,
openLoginModal: vi.fn(),
}),
}));
// 只替换换签结果,测试不发起任何真实请求;返回空串即代表签名失败或字段为空。
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<GameDistributionGame> = {},
): GameDistributionGame {
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',
...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(() => {
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
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());
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);
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('');
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();
});
});
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();
});
});
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,
);
});
});