/* @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, })); 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; 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(); 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 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( 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('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', }); }); });