/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; import App from './App'; import { AuthUiContext } from './components/auth/AuthUiContext'; import type { PlatformEntryFlowShellProps } from './components/platform-entry'; import { APP_HISTORY_STATE_KEY, resolveInitialSelectionStageFromPath, } from './routing/appPageRoutes'; import { canUseNativeHostCapability, resetHostRuntimeCacheForTest, } from './services/host-bridge/hostBridge'; import { resetNativeAppHostBridgeForTest } from './services/host-bridge/nativeAppHostBridge'; const appTitleMock = vi.hoisted(() => ({ syncAppTitle: vi.fn(), })); function mockMatchMedia(matches: boolean) { Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: vi.fn().mockImplementation((query: string) => ({ matches, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), })), }); } vi.mock('./services/appTitle', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, syncAppTitle: appTitleMock.syncAppTitle, }; }); vi.mock('./components/platform-entry/PlatformEntryFlowShell', () => ({ PlatformEntryFlowShell: ({ handleCustomWorldSelect, setSelectionStage, selectionStage, }: PlatformEntryFlowShellProps) => (
{selectionStage}
{canUseNativeHostCapability('share.open') ? 'enabled' : 'disabled'}
), })); vi.mock('./RpgRuntimeApp', () => ({ RpgRuntimeApp: ({ onExitRuntime }: { onExitRuntime: () => void }) => ( ), })); function renderApp() { return render( undefined), musicVolume: 0.6, openAccountModal: vi.fn(), openLoginModal: vi.fn(), openSettingsModal: vi.fn(), platformTheme: 'light', requireAuth: vi.fn(), setCurrentUser: vi.fn(), setMusicVolume: vi.fn(), setPlatformTheme: vi.fn(), settingsError: null, user: null, }} > , ); } afterEach(() => { appTitleMock.syncAppTitle.mockReset(); window.history.replaceState(null, '', '/'); delete window.__TAURI__; delete window.ReactNativeWebView; resetHostRuntimeCacheForTest(); resetNativeAppHostBridgeForTest(); vi.restoreAllMocks(); }); describe('resolveInitialSelectionStageFromPath', () => { test('桌面端根路径进入创作主页', () => { expect(resolveInitialSelectionStageFromPath('/', true)).toBe( 'creation-home', ); }); test('移动端根路径仍进入平台首页', () => { expect(resolveInitialSelectionStageFromPath('/', false)).toBe('platform'); }); test('显式创作路由保持原目标阶段', () => { expect(resolveInitialSelectionStageFromPath('/creation/puzzle', true)).toBe( 'puzzle-agent-workspace', ); expect(resolveInitialSelectionStageFromPath('/creation', true)).toBe( 'creation-home', ); }); }); describe('App title sync', () => { test('主站阶段变化会同步浏览器与宿主标题', () => { renderApp(); expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith( '陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台', ); act(() => { fireEvent.click(screen.getByRole('button', { name: '打开拼图创作' })); }); expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith( '拼图创作 - 陶泥儿', ); }); test('RPG runtime 进入和退出时同步窗口标题', async () => { renderApp(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '进入 RPG' })); }); expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith( 'RPG 运行中 - 陶泥儿', ); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '退出 RPG' })); }); expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith( '陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台', ); }); test('启动时回读宿主 runtime 后刷新壳能力 UI', async () => { window.history.replaceState( null, '', '/?clientRuntime=native_app&hostShell=tauri_desktop', ); window.__TAURI__ = { core: { invoke: vi.fn(async (_command, args) => { const request = (args as { request: { id: string } }).request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: true, result: { shell: 'tauri_desktop', platform: 'linux', hostVersion: '0.1.0', bridgeVersion: 1, capabilities: ['host.getRuntime', 'share.open'], }, }; }), }, }; renderApp(); expect(screen.getByTestId('share-capability').textContent).toBe( 'disabled', ); await screen.findByText('enabled'); expect(screen.getByTestId('share-capability').textContent).toBe( 'enabled', ); }); test('原生壳直达二级页面时补齐 H5 返回锚点', async () => { window.history.replaceState( null, '', '/creation/puzzle?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events,navigation.canGoBack', ); window.__TAURI__ = { core: { invoke: vi.fn(async (_command, args) => { const request = (args as { request: { id: string } }).request; return { bridge: 'GenarrativeHostBridge', version: 1, id: request.id, ok: true, result: { shell: 'tauri_desktop', platform: 'linux', hostVersion: '0.1.0', bridgeVersion: 1, capabilities: ['host.events', 'navigation.canGoBack'], }, }; }), }, }; renderApp(); expect(screen.getByTestId('selection-stage').textContent).toBe( 'puzzle-agent-workspace', ); expect(window.location.pathname).toBe('/creation/puzzle'); expect(window.location.search).toBe( '?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events,navigation.canGoBack', ); await waitFor(() => { expect(canUseNativeHostCapability('navigation.canGoBack')).toBe(true); }); await act(async () => { window.history.back(); }); await waitFor(() => { expect(window.location.pathname).toBe('/'); }); expect(window.location.search).toBe( '?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events%2Cnavigation.canGoBack', ); expect(screen.getByTestId('selection-stage').textContent).toBe('platform'); }); }); describe('App navigation history', () => { test('桌面端首页 URL 渲染为创作主页阶段', () => { mockMatchMedia(true); window.history.replaceState(null, '', '/'); renderApp(); expect(screen.getByTestId('selection-stage').textContent).toBe( 'creation-home', ); }); test('缺少 projectid 的项目画布直达会替换回创作页', () => { window.history.replaceState(null, '', '/editor/canvas'); const replaceStateSpy = vi.spyOn(window.history, 'replaceState'); renderApp(); expect(screen.getByTestId('selection-stage').textContent).toBe( 'creation-home', ); expect(window.location.pathname).toBe('/creation'); expect(window.location.search).toBe(''); expect(replaceStateSpy).toHaveBeenCalledWith( { [APP_HISTORY_STATE_KEY]: true }, '', '/creation', ); replaceStateSpy.mockRestore(); }); test('带 projectid 的项目画布直达保持编辑器阶段', () => { mockMatchMedia(false); window.history.replaceState( null, '', '/editor/canvas?projectid=project-from-url', ); renderApp(); expect(screen.getByTestId('selection-stage').textContent).toBe( 'image-editor', ); expect(window.location.pathname).toBe('/editor/canvas'); expect(window.location.search).toBe('?projectid=project-from-url'); }); test('历史回到缺少 projectid 的项目画布时会替换回创作页', async () => { mockMatchMedia(false); window.history.replaceState(null, '', '/creation'); renderApp(); window.history.pushState(null, '', '/editor/canvas'); await act(async () => { window.dispatchEvent(new PopStateEvent('popstate')); }); expect(screen.getByTestId('selection-stage').textContent).toBe( 'creation-home', ); expect(window.location.pathname).toBe('/creation'); expect(window.location.search).toBe(''); }); test('项目画布导航只写入一次带 projectid 的历史记录', async () => { mockMatchMedia(true); window.history.replaceState(null, '', '/creation'); const pushStateSpy = vi.spyOn(window.history, 'pushState'); const user = userEvent.setup(); renderApp(); await user.click(screen.getByRole('button', { name: '打开最近项目' })); await waitFor(() => { expect(window.location.pathname).toBe('/editor/canvas'); expect(window.location.search).toBe('?projectid=project-from-test'); }); expect(pushStateSpy).toHaveBeenCalledTimes(1); expect(pushStateSpy).toHaveBeenCalledWith( { [APP_HISTORY_STATE_KEY]: true }, '', '/editor/canvas?projectid=project-from-test', ); window.history.back(); await waitFor(() => { expect(window.location.pathname).toBe('/creation'); expect(window.location.search).toBe(''); }); pushStateSpy.mockRestore(); }); });