928fb0e616
- 新增 src/components/game-distribution/gameForkProtocol.ts 收口唤起:协议 genarrative://fork?gameId=<id>,用顶层 location.href 赋值(协议未注册时桌面浏览器只是「什么都不发生」,不会把页面导航走也不会被弹窗拦截,失败可观察),并导出降级等待窗口 FORK_LAUNCH_FALLBACK_DELAY_MS=1200 - 详情页点击「Fork 这个作品」直接唤起;失焦(window blur)或页面转入后台(visibilitychange=hidden)都视为客户端接手 → 只给状态反馈,不出降级面板 - 未失焦 → 经过降级窗口后展示手动面板(作品 ID / 完整作品链接 / 复制按钮 / 客户端指引 / 一句话告知) - 移动端与未知平台不尝试唤起(协议未注册时移动浏览器行为不可控),直接给手动路径 - 每条路径都有可观察反馈:正在唤起 / 已唤起 / 没有检测到客户端 / 当前设备无法直接唤起;定时器与失焦监听的清理覆盖卸载、重复点击、超时三条路径 - 顺手修掉同文件一个既有竞态:作者昵称补查的断言紧跟 findByRole(effect 尚未发出请求)导致偶发 0 次调用、让整份文件的门禁随机变红,改为 waitFor 等待
1528 lines
52 KiB
TypeScript
1528 lines
52 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,
|
||
GameDistributionLineageNode,
|
||
GameDistributionMyGame,
|
||
GameDistributionVersionStatusEntry,
|
||
} from '../../services/gameDistributionClient';
|
||
import { setCreatorViewer } from '../creator/creatorRelationshipState';
|
||
import { GameDetailPage } from './GameDetailPage';
|
||
import { GameGalleryPage } from './GameGalleryPage';
|
||
import { FORK_LAUNCH_FALLBACK_DELAY_MS } from './gameForkProtocol';
|
||
import { GameLineagePage } from './GameLineagePage';
|
||
import { GamePlayPage } from './GamePlayPage';
|
||
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
|
||
|
||
const listGamesMock = vi.hoisted(() => vi.fn());
|
||
const getGameMock = vi.hoisted(() => vi.fn());
|
||
const getGameLineageMock = 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,
|
||
getGameLineage: getGameLineageMock,
|
||
getMyGame: getMyGameMock,
|
||
loadVersionRecoveryActions: loadVersionRecoveryActionsMock,
|
||
recordGamePlay: recordGamePlayMock,
|
||
listGameReviews: listGameReviewsMock,
|
||
getMyGameReview: getMyGameReviewMock,
|
||
}));
|
||
|
||
/**
|
||
* 详情页的登录态:默认一个可访问受保护数据的用户(作者视角评价区要用)。
|
||
* 「未登录」用例会把它改成 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<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(() => {
|
||
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();
|
||
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);
|
||
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('');
|
||
// 补查由 effect 发起、提交后一拍才发出:这里等它发生,避免断言与 React 提交竞态
|
||
// (此前偶发 0 次调用,会让整份文件的门禁随机变红)。
|
||
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();
|
||
});
|
||
});
|
||
|
||
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,
|
||
);
|
||
});
|
||
});
|
||
|
||
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('授权为禁止共创时不出现 Fork 入口', async () => {
|
||
getGameMock.mockResolvedValueOnce(
|
||
createGame({ forkAuthorization: 'forbidden' }),
|
||
);
|
||
render(
|
||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||
);
|
||
|
||
await screen.findByRole('heading', { name: '星轨防线' });
|
||
expect(screen.queryByRole('button', { name: 'Fork 这个作品' })).toBeNull();
|
||
expect(screen.queryByRole('button', { name: '登录后可 Fork' })).toBeNull();
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|