4a2b270714
- 服务端:游戏表追加可空 cover_object_key / screenshots_json,版本表追加可空 metadata_json,创建版本时校验必需封面、最多 6 张截图与图片素材归属,并从素材记录派生对象键生成冻结资料。 - 服务端:审核通过时把冻结资料整体生效到游戏行;只有已公开且存在有效活动版本的游戏,其封面/截图素材才在 /api/assets/read-url 获得匿名读授权。 - 服务端:作者与管理员回读版本时追加 frozenMetadata,带回封面与截图素材 ID 供续发复用,公开投影仍只暴露对象键。 - 网页:/games/publish 新增「封面与截图」区,复用平台图片直传与 confirm 通道,缺封面或截图超量在发请求前拦截,更新版本时按冻结资料回填且不要求重新上传。 - 网页:游戏广场卡片与详情页 hero 换签展示真实封面,详情页新增可点击的截图缩略图条,无素材或换签失败时静默回退原有渐变占位。 - AGC:发布面板支持封面必选与截图最多 6 张,新增素材直传服务并在创建游戏前拦截缺封面,同一文件重复提交复用素材 ID。 - AGC:http 能力作用域新增阿里云 OSS 直传地址,客户端同时校验直传目标必须是平台素材存储。 - 契约与文档:同步 shared-contracts、packages/shared 契约,更新 SpacetimeDB 迁移注释、后端架构、玩法链路与实施计划文档,并补齐网页端与 AGC 定向测试。
551 lines
18 KiB
TypeScript
551 lines
18 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
act,
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { GameDistributionGame } from '../../services/gameDistributionClient';
|
|
import { GameDetailPage } from './GameDetailPage';
|
|
import { GameGalleryPage } from './GameGalleryPage';
|
|
import { GAME_PLAY_STARTUP_TIMEOUT_MS, GamePlayPage } from './GamePlayPage';
|
|
|
|
const listGamesMock = vi.hoisted(() => vi.fn());
|
|
const getGameMock = vi.hoisted(() => vi.fn());
|
|
const resolvedAssetUrlMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('../../services/gameDistributionClient', () => ({
|
|
listGames: listGamesMock,
|
|
getGame: getGameMock,
|
|
}));
|
|
|
|
// 只替换换签结果,测试不发起任何真实请求;返回空串即代表签名失败或字段为空。
|
|
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',
|
|
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();
|
|
setViewportWidth(1024);
|
|
listGamesMock.mockResolvedValue([]);
|
|
getGameMock.mockResolvedValue(createGame());
|
|
resolvedAssetUrlMock.mockImplementation((objectKey: string) =>
|
|
objectKey ? `https://assets.example.test/${objectKey}` : '',
|
|
);
|
|
});
|
|
|
|
describe('GameGalleryPage', () => {
|
|
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('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('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');
|
|
});
|
|
|
|
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'),
|
|
);
|
|
});
|
|
});
|
|
|
|
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(GAME_PLAY_STARTUP_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(GAME_PLAY_STARTUP_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-showcase img.game-cover__image')
|
|
?.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('img.game-cover__image')).toHaveLength(0);
|
|
expect(document.querySelectorAll('.game-cover__icon')).toHaveLength(1);
|
|
});
|
|
});
|