/* @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()), ...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; }) => (
创作主页
), })); 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 () => { gameDistributionMock.getGame.mockResolvedValue(null); const { container } = render( , ); 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( , ); 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(); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); expect(await screen.findByRole('heading', { name: '精选共创' })).toBeTruthy(); expect(screen.getByText('根作品')).toBeTruthy(); expect(gameDistributionMock.getTheme).toHaveBeenCalledWith('theme-1'); // 树复用既有族谱查询:按根请求一次。 expect(gameDistributionMock.getGameLineage).toHaveBeenCalledWith( 'game-root-1', ); }); });