909f3d5fa2
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
- 客户端:新增 listThemes(游标分页,读 nextCursor)与 getTheme(404 由页面渲染成降级态);getGame 的返回类型增量 themes(匿名也返回;无主题时是空数组而不是缺键) - 共创 Tab:广场页新增「游戏 / 共创」页签,复用既有 game-category-tabs 结构与类名,切到 /games/themes - 主题列表页 GameThemesPage:卡片展示 name / summary / badge / memberCount;只有 nextCursor 非空才渲染「加载更多」并在点击时追加;空态与失败重试照既有写法 - 主题页 GameThemePage:标题 / 简介 / 角标 + 成员根清单;**逐根内嵌渲染作品树**——直接复用族谱页(GameLineagePage 新增 embedded 变体,只隐藏页面外壳:返回按钮 / 页头 / 脚注),树查询、可见性过滤、稳定排序与截断全部走 M3 那一份实现,不新建第二套树查询或树渲染(每根一次 /lineage 的 N+1 是技术方案 §3.10.4 已接受的取舍);已发布但可见成员为空 → 明确空态而不是错误;主题不存在 / 未发布 → 错误态 - 详情页:共创信息卡渲染所属主题角标(点击进主题页),第 N 代作品同样可见(服务端按根反查);无主题时不渲染空容器 - 路由登记:新增 /games/themes 与 /games/theme(详情用 ?id=),四处同批登记(STAGE_ROUTE_ENTRIES + SelectionStage 类型 + 壳层 lazy/回调/渲染分支 + 页面标题),并同步三份 nginx SPA allowlist 与 Pingora MAIN_SPA_PATHS:check:nginx-spa-routes(20 SPA routes / 3 模板)与 check:pingora-route-parity 均绿 - 测试:主题列表三例(渲染与卡片跳转 / 有游标时的加载更多与追加 / 空态与失败重试)、主题页三例(逐根两棵树且复用 lineage 查询 + 全屏入口参数 / 空成员空态 / 404 错误态)、详情页主题入口两例(渲染并可跳转 / 无主题不渲染容器)、路由与标题登记断言、壳层三例(页签切换 / 列表进主题页 / 主题页按 ?id= 渲染并复用族谱查询)
1602 lines
48 KiB
TypeScript
1602 lines
48 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render as testingLibraryRender,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import { type ReactElement, type ReactNode, useState } from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
|
|
import {
|
|
hasAppHistoryBackEntry,
|
|
pushAppHistoryPath,
|
|
} from '../../routing/activeAppPageRoutes';
|
|
import {
|
|
usePlatformWalletLifecycle,
|
|
usePlatformWalletStore,
|
|
} from '../../stores/usePlatformWalletStore';
|
|
import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell';
|
|
import type { SelectionStage } from './platformEntryActiveTypes';
|
|
|
|
const authUiMock = vi.hoisted(() => ({
|
|
value: {
|
|
user: null as AuthUser | null,
|
|
canAccessProtectedData: false,
|
|
openLoginModal: vi.fn(),
|
|
openAccountModal: vi.fn(),
|
|
openSettingsModal: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const responsiveMock = vi.hoisted(() => ({
|
|
isDesktopLayout: true,
|
|
}));
|
|
|
|
const profileClientMock = vi.hoisted(() => ({
|
|
getPlatformProfileDashboard: vi.fn(),
|
|
getPlatformProfileRechargeCenter: vi.fn(),
|
|
}));
|
|
|
|
const profileCenterMock = vi.hoisted(() => ({
|
|
isWalletLedgerOpen: false,
|
|
rechargeCenter: null as { walletBalance: number } | null,
|
|
}));
|
|
|
|
const gameDistributionMock = vi.hoisted(() => ({
|
|
cancelGameVersion: vi.fn(),
|
|
createGame: vi.fn(),
|
|
createGameVersion: vi.fn(),
|
|
deleteMyGame: vi.fn(),
|
|
getDerivedGames: vi.fn(),
|
|
getGame: vi.fn(),
|
|
getGameLineage: vi.fn(),
|
|
getTheme: vi.fn(),
|
|
listThemes: vi.fn(),
|
|
getGameVersion: vi.fn(),
|
|
getMyGame: vi.fn(),
|
|
listGames: vi.fn(),
|
|
listMyGames: vi.fn(),
|
|
loadVersionRecoveryActions: vi.fn(),
|
|
submitGameVersion: vi.fn(),
|
|
unpublishGame: vi.fn(),
|
|
updateMyGame: vi.fn(),
|
|
uploadGamePackage: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../services/gameDistributionClient', () => gameDistributionMock);
|
|
|
|
const creatorClientMock = vi.hoisted(() => ({
|
|
getCreator: vi.fn(),
|
|
getCreatorConnections: vi.fn(),
|
|
getCreatorRelationship: vi.fn(),
|
|
}));
|
|
vi.mock('../../services/creatorProfileClient', async (original) => ({
|
|
...(await original<typeof import('../../services/creatorProfileClient')>()),
|
|
...creatorClientMock,
|
|
}));
|
|
|
|
const frontendRuntimeConfigMock = vi.hoisted(() => ({
|
|
loadFrontendRuntimeConfig: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../services/frontendRuntimeConfigService', () => ({
|
|
loadFrontendRuntimeConfig: (...args: unknown[]) =>
|
|
frontendRuntimeConfigMock.loadFrontendRuntimeConfig(...args),
|
|
}));
|
|
|
|
vi.mock('../auth/AuthUiContext', () => ({
|
|
useAuthUi: () => authUiMock.value,
|
|
}));
|
|
|
|
vi.mock('./platformEntryResponsive', () => ({
|
|
usePlatformDesktopLayout: () => responsiveMock.isDesktopLayout,
|
|
}));
|
|
|
|
vi.mock('../creation-home/CreationLandingView', () => ({
|
|
CreationLandingView: ({
|
|
onOpenProject,
|
|
onOpenProjects,
|
|
searchKeyword,
|
|
}: {
|
|
onOpenProject: (
|
|
projectId: string,
|
|
options?: { guide?: boolean; tool?: string },
|
|
) => void;
|
|
onOpenProjects: () => void;
|
|
searchKeyword?: string;
|
|
}) => (
|
|
<main aria-label="陶泥儿创作主页" data-search={searchKeyword}>
|
|
创作主页
|
|
<button type="button" onClick={onOpenProjects}>
|
|
查看项目
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
onOpenProject('tool-project', { tool: 'background-music' })
|
|
}
|
|
>
|
|
打开音乐工具
|
|
</button>
|
|
</main>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../project/ProjectGalleryView', () => ({
|
|
ProjectGalleryView: ({
|
|
onOpenProject,
|
|
searchKeyword,
|
|
}: {
|
|
onOpenProject: (projectId: string, options?: { guide?: boolean }) => void;
|
|
searchKeyword?: string;
|
|
}) => (
|
|
<main aria-label="项目" data-search={searchKeyword}>
|
|
项目
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenProject('guide-project', { guide: true })}
|
|
>
|
|
打开引导项目
|
|
</button>
|
|
</main>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../image-editor/ImageCanvasEditorView', () => ({
|
|
ImageCanvasEditorView: ({
|
|
legacyWalletBalance,
|
|
}: {
|
|
legacyWalletBalance?: number | null;
|
|
}) => (
|
|
<main
|
|
aria-label="图片画布编辑器"
|
|
data-legacy-wallet-balance={legacyWalletBalance ?? ''}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
vi.mock(
|
|
'../../services/platform-entry/platformProfileClient',
|
|
() => profileClientMock,
|
|
);
|
|
|
|
vi.mock('./usePlatformProfileCenterController', () => ({
|
|
usePlatformProfileCenterController: () => ({
|
|
buyRechargeProduct: vi.fn(),
|
|
closeNativeWechatPayment: vi.fn(),
|
|
confirmNativeWechatPayment: vi.fn(),
|
|
isLoadingRechargeCenter: false,
|
|
isLoadingWalletLedger: false,
|
|
isRechargeOpen: false,
|
|
isWalletLedgerOpen: profileCenterMock.isWalletLedgerOpen,
|
|
loadRechargeCenter: vi.fn(),
|
|
nativeWechatPayment: null,
|
|
openWalletLedgerPanel: vi.fn(),
|
|
rechargeCenter: profileCenterMock.rechargeCenter,
|
|
rechargeError: null,
|
|
rechargePaymentResult: null,
|
|
setIsRechargeOpen: vi.fn(),
|
|
setIsWalletLedgerOpen: vi.fn(),
|
|
setRechargePaymentResult: vi.fn(),
|
|
submittingRechargeProductId: null,
|
|
walletLedger: null,
|
|
walletLedgerError: null,
|
|
wechatRechargeOrderConfirmationState: null,
|
|
}),
|
|
}));
|
|
|
|
function AuthWalletLifecycleTestBoundary({
|
|
children,
|
|
}: {
|
|
children: ReactNode;
|
|
}) {
|
|
usePlatformWalletLifecycle(
|
|
authUiMock.value.user?.id ?? null,
|
|
authUiMock.value.canAccessProtectedData,
|
|
);
|
|
return children;
|
|
}
|
|
|
|
function render(ui: ReactElement) {
|
|
return testingLibraryRender(ui, {
|
|
wrapper: AuthWalletLifecycleTestBoundary,
|
|
});
|
|
}
|
|
|
|
function StatefulPlatformEntryFlowShell({
|
|
initialStage,
|
|
}: {
|
|
initialStage: SelectionStage;
|
|
}) {
|
|
const [selectionStage, setSelectionStage] = useState(initialStage);
|
|
|
|
return (
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage={selectionStage}
|
|
setSelectionStage={(nextStage) => setSelectionStage(nextStage)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
describe('PlatformEntryActiveFlowShell', () => {
|
|
beforeEach(() => {
|
|
window.history.replaceState(null, '', '/creation');
|
|
usePlatformWalletStore.getState().resetWalletBalance();
|
|
profileClientMock.getPlatformProfileDashboard.mockReset();
|
|
profileClientMock.getPlatformProfileRechargeCenter.mockReset();
|
|
profileCenterMock.isWalletLedgerOpen = false;
|
|
profileCenterMock.rechargeCenter = null;
|
|
authUiMock.value.user = null;
|
|
authUiMock.value.canAccessProtectedData = false;
|
|
window.history.replaceState(null, '', '/creation');
|
|
for (const mock of Object.values(gameDistributionMock)) {
|
|
mock.mockReset();
|
|
}
|
|
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockReset();
|
|
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockResolvedValue({
|
|
imageEditorAgentSidebarEnabled: false,
|
|
agcTemplateLibraryEnabled: false,
|
|
gameDistributionPublishEnabled: true,
|
|
});
|
|
authUiMock.value.openLoginModal.mockReset();
|
|
responsiveMock.isDesktopLayout = true;
|
|
});
|
|
|
|
it('uses one recharge-center snapshot for the topbar and profile wallet when dashboard differs', async () => {
|
|
authUiMock.value.user = {
|
|
id: 'user-1',
|
|
publicUserCode: '100001',
|
|
displayName: '测试用户',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: null,
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
};
|
|
authUiMock.value.canAccessProtectedData = true;
|
|
profileClientMock.getPlatformProfileDashboard.mockResolvedValue({
|
|
walletBalance: 999,
|
|
totalPlayTimeMs: 0,
|
|
playedWorldCount: 0,
|
|
updatedAt: null,
|
|
});
|
|
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
|
|
walletBalance: 888,
|
|
mudPointBalance: {
|
|
totalPoints: 207,
|
|
permanentPoints: 180,
|
|
limitedPoints: 7,
|
|
limitedExpiresAt: '2026-08-31T16:00:00Z',
|
|
dailyFreePoints: 20,
|
|
dailyFreeResetPoints: 20,
|
|
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
|
|
},
|
|
});
|
|
|
|
const { rerender } = render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="creation-home"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByLabelText('泥点 207')).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy();
|
|
|
|
rerender(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="profile"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole('button', { name: '泥点余额 207' }),
|
|
).toBeTruthy();
|
|
expect(screen.queryByText('999')).toBeNull();
|
|
});
|
|
|
|
it('passes the lifecycle legacy total to profile and editor without opening recharge', async () => {
|
|
authUiMock.value.user = {
|
|
id: 'user-1',
|
|
publicUserCode: '100001',
|
|
displayName: '测试用户',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: null,
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
};
|
|
authUiMock.value.canAccessProtectedData = true;
|
|
profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null);
|
|
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
|
|
walletBalance: 37,
|
|
});
|
|
profileCenterMock.isWalletLedgerOpen = true;
|
|
|
|
const { rerender } = render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="profile"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole('button', { name: '泥点余额 37' }),
|
|
).toBeTruthy();
|
|
expect(await screen.findByText('37泥点')).toBeTruthy();
|
|
expect(screen.getByText('暂无账单记录')).toBeTruthy();
|
|
|
|
rerender(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="image-editor"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
const imageEditor = await screen.findByRole('main', {
|
|
name: '图片画布编辑器',
|
|
});
|
|
expect(imageEditor.getAttribute('data-legacy-wallet-balance')).toBe('37');
|
|
});
|
|
|
|
it('clears the previous wallet synchronously when the authenticated account changes', async () => {
|
|
authUiMock.value.user = {
|
|
id: 'user-1',
|
|
publicUserCode: '100001',
|
|
displayName: '用户一',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: null,
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
};
|
|
authUiMock.value.canAccessProtectedData = true;
|
|
profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null);
|
|
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
|
|
mudPointBalance: {
|
|
totalPoints: 66,
|
|
permanentPoints: 66,
|
|
limitedPoints: 0,
|
|
limitedExpiresAt: null,
|
|
dailyFreePoints: 0,
|
|
dailyFreeResetPoints: 20,
|
|
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
|
|
},
|
|
});
|
|
const { rerender } = render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="creation-home"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
await screen.findByLabelText('泥点 66');
|
|
|
|
let resolveNextOwner!: (value: unknown) => void;
|
|
profileClientMock.getPlatformProfileRechargeCenter.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveNextOwner = resolve;
|
|
}),
|
|
);
|
|
authUiMock.value.user = { ...authUiMock.value.user, id: 'user-2' };
|
|
rerender(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="creation-home"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByLabelText('泥点 66')).toBeNull();
|
|
await waitFor(() => {
|
|
expect(usePlatformWalletStore.getState()).toMatchObject({
|
|
ownerUserId: 'user-2',
|
|
mudPointBalance: null,
|
|
});
|
|
});
|
|
resolveNextOwner({
|
|
mudPointBalance: {
|
|
totalPoints: 67,
|
|
permanentPoints: 67,
|
|
limitedPoints: 0,
|
|
limitedExpiresAt: null,
|
|
dailyFreePoints: 0,
|
|
dailyFreeResetPoints: 20,
|
|
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
|
|
},
|
|
});
|
|
await screen.findByLabelText('泥点 67');
|
|
});
|
|
|
|
it('coalesces visibility and focus refreshes when the page returns to the foreground', async () => {
|
|
authUiMock.value.user = {
|
|
id: 'user-1',
|
|
publicUserCode: '100001',
|
|
displayName: '测试用户',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: null,
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
};
|
|
authUiMock.value.canAccessProtectedData = true;
|
|
profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null);
|
|
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
|
|
mudPointBalance: {
|
|
totalPoints: 10,
|
|
permanentPoints: 10,
|
|
limitedPoints: 0,
|
|
limitedExpiresAt: null,
|
|
dailyFreePoints: 0,
|
|
dailyFreeResetPoints: 20,
|
|
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
|
|
},
|
|
});
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="creation-home"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
await screen.findByLabelText('泥点 10');
|
|
|
|
await act(async () => {
|
|
document.dispatchEvent(new Event('visibilitychange'));
|
|
window.dispatchEvent(new Event('focus'));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
profileClientMock.getPlatformProfileRechargeCenter,
|
|
).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
it('keeps the active desktop rail and the shared account capsule', async () => {
|
|
const setSelectionStage = vi.fn();
|
|
const { container, rerender } = render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="creation-home"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole('main', { name: '陶泥儿创作主页' }),
|
|
).toBeTruthy();
|
|
const navigation = screen.getByRole('navigation', { name: '平台导航' });
|
|
expect(
|
|
within(navigation)
|
|
.getAllByRole('button')
|
|
.map((button) => button.getAttribute('aria-label')),
|
|
).toEqual(['创作', '项目', '游戏', '创作者主页', '我的']);
|
|
expect(
|
|
within(navigation)
|
|
.getByRole('button', { name: '创作' })
|
|
.getAttribute('aria-current'),
|
|
).toBe('page');
|
|
|
|
const topbar = container.querySelector('.platform-desktop-topbar');
|
|
expect(topbar).toBeTruthy();
|
|
expect(
|
|
topbar?.querySelectorAll('button.platform-desktop-search'),
|
|
).toHaveLength(1);
|
|
expect(
|
|
within(topbar as HTMLElement).queryByRole('button', { name: '设置' }),
|
|
).toBeNull();
|
|
|
|
fireEvent.change(
|
|
screen.getByRole('searchbox', { name: '搜索项目和素材' }),
|
|
{ target: { value: '角色' } },
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '搜索' }));
|
|
expect(
|
|
screen
|
|
.getByRole('main', { name: '陶泥儿创作主页' })
|
|
.getAttribute('data-search'),
|
|
).toBe('角色');
|
|
|
|
fireEvent.click(within(navigation).getByRole('button', { name: '我的' }));
|
|
|
|
expect(window.location.pathname).toBe('/profile');
|
|
expect(setSelectionStage).toHaveBeenCalledWith('profile', {
|
|
path: '/profile',
|
|
});
|
|
|
|
rerender(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="profile"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
|
|
expect(
|
|
within(navigation)
|
|
.getByRole('button', { name: '我的' })
|
|
.getAttribute('aria-current'),
|
|
).toBe('page');
|
|
});
|
|
|
|
it('keeps only games and profile reachable from the mobile dock', async () => {
|
|
responsiveMock.isDesktopLayout = false;
|
|
const setSelectionStage = vi.fn();
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="platform"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
const navigation = await screen.findByRole('navigation', {
|
|
name: '移动平台导航',
|
|
});
|
|
expect(
|
|
within(navigation)
|
|
.getAllByRole('button')
|
|
.map((button) => button.getAttribute('aria-label')),
|
|
).toEqual(['游戏', '创作者主页', '我的']);
|
|
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
|
|
expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy();
|
|
expect(
|
|
within(navigation)
|
|
.getByRole('button', { name: '我的' })
|
|
.getAttribute('aria-current'),
|
|
).toBe('page');
|
|
expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull();
|
|
|
|
fireEvent.click(within(navigation).getByRole('button', { name: '我的' }));
|
|
expect(window.location.pathname).toBe('/profile');
|
|
expect(setSelectionStage).toHaveBeenCalledWith('profile', {
|
|
path: '/profile',
|
|
});
|
|
});
|
|
|
|
it('restores the original blocking welcome on mobile until acknowledged', async () => {
|
|
responsiveMock.isDesktopLayout = false;
|
|
const { rerender } = render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="platform"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole('dialog', { name: '欢迎' })).toBeTruthy();
|
|
expect(
|
|
screen.getByText(
|
|
'尼嚎,新陶泥er!移动端仅支持作品展示体验创作工具请使用电脑端访问该地址',
|
|
),
|
|
).toBeTruthy();
|
|
expect(
|
|
document
|
|
.querySelector('.platform-mobile-home-welcome-dialog__icon')
|
|
?.getAttribute('src'),
|
|
).toBe('/branding/mobile-home-welcome-taonier-ip.png');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '好' }));
|
|
expect(screen.queryByRole('dialog', { name: '欢迎' })).toBeNull();
|
|
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
|
|
const navigation = screen.getByRole('navigation', {
|
|
name: '移动平台导航',
|
|
});
|
|
expect(
|
|
within(navigation)
|
|
.getByRole('button', { name: '我的' })
|
|
.getAttribute('aria-current'),
|
|
).toBe('page');
|
|
|
|
rerender(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="profile"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
expect(screen.queryByRole('dialog', { name: '欢迎' })).toBeNull();
|
|
});
|
|
|
|
it.each(['creation-home', 'project', 'image-editor'] as const)(
|
|
'shows the desktop guide instead of mounting the %s page on mobile',
|
|
async (selectionStage) => {
|
|
responsiveMock.isDesktopLayout = false;
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage={selectionStage}
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole('main', { name: '桌面端创作提示' }),
|
|
).toBeTruthy();
|
|
expect(screen.getByText('请在桌面端打开创作主页')).toBeTruthy();
|
|
expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull();
|
|
expect(screen.queryByRole('main', { name: '项目' })).toBeNull();
|
|
expect(screen.queryByRole('main', { name: '图片画布编辑器' })).toBeNull();
|
|
const navigation = screen.getByRole('navigation', {
|
|
name: '移动平台导航',
|
|
});
|
|
expect(
|
|
within(navigation)
|
|
.getAllByRole('button')
|
|
.map((button) => button.getAttribute('aria-label')),
|
|
).toEqual(['游戏', '创作者主页', '我的']);
|
|
expect(
|
|
within(navigation)
|
|
.getByRole('button', { name: '我的' })
|
|
.getAttribute('aria-current'),
|
|
).toBeNull();
|
|
},
|
|
);
|
|
|
|
it('returns from the mobile tool guide through the only profile tab', async () => {
|
|
responsiveMock.isDesktopLayout = false;
|
|
render(<StatefulPlatformEntryFlowShell initialStage="creation-home" />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '好' }));
|
|
const navigation = screen.getByRole('navigation', {
|
|
name: '移动平台导航',
|
|
});
|
|
fireEvent.click(within(navigation).getByRole('button', { name: '我的' }));
|
|
|
|
expect(window.location.pathname).toBe('/profile');
|
|
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
|
|
});
|
|
|
|
it('does not mount creation actions on the mobile root', async () => {
|
|
responsiveMock.isDesktopLayout = false;
|
|
|
|
render(<StatefulPlatformEntryFlowShell initialStage="platform" />);
|
|
|
|
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
|
|
expect(screen.queryByRole('button', { name: '打开音乐工具' })).toBeNull();
|
|
expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull();
|
|
});
|
|
|
|
it('switches between creation and projects and passes search to the active page', async () => {
|
|
const setSelectionStage = vi.fn();
|
|
const { rerender } = render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="creation-home"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
const navigation = await screen.findByRole('navigation', {
|
|
name: '平台导航',
|
|
});
|
|
fireEvent.click(within(navigation).getByRole('button', { name: '项目' }));
|
|
|
|
expect(window.location.pathname).toBe('/project');
|
|
expect(setSelectionStage).toHaveBeenCalledWith('project', {
|
|
path: '/project',
|
|
});
|
|
|
|
rerender(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="project"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
const projectPage = await screen.findByRole('main', { name: '项目' });
|
|
expect(
|
|
within(navigation)
|
|
.getByRole('button', { name: '项目' })
|
|
.getAttribute('aria-current'),
|
|
).toBe('page');
|
|
expect(
|
|
within(navigation)
|
|
.getByRole('button', { name: '创作' })
|
|
.getAttribute('aria-current'),
|
|
).toBeNull();
|
|
|
|
fireEvent.change(
|
|
screen.getByRole('searchbox', { name: '搜索项目和素材' }),
|
|
{ target: { value: '场景' } },
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '搜索' }));
|
|
expect(projectPage.getAttribute('data-search')).toBe('场景');
|
|
|
|
fireEvent.click(within(navigation).getByRole('button', { name: '创作' }));
|
|
expect(window.location.pathname).toBe('/creation');
|
|
expect(setSelectionStage).toHaveBeenCalledWith('creation-home', {
|
|
path: '/creation',
|
|
});
|
|
});
|
|
|
|
it('在我的游戏里进入发布新版本时沿用同一个 gameId', async () => {
|
|
authUiMock.value.user = {
|
|
id: 'user-1',
|
|
publicUserCode: '100001',
|
|
displayName: '测试用户',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: null,
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
};
|
|
authUiMock.value.canAccessProtectedData = true;
|
|
gameDistributionMock.listMyGames.mockResolvedValue([
|
|
{
|
|
id: 'game-1',
|
|
title: '星轨防线',
|
|
summary: '守住轨道城',
|
|
description: '旧版资料',
|
|
category: '动作',
|
|
tags: ['塔防'],
|
|
coverColor: '#d77a51',
|
|
icon: '✦',
|
|
author: { id: 'user-1', name: '测试用户' },
|
|
deviceSupport: { desktop: true, mobile: false, touch: false },
|
|
status: 'published',
|
|
publicationRevision: 3,
|
|
currentVersion: null,
|
|
playCount: 1,
|
|
createdAt: '2026-09-20T08:00:00Z',
|
|
latestVersion: {
|
|
versionId: 'gamever-2',
|
|
gameId: 'game-1',
|
|
versionNumber: 2,
|
|
packageSha256: 'e'.repeat(64),
|
|
packageBytes: 1024,
|
|
status: 'published',
|
|
publicationRevision: 3,
|
|
reviewReason: null,
|
|
createdAt: '2026-09-20T08:00:00Z',
|
|
updatedAt: '2026-09-20T09:00:00Z',
|
|
},
|
|
versions: [],
|
|
},
|
|
]);
|
|
const setSelectionStage = vi.fn();
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-mine"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '发布新版本' }));
|
|
|
|
expect(setSelectionStage).toHaveBeenCalledWith('game-publish', {
|
|
path: '/games/publish?game=game-1',
|
|
});
|
|
});
|
|
|
|
it('发布页带 game 参数时进入更新模式并预填既有资料', async () => {
|
|
authUiMock.value.user = {
|
|
id: 'user-1',
|
|
publicUserCode: '100001',
|
|
displayName: '测试用户',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: null,
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
};
|
|
authUiMock.value.canAccessProtectedData = true;
|
|
gameDistributionMock.listMyGames.mockResolvedValue([
|
|
{
|
|
id: 'game-9',
|
|
title: '星轨防线',
|
|
summary: '守住轨道城',
|
|
description: '旧版资料',
|
|
category: '动作',
|
|
tags: ['塔防'],
|
|
coverColor: '#d77a51',
|
|
icon: '✦',
|
|
author: { id: 'user-1', name: '测试用户' },
|
|
deviceSupport: { desktop: true, mobile: false, touch: false },
|
|
status: 'published',
|
|
publicationRevision: 5,
|
|
currentVersion: null,
|
|
playCount: 1,
|
|
createdAt: '2026-09-20T08:00:00Z',
|
|
latestVersion: {
|
|
versionId: 'gamever-9',
|
|
gameId: 'game-9',
|
|
versionNumber: 4,
|
|
packageSha256: 'f'.repeat(64),
|
|
packageBytes: 2048,
|
|
status: 'published',
|
|
publicationRevision: 5,
|
|
reviewReason: null,
|
|
createdAt: '2026-09-20T08:00:00Z',
|
|
updatedAt: '2026-09-20T09:00:00Z',
|
|
},
|
|
versions: [],
|
|
},
|
|
]);
|
|
gameDistributionMock.getGameVersion.mockResolvedValue({
|
|
game: {
|
|
id: 'game-9',
|
|
title: '星轨防线',
|
|
summary: '守住轨道城',
|
|
description: '旧版资料',
|
|
category: '动作',
|
|
tags: ['塔防'],
|
|
publicationRevision: 5,
|
|
},
|
|
version: {
|
|
versionId: 'gamever-9',
|
|
versionNumber: 4,
|
|
status: 'published',
|
|
publicationRevision: 5,
|
|
recoveryAction: 'none',
|
|
},
|
|
});
|
|
window.history.replaceState(null, '', '/games/publish?game=game-9');
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-publish"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(
|
|
await screen.findByText(/正在为《星轨防线》发布新版本 v5/u),
|
|
).toBeTruthy();
|
|
expect(screen.getByLabelText('游戏名称')).toHaveProperty(
|
|
'value',
|
|
'星轨防线',
|
|
);
|
|
});
|
|
|
|
it('keeps guide and tool intent in editor navigation URLs', async () => {
|
|
const setSelectionStage = vi.fn();
|
|
const { rerender } = render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="project"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '打开引导项目' }),
|
|
);
|
|
expect(`${window.location.pathname}${window.location.search}`).toBe(
|
|
'/editor/canvas?projectid=guide-project&guide=toolbar',
|
|
);
|
|
expect(setSelectionStage).toHaveBeenCalledWith('image-editor', {
|
|
path: '/editor/canvas?projectid=guide-project&guide=toolbar',
|
|
});
|
|
|
|
window.history.replaceState(null, '', '/creation');
|
|
setSelectionStage.mockClear();
|
|
rerender(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="creation-home"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '打开音乐工具' }),
|
|
);
|
|
expect(`${window.location.pathname}${window.location.search}`).toBe(
|
|
'/editor/canvas?projectid=tool-project&tool=background-music',
|
|
);
|
|
expect(setSelectionStage).toHaveBeenCalledWith('image-editor', {
|
|
path: '/editor/canvas?projectid=tool-project&tool=background-music',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('游戏发布灰度入口', () => {
|
|
function loginAsAuthor() {
|
|
authUiMock.value.user = {
|
|
id: 'user-1',
|
|
publicUserCode: '100001',
|
|
displayName: '测试作者',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: null,
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
};
|
|
authUiMock.value.canAccessProtectedData = true;
|
|
}
|
|
|
|
it('灰度未命中时广场不展示发布入口', async () => {
|
|
loginAsAuthor();
|
|
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockResolvedValue({
|
|
imageEditorAgentSidebarEnabled: false,
|
|
agcTemplateLibraryEnabled: false,
|
|
gameDistributionPublishEnabled: false,
|
|
});
|
|
gameDistributionMock.listGames.mockResolvedValue([]);
|
|
|
|
render(<StatefulPlatformEntryFlowShell initialStage="games" />);
|
|
|
|
expect(await screen.findByText('游戏广场')).toBeTruthy();
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole('button', { name: '发布游戏' })).toBeNull(),
|
|
);
|
|
});
|
|
|
|
it('游玩舞台的页签容器不滚动', async () => {
|
|
gameDistributionMock.getGame.mockResolvedValue(null);
|
|
|
|
const { container } = render(
|
|
<StatefulPlatformEntryFlowShell initialStage="game-play" />,
|
|
);
|
|
|
|
const panel = container.querySelector('.platform-tab-panel');
|
|
expect(panel?.classList.contains('platform-tab-panel--game-play')).toBe(
|
|
true,
|
|
);
|
|
expect(panel?.className).not.toContain('overflow-auto');
|
|
});
|
|
|
|
it('游戏广场等舞台仍保持页签容器滚动', async () => {
|
|
gameDistributionMock.listGames.mockResolvedValue([]);
|
|
|
|
const { container } = render(
|
|
<StatefulPlatformEntryFlowShell initialStage="games" />,
|
|
);
|
|
expect(await screen.findByText('游戏广场')).toBeTruthy();
|
|
|
|
const panel = container.querySelector('.platform-tab-panel');
|
|
expect(panel?.classList.contains('platform-tab-panel--game-play')).toBe(
|
|
false,
|
|
);
|
|
expect(panel?.className).toContain('overflow-auto');
|
|
});
|
|
|
|
it('灰度命中时广场展示发布入口', async () => {
|
|
loginAsAuthor();
|
|
gameDistributionMock.listGames.mockResolvedValue([]);
|
|
|
|
render(<StatefulPlatformEntryFlowShell initialStage="games" />);
|
|
|
|
expect(
|
|
await screen.findByRole('button', { name: '发布游戏' }),
|
|
).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('创作者页面深链与返回', () => {
|
|
beforeEach(() => {
|
|
Object.defineProperty(Element.prototype, 'scrollTo', {
|
|
configurable: true,
|
|
value: vi.fn(),
|
|
});
|
|
authUiMock.value.user = {
|
|
id: 'user-1',
|
|
publicUserCode: '100001',
|
|
displayName: '测试用户',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: null,
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
};
|
|
authUiMock.value.canAccessProtectedData = true;
|
|
gameDistributionMock.listGames.mockResolvedValue([]);
|
|
creatorClientMock.getCreator.mockResolvedValue({
|
|
user: authUiMock.value.user,
|
|
followingCount: 0,
|
|
followerCount: 0,
|
|
});
|
|
creatorClientMock.getCreatorConnections.mockResolvedValue({
|
|
items: [],
|
|
nextCursor: null,
|
|
total: 0,
|
|
});
|
|
creatorClientMock.getCreatorRelationship.mockResolvedValue({
|
|
relationship: { isSelf: false, isFollowing: false, isFollowedBy: false },
|
|
});
|
|
});
|
|
|
|
it.each(['', '&tab=', '&tab=invalid'])(
|
|
'关系列表拒绝无效 tab:%s,不请求列表且仍可返回',
|
|
async (query) => {
|
|
creatorClientMock.getCreatorConnections.mockClear();
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
`/creators/connections?id=user-2${query}`,
|
|
);
|
|
render(
|
|
<StatefulPlatformEntryFlowShell initialStage="creator-connections" />,
|
|
);
|
|
expect((await screen.findByRole('alert')).textContent).toBe(
|
|
'无效的关系列表链接',
|
|
);
|
|
expect(creatorClientMock.getCreatorConnections).not.toHaveBeenCalled();
|
|
fireEvent.click(screen.getByRole('button', { name: '返回' }));
|
|
await screen.findByRole('heading', { name: '创作者主页' });
|
|
expect(window.location.pathname + window.location.search).toBe(
|
|
'/creators?id=user-2',
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each(['/creators', '/creators?id=user-1', '/creators?id=user-2'])(
|
|
'直接打开 %s 后返回替换为游戏广场,不增加历史',
|
|
async (path) => {
|
|
window.history.replaceState(null, '', path);
|
|
const historyLength = window.history.length;
|
|
const setSelectionStage = vi.fn();
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="creator-home"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
fireEvent.click(await screen.findByRole('button', { name: '返回' }));
|
|
expect(window.location.pathname).toBe('/games');
|
|
expect(window.location.search).toBe('');
|
|
expect(window.history.length).toBe(historyLength);
|
|
expect(hasAppHistoryBackEntry()).toBe(false);
|
|
expect(setSelectionStage).toHaveBeenCalledWith('games', {
|
|
path: '/games',
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each(['following', 'followers'])(
|
|
'直接打开 %s 列表连续返回:所属主页、游戏广场,均不增加历史',
|
|
async (tab) => {
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
`/creators/connections?id=user-2&tab=${tab}`,
|
|
);
|
|
const historyLength = window.history.length;
|
|
render(
|
|
<StatefulPlatformEntryFlowShell initialStage="creator-connections" />,
|
|
);
|
|
fireEvent.click(await screen.findByRole('button', { name: '返回' }));
|
|
await screen.findByRole('heading', { name: '创作者主页' });
|
|
expect(window.location.pathname + window.location.search).toBe(
|
|
'/creators?id=user-2',
|
|
);
|
|
expect(hasAppHistoryBackEntry()).toBe(false);
|
|
fireEvent.click(screen.getByRole('button', { name: '返回' }));
|
|
expect(window.location.pathname).toBe('/games');
|
|
expect(window.history.length).toBe(historyLength);
|
|
expect(hasAppHistoryBackEntry()).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
'/games/detail?id=game-1',
|
|
'/creators/connections?id=user-1&tab=following',
|
|
])('从 %s 进入主页后使用原生返回,不覆盖来源', async (source) => {
|
|
window.history.replaceState(null, '', source);
|
|
pushAppHistoryPath('/creators?id=user-2');
|
|
const back = vi.spyOn(window.history, 'back').mockImplementation(() => {});
|
|
try {
|
|
const setSelectionStage = vi.fn();
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="creator-home"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
fireEvent.click(await screen.findByRole('button', { name: '返回' }));
|
|
expect(back).toHaveBeenCalledTimes(1);
|
|
expect(setSelectionStage).not.toHaveBeenCalled();
|
|
expect(window.location.search).toBe('?id=user-2');
|
|
} finally {
|
|
back.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('游戏详情返回', () => {
|
|
function mockDetailGame() {
|
|
gameDistributionMock.getGame.mockResolvedValue({
|
|
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',
|
|
});
|
|
}
|
|
|
|
async function clickDetailBack() {
|
|
fireEvent.click(await screen.findByRole('button', { name: '返回' }));
|
|
}
|
|
|
|
it('返回按钮不再写死广场文案与目标,改走浏览器原生返回', async () => {
|
|
mockDetailGame();
|
|
window.history.replaceState(null, '', '/games/mine');
|
|
pushAppHistoryPath('/games/detail?id=game-1');
|
|
const backSpy = vi
|
|
.spyOn(window.history, 'back')
|
|
.mockImplementation(() => {});
|
|
const setSelectionStage = vi.fn();
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-detail"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
await clickDetailBack();
|
|
|
|
expect(backSpy).toHaveBeenCalledTimes(1);
|
|
expect(setSelectionStage).not.toHaveBeenCalled();
|
|
backSpy.mockRestore();
|
|
});
|
|
|
|
it('直接打开详情链接时返回兜底回游戏广场', async () => {
|
|
mockDetailGame();
|
|
window.history.replaceState(null, '', '/games/detail?id=game-1');
|
|
const setSelectionStage = vi.fn();
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-detail"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
await clickDetailBack();
|
|
|
|
expect(setSelectionStage).toHaveBeenCalledWith('games', {
|
|
path: '/games',
|
|
});
|
|
expect(window.location.pathname).toBe('/games');
|
|
});
|
|
});
|
|
|
|
describe('作品管理 owner 作用域', () => {
|
|
beforeEach(() => {
|
|
window.history.replaceState(null, '', '/creation');
|
|
authUiMock.value.user = null;
|
|
authUiMock.value.canAccessProtectedData = false;
|
|
for (const mock of Object.values(gameDistributionMock)) {
|
|
mock.mockReset();
|
|
}
|
|
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockReset();
|
|
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockResolvedValue({
|
|
imageEditorAgentSidebarEnabled: false,
|
|
agcTemplateLibraryEnabled: false,
|
|
gameDistributionPublishEnabled: true,
|
|
});
|
|
});
|
|
|
|
const ownerEntry = {
|
|
id: 'game-1',
|
|
title: '未公开作品',
|
|
summary: '还在审核',
|
|
description: '作者自有游戏',
|
|
category: '动作',
|
|
tags: ['塔防'],
|
|
coverColor: '#d77a51',
|
|
icon: '✦',
|
|
author: { id: 'user-1', name: '测试作者' },
|
|
deviceSupport: { desktop: true, mobile: true, touch: true },
|
|
status: 'unpublished',
|
|
publicationRevision: 4,
|
|
currentVersion: null,
|
|
playCount: 0,
|
|
createdAt: '2026-09-20T08:00:00Z',
|
|
latestVersion: {
|
|
versionId: 'gamever-1',
|
|
gameId: 'game-1',
|
|
versionNumber: 1,
|
|
packageSha256: 'a'.repeat(64),
|
|
packageBytes: 1024,
|
|
status: 'pending_review',
|
|
publicationRevision: 4,
|
|
reviewReason: '封面需要替换',
|
|
createdAt: '2026-09-20T08:00:00Z',
|
|
updatedAt: '2026-09-20T09:00:00Z',
|
|
},
|
|
versions: [
|
|
{
|
|
versionId: 'gamever-1',
|
|
gameId: 'game-1',
|
|
versionNumber: 1,
|
|
packageSha256: 'a'.repeat(64),
|
|
packageBytes: 1024,
|
|
status: 'pending_review',
|
|
publicationRevision: 4,
|
|
reviewReason: '封面需要替换',
|
|
createdAt: '2026-09-20T08:00:00Z',
|
|
updatedAt: '2026-09-20T09:00:00Z',
|
|
},
|
|
],
|
|
};
|
|
|
|
function loginAsAuthor() {
|
|
authUiMock.value.user = {
|
|
id: 'user-1',
|
|
publicUserCode: '100001',
|
|
displayName: '测试作者',
|
|
avatarUrl: null,
|
|
phoneNumberMasked: null,
|
|
loginMethod: 'password',
|
|
bindingStatus: 'active',
|
|
wechatBound: false,
|
|
};
|
|
authUiMock.value.canAccessProtectedData = true;
|
|
}
|
|
|
|
it('从我的作品进入详情时把 owner 作用域编码进地址', async () => {
|
|
loginAsAuthor();
|
|
gameDistributionMock.listMyGames.mockResolvedValue([ownerEntry]);
|
|
gameDistributionMock.loadVersionRecoveryActions.mockResolvedValue({
|
|
'gamever-1': 'wait',
|
|
});
|
|
const setSelectionStage = vi.fn();
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-mine"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '查看详情' }));
|
|
|
|
expect(setSelectionStage).toHaveBeenCalledWith('game-detail', {
|
|
path: '/games/detail?id=game-1&owner=1',
|
|
});
|
|
});
|
|
|
|
it('带 owner 的详情深链读 owner 路由而不是公开详情', async () => {
|
|
loginAsAuthor();
|
|
gameDistributionMock.getMyGame.mockResolvedValue(ownerEntry);
|
|
gameDistributionMock.loadVersionRecoveryActions.mockResolvedValue({
|
|
'gamever-1': 'wait',
|
|
});
|
|
gameDistributionMock.getGame.mockResolvedValue(null);
|
|
window.history.replaceState(null, '', '/games/detail?id=game-1&owner=1');
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-detail"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByText('作品状态')).toBeTruthy();
|
|
expect(gameDistributionMock.getMyGame).toHaveBeenCalledWith('game-1');
|
|
expect(gameDistributionMock.getGame).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('没有 owner 的详情仍走公开详情', async () => {
|
|
gameDistributionMock.getMyGame.mockResolvedValue(ownerEntry);
|
|
gameDistributionMock.getGame.mockResolvedValue({
|
|
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: 1,
|
|
createdAt: '2026-09-18T00:00:00.000Z',
|
|
});
|
|
window.history.replaceState(null, '', '/games/detail?id=game-1');
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-detail"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByText('公开作品')).toBeTruthy();
|
|
expect(gameDistributionMock.getMyGame).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('创作族谱', () => {
|
|
const lineageTree = {
|
|
rootGameId: 'game-root',
|
|
root: {
|
|
gameId: 'game-root',
|
|
title: '母版',
|
|
authorName: '原作者',
|
|
generation: 0,
|
|
parentGameId: null,
|
|
playCount: 3,
|
|
status: 'published',
|
|
},
|
|
nodes: [
|
|
{
|
|
gameId: 'game-root',
|
|
title: '母版',
|
|
authorName: '原作者',
|
|
generation: 0,
|
|
parentGameId: null,
|
|
playCount: 3,
|
|
status: 'published',
|
|
},
|
|
{
|
|
gameId: 'game-leaf',
|
|
title: '叶节点',
|
|
authorName: '改编者',
|
|
generation: 1,
|
|
parentGameId: 'game-root',
|
|
playCount: 1,
|
|
status: 'published',
|
|
},
|
|
],
|
|
truncated: false,
|
|
};
|
|
|
|
it('族谱页按根作品渲染树,未登录也能浏览并跳转节点', async () => {
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/games/lineage?id=game-root&from=game-leaf',
|
|
);
|
|
gameDistributionMock.getGameLineage.mockResolvedValue(lineageTree);
|
|
const setSelectionStage = vi.fn();
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-lineage"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByText('叶节点')).toBeTruthy();
|
|
expect(gameDistributionMock.getGameLineage).toHaveBeenCalledWith(
|
|
'game-root',
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '母版' }));
|
|
expect(setSelectionStage).toHaveBeenCalledWith('game-detail', {
|
|
path: '/games/detail?id=game-root',
|
|
});
|
|
});
|
|
|
|
it('详情页的族谱入口带上根作品与当前作品', async () => {
|
|
window.history.replaceState(null, '', '/games/detail?id=game-1');
|
|
gameDistributionMock.getGame.mockResolvedValue({
|
|
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: 1,
|
|
createdAt: '2026-09-18T00:00:00.000Z',
|
|
forkCount: 0,
|
|
lineage: {
|
|
generation: 1,
|
|
rootGameId: 'game-root',
|
|
rootTitle: '母版',
|
|
parentGameId: 'game-root',
|
|
parentTitle: '母版',
|
|
parentAuthorName: '原作者',
|
|
},
|
|
});
|
|
const setSelectionStage = vi.fn();
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-detail"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: '查看创作族谱' }),
|
|
);
|
|
expect(setSelectionStage).toHaveBeenCalledWith('game-lineage', {
|
|
path: '/games/lineage?id=game-root&from=game-1',
|
|
});
|
|
});
|
|
|
|
it('族谱节点可打开「原版与二创对比」', async () => {
|
|
window.history.replaceState(null, '', '/games/lineage?id=game-root');
|
|
gameDistributionMock.getGameLineage.mockResolvedValue(lineageTree);
|
|
const setSelectionStage = vi.fn();
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-lineage"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: '与原版对比' }));
|
|
expect(setSelectionStage).toHaveBeenCalledWith('game-compare', {
|
|
path: '/games/compare?left=game-root&right=game-leaf',
|
|
});
|
|
});
|
|
|
|
it('对比页按 left/right 参数左右渲染两侧作品', async () => {
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/games/compare?left=game-1&right=game-2',
|
|
);
|
|
gameDistributionMock.getGame.mockImplementation((gameId: string) =>
|
|
Promise.resolve({
|
|
id: gameId,
|
|
title: `对比作品 ${gameId}`,
|
|
summary: '并排对比',
|
|
description: '',
|
|
category: '动作',
|
|
tags: [],
|
|
coverColor: '#d77a51',
|
|
icon: '✦',
|
|
author: { id: 'author-1', name: '测试作者' },
|
|
deviceSupport: { desktop: true, mobile: true, touch: true },
|
|
status: 'published',
|
|
currentVersion: {
|
|
id: `version-${gameId}`,
|
|
version: '1.0.0',
|
|
entryUrl: `https://play.example.test/releases/${gameId}/index.html`,
|
|
sha256: 'sha256',
|
|
publishedAt: '2026-09-18T00:00:00.000Z',
|
|
controls: ['键盘'],
|
|
},
|
|
playCount: 1,
|
|
createdAt: '2026-09-18T00:00:00.000Z',
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-compare"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByText('对比作品 game-1')).toBeTruthy();
|
|
expect(screen.getByText('对比作品 game-2')).toBeTruthy();
|
|
expect(document.querySelectorAll('iframe.game-player-frame')).toHaveLength(
|
|
2,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('共创主题', () => {
|
|
it('广场「共创」页签切到主题列表', async () => {
|
|
window.history.replaceState(null, '', '/games');
|
|
gameDistributionMock.listGames.mockResolvedValue([]);
|
|
gameDistributionMock.listThemes.mockResolvedValue({
|
|
themes: [],
|
|
nextCursor: null,
|
|
});
|
|
const setSelectionStage = vi.fn();
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="games"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('tab', { name: '共创' }));
|
|
expect(setSelectionStage).toHaveBeenCalledWith('game-themes', {
|
|
path: '/games/themes',
|
|
});
|
|
});
|
|
|
|
it('主题列表页渲染主题并进入主题页', async () => {
|
|
window.history.replaceState(null, '', '/games/themes');
|
|
gameDistributionMock.listThemes.mockResolvedValue({
|
|
themes: [
|
|
{
|
|
themeId: 'theme-1',
|
|
name: '精选共创',
|
|
summary: '运营精选',
|
|
badge: '精选',
|
|
memberCount: 1,
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
const setSelectionStage = vi.fn();
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-themes"
|
|
setSelectionStage={setSelectionStage}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /精选共创/u }));
|
|
expect(setSelectionStage).toHaveBeenCalledWith('game-theme', {
|
|
path: '/games/theme?id=theme-1',
|
|
});
|
|
});
|
|
|
|
it('主题详情按 ?id= 渲染主题名与成员根,并复用族谱查询', async () => {
|
|
window.history.replaceState(null, '', '/games/theme?id=theme-1');
|
|
gameDistributionMock.getTheme.mockResolvedValue({
|
|
themeId: 'theme-1',
|
|
name: '精选共创',
|
|
summary: '运营精选',
|
|
badge: '精选',
|
|
memberCount: 1,
|
|
roots: [
|
|
{
|
|
id: 'game-root-1',
|
|
title: '根作品',
|
|
summary: '根',
|
|
description: '',
|
|
category: '动作',
|
|
tags: [],
|
|
coverColor: '#d77a51',
|
|
icon: '✦',
|
|
author: { id: 'author-1', name: '测试作者' },
|
|
deviceSupport: { desktop: true, mobile: true, touch: true },
|
|
status: 'published',
|
|
currentVersion: null,
|
|
playCount: 1,
|
|
createdAt: '2026-09-18T00:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
gameDistributionMock.getGameLineage.mockResolvedValue({
|
|
rootGameId: 'game-root-1',
|
|
root: null,
|
|
nodes: [],
|
|
truncated: false,
|
|
});
|
|
|
|
render(
|
|
<PlatformEntryFlowShellImpl
|
|
selectionStage="game-theme"
|
|
setSelectionStage={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(await screen.findByRole('heading', { name: '精选共创' })).toBeTruthy();
|
|
expect(screen.getByText('根作品')).toBeTruthy();
|
|
expect(gameDistributionMock.getTheme).toHaveBeenCalledWith('theme-1');
|
|
// 树复用既有族谱查询:按根请求一次。
|
|
expect(gameDistributionMock.getGameLineage).toHaveBeenCalledWith(
|
|
'game-root-1',
|
|
);
|
|
});
|
|
});
|