补齐平台壳宿主消费门禁
新增平台壳草稿通知和角标 HostBridge 同步层 补充真实 Tauri transport 单测和跳一跳完成集成测试 扩展原生壳门禁覆盖 H5 宿主消费链路 更新宿主壳协议、方案和共享决策记录
This commit is contained in:
@@ -191,10 +191,6 @@ import {
|
||||
saveBabyObjectMatchDraft,
|
||||
} from '../../services/edutainment-baby-object';
|
||||
import { getExternalGenerationQueueOverview } from '../../services/external-generation';
|
||||
import {
|
||||
setHostAppBadgeCount,
|
||||
showHostLocalNotification,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import {
|
||||
jumpHopClient,
|
||||
type JumpHopGalleryCardResponse,
|
||||
@@ -553,6 +549,10 @@ import {
|
||||
type PlatformHostDraftNotification,
|
||||
sendPlatformHostDraftNotificationOnce,
|
||||
} from './platformHostNotificationModel';
|
||||
import {
|
||||
sendPlatformHostDraftNotificationToHost,
|
||||
syncPlatformHostDraftBadgeCountToHost,
|
||||
} from './platformHostBridgeSync';
|
||||
import {
|
||||
buildMatch3DProfileFromSession,
|
||||
hasMatch3DRuntimeAsset,
|
||||
@@ -2030,7 +2030,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
sendPlatformHostDraftNotificationOnce(
|
||||
sentPlatformHostDraftNotificationKeysRef.current,
|
||||
notification,
|
||||
showHostLocalNotification,
|
||||
sendPlatformHostDraftNotificationToHost,
|
||||
);
|
||||
},
|
||||
[],
|
||||
@@ -3340,7 +3340,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[draftGenerationNotices, visibleDraftNoticeKeys],
|
||||
);
|
||||
useEffect(() => {
|
||||
void setHostAppBadgeCount({ count: unreadDraftGenerationUpdateCount });
|
||||
syncPlatformHostDraftBadgeCountToHost(unreadDraftGenerationUpdateCount);
|
||||
}, [unreadDraftGenerationUpdateCount]);
|
||||
const resultViewError =
|
||||
autosaveCoordinator.customWorldAutoSaveError ??
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
HostBridgeCapability,
|
||||
HostBridgeRequest,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
import { resetHostRuntimeCacheForTest } from '../../services/host-bridge/hostBridge';
|
||||
import { resetNativeAppHostBridgeForTest } from '../../services/host-bridge/nativeAppHostBridge';
|
||||
import {
|
||||
sendPlatformHostDraftNotificationToHost,
|
||||
syncPlatformHostDraftBadgeCountToHost,
|
||||
} from './platformHostBridgeSync';
|
||||
|
||||
function asTauriInvoke(
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
|
||||
) {
|
||||
return async function tauriInvoke<Result = unknown>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) {
|
||||
return (await invoke(command, args)) as Result;
|
||||
};
|
||||
}
|
||||
|
||||
function nativeAppPath(capabilities: HostBridgeCapability[] = []) {
|
||||
const params = new URLSearchParams({
|
||||
clientRuntime: 'native_app',
|
||||
hostShell: 'tauri_desktop',
|
||||
});
|
||||
if (capabilities.length > 0) {
|
||||
params.set('hostCapabilities', capabilities.join(','));
|
||||
}
|
||||
return `/?${params.toString()}`;
|
||||
}
|
||||
|
||||
function installTauriHostBridgeRecorder() {
|
||||
const requests: HostBridgeRequest[] = [];
|
||||
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
||||
const request = (args as { request: HostBridgeRequest }).request;
|
||||
requests.push(request);
|
||||
return {
|
||||
bridge: 'GenarrativeHostBridge',
|
||||
version: 1,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: true,
|
||||
};
|
||||
});
|
||||
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
},
|
||||
};
|
||||
|
||||
return { invoke, requests };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
delete window.__TAURI__;
|
||||
resetNativeAppHostBridgeForTest();
|
||||
resetHostRuntimeCacheForTest();
|
||||
});
|
||||
|
||||
describe('platformHostBridgeSync', () => {
|
||||
test('平台壳草稿通知通过真实 HostBridge 请求本地系统通知', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
nativeAppPath(['notification.showLocal']),
|
||||
);
|
||||
const { invoke, requests } = installTauriHostBridgeRecorder();
|
||||
|
||||
await expect(
|
||||
sendPlatformHostDraftNotificationToHost({
|
||||
title: ' 生成完成 ',
|
||||
body: ' 拼图草稿 puzzle-session-1 生成任务已完成 ',
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||
request: expect.objectContaining({
|
||||
method: 'notification.showLocal',
|
||||
payload: {
|
||||
title: '生成完成',
|
||||
body: '拼图草稿 puzzle-session-1 生成任务已完成',
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('平台壳草稿未读计数通过真实 HostBridge 同步应用角标', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
nativeAppPath(['app.setBadgeCount']),
|
||||
);
|
||||
const { invoke, requests } = installTauriHostBridgeRecorder();
|
||||
|
||||
await expect(syncPlatformHostDraftBadgeCountToHost(3)).resolves.toBe(true);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||
request: expect.objectContaining({
|
||||
method: 'app.setBadgeCount',
|
||||
payload: {
|
||||
count: 3,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('宿主未声明草稿通知和角标能力时平台壳保持 H5 回退', async () => {
|
||||
window.history.replaceState(null, '', nativeAppPath());
|
||||
const { invoke } = installTauriHostBridgeRecorder();
|
||||
|
||||
await expect(
|
||||
sendPlatformHostDraftNotificationToHost({
|
||||
title: '生成完成',
|
||||
body: '拼图草稿已完成。',
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(syncPlatformHostDraftBadgeCountToHost(1)).resolves.toBe(false);
|
||||
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
setHostAppBadgeCount,
|
||||
showHostLocalNotification,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import type { PlatformHostDraftNotification } from './platformHostNotificationModel';
|
||||
|
||||
type PlatformHostDraftNotificationPayload = Pick<
|
||||
PlatformHostDraftNotification,
|
||||
'title' | 'body'
|
||||
>;
|
||||
|
||||
// 中文注释:平台壳只把已派生出的通知和角标计数交给 HostBridge,不改业务事实。
|
||||
export function sendPlatformHostDraftNotificationToHost({
|
||||
title,
|
||||
body,
|
||||
}: PlatformHostDraftNotificationPayload) {
|
||||
return showHostLocalNotification({ title, body });
|
||||
}
|
||||
|
||||
export function syncPlatformHostDraftBadgeCountToHost(count: number) {
|
||||
return setHostAppBadgeCount({ count });
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import type { PublicUserSummary } from '../../../packages/shared/src/contracts/a
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type {
|
||||
HostBridgeCapability,
|
||||
HostBridgeRequest,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldWorkSummary,
|
||||
@@ -94,6 +98,8 @@ import {
|
||||
regenerateBabyObjectMatchDraftAssets,
|
||||
saveBabyObjectMatchDraft,
|
||||
} from '../../services/edutainment-baby-object';
|
||||
import { resetHostRuntimeCacheForTest } from '../../services/host-bridge/hostBridge';
|
||||
import { resetNativeAppHostBridgeForTest } from '../../services/host-bridge/nativeAppHostBridge';
|
||||
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
|
||||
@@ -287,6 +293,51 @@ function queryCreationTypeButton(name: string | RegExp) {
|
||||
});
|
||||
}
|
||||
|
||||
function asTauriInvoke(
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
|
||||
) {
|
||||
return async function tauriInvoke<Result = unknown>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) {
|
||||
return (await invoke(command, args)) as Result;
|
||||
};
|
||||
}
|
||||
|
||||
function nativeAppPath(capabilities: HostBridgeCapability[] = []) {
|
||||
const params = new URLSearchParams({
|
||||
clientRuntime: 'native_app',
|
||||
hostShell: 'tauri_desktop',
|
||||
});
|
||||
if (capabilities.length > 0) {
|
||||
params.set('hostCapabilities', capabilities.join(','));
|
||||
}
|
||||
return `/?${params.toString()}`;
|
||||
}
|
||||
|
||||
function installTauriHostBridgeRecorder() {
|
||||
const requests: HostBridgeRequest[] = [];
|
||||
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
||||
const request = (args as { request: HostBridgeRequest }).request;
|
||||
requests.push(request);
|
||||
return {
|
||||
bridge: 'GenarrativeHostBridge',
|
||||
version: 1,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: true,
|
||||
};
|
||||
});
|
||||
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
},
|
||||
};
|
||||
|
||||
return { invoke, requests };
|
||||
}
|
||||
|
||||
async function openPuzzleFormFromCreateHub(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
) {
|
||||
@@ -3779,6 +3830,10 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
delete window.__TAURI__;
|
||||
delete window.ReactNativeWebView;
|
||||
resetNativeAppHostBridgeForTest();
|
||||
resetHostRuntimeCacheForTest();
|
||||
});
|
||||
|
||||
test('create tab shows template tabs and embeds puzzle form by default', async () => {
|
||||
@@ -9057,6 +9112,142 @@ test('completed unpublished jump hop draft opens result page without starting ru
|
||||
);
|
||||
});
|
||||
|
||||
test('native app jump hop draft completion sends host notification and badge from platform shell', async () => {
|
||||
const user = userEvent.setup();
|
||||
const sessionId = 'jump-hop-session-host-bridge-1';
|
||||
const profileId = 'jump-hop-profile-host-bridge-1';
|
||||
const work = buildMockJumpHopWork({
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-host-bridge-1',
|
||||
profileId,
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: sessionId,
|
||||
themeText: '桥接测试跳台',
|
||||
workTitle: '桥接测试跳台',
|
||||
workDescription: '原生壳桥接测试。',
|
||||
themeTags: ['桥接'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-30T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
draft: {
|
||||
...buildMockJumpHopWork().draft,
|
||||
profileId,
|
||||
themeText: '桥接测试跳台',
|
||||
workTitle: '桥接测试跳台',
|
||||
workDescription: '原生壳桥接测试。',
|
||||
themeTags: ['桥接'],
|
||||
stylePreset: 'minimal-blocks',
|
||||
},
|
||||
});
|
||||
const createdSession = {
|
||||
sessionId,
|
||||
ownerUserId: 'user-1',
|
||||
status: 'draft' as const,
|
||||
draft: {
|
||||
...work.draft,
|
||||
profileId,
|
||||
generationStatus: 'draft' as const,
|
||||
},
|
||||
createdAt: '2026-05-30T10:00:00.000Z',
|
||||
updatedAt: '2026-05-30T10:00:00.000Z',
|
||||
};
|
||||
const readySession = {
|
||||
...createdSession,
|
||||
status: 'ready' as const,
|
||||
draft: work.draft,
|
||||
updatedAt: '2026-05-30T10:05:00.000Z',
|
||||
};
|
||||
let resolveCompile!: (value: {
|
||||
actionType: 'compile-draft';
|
||||
session: typeof readySession;
|
||||
work: JumpHopWorkProfileResponse;
|
||||
}) => void;
|
||||
vi.mocked(fetchCreationEntryConfig).mockResolvedValueOnce({
|
||||
...testCreationEntryConfig,
|
||||
creationTypes: [
|
||||
...testCreationEntryConfig.creationTypes,
|
||||
{
|
||||
id: 'jump-hop',
|
||||
title: '跳一跳',
|
||||
subtitle: '主题驱动平台跳跃',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/jump-hop.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 55,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
unifiedCreationSpec: buildTestUnifiedCreationSpec('jump-hop', 5),
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(jumpHopClient.createSession).mockResolvedValue({
|
||||
session: createdSession,
|
||||
});
|
||||
vi.mocked(jumpHopClient.executeAction).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCompile = resolve;
|
||||
}),
|
||||
);
|
||||
const { requests } = installTauriHostBridgeRecorder();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
nativeAppPath(['app.setBadgeCount', 'notification.showLocal']),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('跳一跳'));
|
||||
await user.type(await screen.findByLabelText('主题'), '桥接测试跳台');
|
||||
await user.click(await screen.findByRole('button', { name: '生成' }));
|
||||
expect(
|
||||
await screen.findByRole('progressbar', {
|
||||
name: '跳一跳草稿生成进度',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
|
||||
await act(async () => {
|
||||
resolveCompile({
|
||||
actionType: 'compile-draft',
|
||||
session: readySession,
|
||||
work,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
requests.some(
|
||||
(request) =>
|
||||
request.method === 'notification.showLocal' &&
|
||||
(request.payload as { title?: string; body?: string }).title ===
|
||||
'生成完成' &&
|
||||
(request.payload as { body?: string }).body?.includes('跳一跳草稿'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requests.some(
|
||||
(request) =>
|
||||
request.method === 'app.setBadgeCount' &&
|
||||
(request.payload as { count?: number }).count === 1,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user