09fec601b1
补充 robots、sitemap、SEO 元信息、结构化数据与首页语义内容 统一三套 Nginx 与 Pingora 的 62 条 SPA 路由和真实 404 行为 新增路由一致性检查、网关测试并同步技术文档与项目记忆
386 lines
11 KiB
TypeScript
386 lines
11 KiB
TypeScript
/* @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<typeof import('./services/appTitle')>();
|
|
return {
|
|
...actual,
|
|
syncAppTitle: appTitleMock.syncAppTitle,
|
|
};
|
|
});
|
|
|
|
vi.mock('./components/platform-entry/PlatformEntryFlowShell', () => ({
|
|
PlatformEntryFlowShell: ({
|
|
handleCustomWorldSelect,
|
|
setSelectionStage,
|
|
selectionStage,
|
|
}: PlatformEntryFlowShellProps) => (
|
|
<div>
|
|
<div data-testid="selection-stage">{selectionStage}</div>
|
|
<div data-testid="share-capability">
|
|
{canUseNativeHostCapability('share.open') ? 'enabled' : 'disabled'}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectionStage('puzzle-agent-workspace')}
|
|
>
|
|
打开拼图创作
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
handleCustomWorldSelect(
|
|
{
|
|
id: 'profile-1',
|
|
name: '潮雾列岛',
|
|
} as Parameters<
|
|
PlatformEntryFlowShellProps['handleCustomWorldSelect']
|
|
>[0],
|
|
)
|
|
}
|
|
>
|
|
进入 RPG
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setSelectionStage('image-editor', {
|
|
path: '/editor/canvas?projectid=project-from-test',
|
|
})
|
|
}
|
|
>
|
|
打开最近项目
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock('./RpgRuntimeApp', () => ({
|
|
RpgRuntimeApp: ({ onExitRuntime }: { onExitRuntime: () => void }) => (
|
|
<button type="button" onClick={onExitRuntime}>
|
|
退出 RPG
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
function renderApp() {
|
|
return render(
|
|
<AuthUiContext.Provider
|
|
value={{
|
|
canAccessProtectedData: false,
|
|
isHydratingSettings: false,
|
|
isPersistingSettings: false,
|
|
logout: vi.fn(async () => 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,
|
|
}}
|
|
>
|
|
<App />
|
|
</AuthUiContext.Provider>,
|
|
);
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|