/* @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 {
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(),
getGame: vi.fn(),
getGameVersion: vi.fn(),
listGames: vi.fn(),
listMyGames: vi.fn(),
submitGameVersion: vi.fn(),
unpublishGame: vi.fn(),
uploadGamePackage: vi.fn(),
}));
vi.mock('../../services/gameDistributionClient', () => gameDistributionMock);
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;
}) => (
创作主页
),
}));
vi.mock('../project/ProjectGalleryView', () => ({
ProjectGalleryView: ({
onOpenProject,
searchKeyword,
}: {
onOpenProject: (projectId: string, options?: { guide?: boolean }) => void;
searchKeyword?: string;
}) => (
项目
),
}));
vi.mock('../image-editor/ImageCanvasEditorView', () => ({
ImageCanvasEditorView: ({
legacyWalletBalance,
}: {
legacyWalletBalance?: number | null;
}) => (
),
}));
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 (
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(
,
);
expect(await screen.findByLabelText('泥点 207')).toBeTruthy();
expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy();
rerender(
,
);
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(
,
);
expect(
await screen.findByRole('button', { name: '泥点余额 37' }),
).toBeTruthy();
expect(await screen.findByText('37泥点')).toBeTruthy();
expect(screen.getByText('暂无账单记录')).toBeTruthy();
rerender(
,
);
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(
,
);
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(
,
);
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(
,
);
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(
,
);
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(
,
);
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(
,
);
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(
,
);
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(
,
);
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(
,
);
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();
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();
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(
,
);
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(
,
);
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(
,
);
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(
,
);
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(
,
);
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(
,
);
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();
expect(await screen.findByText('游戏广场')).toBeTruthy();
await waitFor(() =>
expect(screen.queryByRole('button', { name: '发布游戏' })).toBeNull(),
);
});
it('灰度命中时广场展示发布入口', async () => {
loginAsAuthor();
gameDistributionMock.listGames.mockResolvedValue([]);
render();
expect(
await screen.findByRole('button', { name: '发布游戏' }),
).not.toBeNull();
});
});