@@ -205,10 +205,16 @@ describe('Match3DResultView', () => {
|
||||
];
|
||||
const profile = createProfile({ generatedItemAssets });
|
||||
const savedProfile = createProfile({ generatedItemAssets: [] });
|
||||
const persistedProfile = createProfile({ generatedItemAssets });
|
||||
const onStartTestRun = vi.fn();
|
||||
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
|
||||
item: savedProfile,
|
||||
});
|
||||
vi.mocked(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).mockResolvedValue({
|
||||
item: persistedProfile,
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
@@ -221,10 +227,34 @@ describe('Match3DResultView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).toHaveBeenCalledWith(
|
||||
profile.profileId,
|
||||
expect.objectContaining({
|
||||
generatedItemAssets: [
|
||||
expect.objectContaining({
|
||||
itemId: 'match3d-item-1',
|
||||
modelSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
status: 'model_ready',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profileId: profile.profileId,
|
||||
generatedItemAssets,
|
||||
generatedItemAssets: [
|
||||
expect.objectContaining({
|
||||
itemId: 'match3d-item-1',
|
||||
modelSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
status: 'model_ready',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -246,6 +276,105 @@ describe('Match3DResultView', () => {
|
||||
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('发布前会先把历史草稿 3D 模型素材写回 profile', async () => {
|
||||
const generatedItemAssets = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-strawberry',
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
const profile = createProfile({
|
||||
summary: '轻松消除水果',
|
||||
coverImageSrc: 'data:image/png;base64,cover',
|
||||
generatedItemAssets,
|
||||
});
|
||||
const savedProfile = createProfile({
|
||||
...profile,
|
||||
generatedItemAssets: [],
|
||||
});
|
||||
const onPublished = vi.fn();
|
||||
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
|
||||
item: savedProfile,
|
||||
});
|
||||
vi.mocked(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).mockResolvedValue({
|
||||
item: profile,
|
||||
});
|
||||
vi.mocked(match3dWorksService.publishMatch3DWork).mockResolvedValue({
|
||||
item: createProfile({
|
||||
...profile,
|
||||
publicationStatus: 'published',
|
||||
generatedItemAssets: [],
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
onBack={() => {}}
|
||||
onPublished={onPublished}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).toHaveBeenCalledWith(
|
||||
profile.profileId,
|
||||
expect.objectContaining({
|
||||
generatedItemAssets: [
|
||||
expect.objectContaining({
|
||||
itemId: 'match3d-item-1',
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
status: 'model_ready',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(match3dWorksService.publishMatch3DWork).toHaveBeenCalledWith(
|
||||
profile.profileId,
|
||||
);
|
||||
expect(onPublished).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
publicationStatus: 'published',
|
||||
generatedItemAssets: [
|
||||
expect.objectContaining({
|
||||
itemId: 'match3d-item-1',
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
const assetPersistCallOrder = vi.mocked(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).mock.invocationCallOrder[0];
|
||||
const publishCallOrder = vi.mocked(
|
||||
match3dWorksService.publishMatch3DWork,
|
||||
).mock.invocationCallOrder[0];
|
||||
expect(assetPersistCallOrder).toBeDefined();
|
||||
expect(publishCallOrder).toBeDefined();
|
||||
expect(assetPersistCallOrder!).toBeLessThan(publishCallOrder!);
|
||||
});
|
||||
|
||||
test('结果页提供多 Tab,并能进入 Rodin 3D 素材详情', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
@@ -483,6 +612,11 @@ describe('Match3DResultView', () => {
|
||||
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
|
||||
item: createProfile({ generatedItemAssets: [] }),
|
||||
});
|
||||
vi.mocked(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).mockResolvedValue({
|
||||
item: createProfile({ generatedItemAssets: [profileAsset] }),
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
|
||||
@@ -178,6 +178,70 @@ function hasMatch3DGeneratedModelSource(asset: Match3DGeneratedItemAsset) {
|
||||
return Boolean(asset.modelSrc?.trim() || asset.modelObjectKey?.trim());
|
||||
}
|
||||
|
||||
function hasPersistableMatch3DGeneratedItemAsset(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
return Boolean(
|
||||
asset.imageSrc?.trim() ||
|
||||
asset.imageObjectKey?.trim() ||
|
||||
asset.modelSrc?.trim() ||
|
||||
asset.modelObjectKey?.trim() ||
|
||||
asset.taskUuid?.trim() ||
|
||||
asset.subscriptionKey?.trim() ||
|
||||
asset.backgroundMusic ||
|
||||
asset.clickSound,
|
||||
);
|
||||
}
|
||||
|
||||
function getMatch3DGeneratedItemAssetPersistenceSignature(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
return [
|
||||
asset.itemId.trim(),
|
||||
asset.itemName.trim(),
|
||||
asset.imageSrc?.trim() ?? '',
|
||||
asset.imageObjectKey?.trim() ?? '',
|
||||
asset.modelSrc?.trim() ?? '',
|
||||
asset.modelObjectKey?.trim() ?? '',
|
||||
asset.modelFileName?.trim() ?? '',
|
||||
asset.taskUuid?.trim() ?? '',
|
||||
asset.subscriptionKey?.trim() ?? '',
|
||||
asset.status.trim(),
|
||||
asset.backgroundMusic?.audioSrc?.trim() ??
|
||||
asset.backgroundMusic?.assetObjectId?.trim() ??
|
||||
asset.backgroundMusic?.taskId?.trim() ??
|
||||
'',
|
||||
asset.clickSound?.audioSrc?.trim() ??
|
||||
asset.clickSound?.assetObjectId?.trim() ??
|
||||
asset.clickSound?.taskId?.trim() ??
|
||||
'',
|
||||
asset.error?.trim() ?? '',
|
||||
].join('\u001f');
|
||||
}
|
||||
|
||||
function shouldPersistGeneratedItemAssets(
|
||||
currentAssets: readonly Match3DGeneratedItemAsset[],
|
||||
savedAssets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
) {
|
||||
if (currentAssets.length <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (currentAssets.length !== savedAssets.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return currentAssets.some(
|
||||
(asset, index) => {
|
||||
const savedAsset = savedAssets[index];
|
||||
return (
|
||||
!savedAsset ||
|
||||
getMatch3DGeneratedItemAssetPersistenceSignature(asset) !==
|
||||
getMatch3DGeneratedItemAssetPersistenceSignature(savedAsset)
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mergeMatch3DGeneratedItemAsset(
|
||||
base: Match3DGeneratedItemAsset,
|
||||
override: Match3DGeneratedItemAsset,
|
||||
@@ -636,6 +700,19 @@ function attachMatch3DGeneratedItemAssets(
|
||||
};
|
||||
}
|
||||
|
||||
function buildPersistableGeneratedItemAssets(
|
||||
assetDrafts: Match3DRodinAssetDraft[],
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
if (generatedItemAssets.length <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return createGeneratedAssetsFromDrafts(assetDrafts, generatedItemAssets).filter(
|
||||
hasPersistableMatch3DGeneratedItemAsset,
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DResultHeader({
|
||||
autoSaveState,
|
||||
isBusy,
|
||||
@@ -1459,8 +1536,12 @@ export function Match3DResultView({
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const playableItem = attachMatch3DGeneratedItemAssets(
|
||||
item,
|
||||
generatedItemAssets,
|
||||
);
|
||||
setAutoSaveState('saved');
|
||||
onSaved?.(item);
|
||||
onSaved?.(playableItem);
|
||||
})
|
||||
.catch((saveError) => {
|
||||
if (cancelled) {
|
||||
@@ -1477,7 +1558,7 @@ export function Match3DResultView({
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [editState, onSaved, profile]);
|
||||
}, [editState, generatedItemAssets, onSaved, profile]);
|
||||
|
||||
const saveNow = async () => {
|
||||
const payload = buildSavePayload(editState);
|
||||
@@ -1489,10 +1570,34 @@ export function Match3DResultView({
|
||||
setAutoSaveState('saving');
|
||||
setLocalError(null);
|
||||
const { item } = await updateMatch3DWork(profile.profileId, payload);
|
||||
const playableItem = attachMatch3DGeneratedItemAssets(
|
||||
item,
|
||||
const currentGeneratedItemAssets = buildPersistableGeneratedItemAssets(
|
||||
assetDrafts,
|
||||
generatedItemAssets,
|
||||
);
|
||||
let playableItem = attachMatch3DGeneratedItemAssets(
|
||||
item,
|
||||
currentGeneratedItemAssets.length > 0
|
||||
? currentGeneratedItemAssets
|
||||
: generatedItemAssets,
|
||||
);
|
||||
if (
|
||||
shouldPersistGeneratedItemAssets(
|
||||
currentGeneratedItemAssets,
|
||||
item.generatedItemAssets ?? [],
|
||||
)
|
||||
) {
|
||||
// 中文注释:历史草稿可能只在页面 draft 中带 3D 模型;试玩和发布前必须先把当前可见素材写回 profile。
|
||||
const { item: persistedItem } = await updateMatch3DGeneratedItemAssets(
|
||||
profile.profileId,
|
||||
{
|
||||
generatedItemAssets: currentGeneratedItemAssets,
|
||||
},
|
||||
);
|
||||
playableItem = attachMatch3DGeneratedItemAssets(
|
||||
persistedItem,
|
||||
currentGeneratedItemAssets,
|
||||
);
|
||||
}
|
||||
setAutoSaveState('saved');
|
||||
onSaved?.(playableItem);
|
||||
return playableItem;
|
||||
@@ -1829,7 +1934,12 @@ export function Match3DResultView({
|
||||
const { item } = await publishMatch3DWork(
|
||||
savedProfile?.profileId ?? profile.profileId,
|
||||
);
|
||||
onPublished?.(item);
|
||||
onPublished?.(
|
||||
attachMatch3DGeneratedItemAssets(
|
||||
item,
|
||||
savedProfile?.generatedItemAssets ?? generatedItemAssets,
|
||||
),
|
||||
);
|
||||
setLocalError(null);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
Match3DRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import { isDebugMode } from '../../config/debugMode';
|
||||
import { readAssetBytes } from '../../services/assetReadUrlService';
|
||||
import {
|
||||
isItemState,
|
||||
@@ -236,6 +237,23 @@ function resolveGeneratedModelSourceForItemType(
|
||||
return asset ? normalizeMatch3DGeneratedModelSource(asset) : '';
|
||||
}
|
||||
|
||||
function warnMatch3DGeneratedModelLoadFailure(
|
||||
itemTypeId: string,
|
||||
source: string,
|
||||
error: unknown,
|
||||
) {
|
||||
if (!isDebugMode()) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error || 'unknown error');
|
||||
console.warn('[match3d] generated model load failed', {
|
||||
itemTypeId,
|
||||
source,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMatch3DGeneratedModelTemplate(
|
||||
templateMap: Match3DGeneratedModelTemplateMap,
|
||||
three: ThreeModule,
|
||||
@@ -1693,10 +1711,15 @@ export function Match3DTrayPreviewBoard({
|
||||
runtime.entries.delete(itemInstanceId);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((caughtError) => {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
warnMatch3DGeneratedModelLoadFailure(
|
||||
itemTypeId,
|
||||
source,
|
||||
caughtError,
|
||||
);
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
});
|
||||
});
|
||||
@@ -2168,10 +2191,15 @@ export function Match3DPhysicsBoard({
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((caughtError) => {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
warnMatch3DGeneratedModelLoadFailure(
|
||||
itemTypeId,
|
||||
source,
|
||||
caughtError,
|
||||
);
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,7 +100,10 @@ import {
|
||||
buildPublicWorkStagePath,
|
||||
pushAppHistoryPath,
|
||||
} from '../../routing/appPageRoutes';
|
||||
import { resolveRuntimeNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery';
|
||||
import {
|
||||
resolveRuntimeNotFoundRecoveryAction,
|
||||
resolveWorkNotFoundRecoveryAction,
|
||||
} from '../../routing/runtimeNotFoundRecovery';
|
||||
import {
|
||||
ApiClientError,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
@@ -998,6 +1001,24 @@ function maybeAlertRuntimeNotFoundAndReturnHome() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeAlertWorkNotFoundAndReturnHome() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recoveryAction = resolveWorkNotFoundRecoveryAction(
|
||||
window.location.pathname,
|
||||
);
|
||||
if (!recoveryAction) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 中文注释:直接打开公开详情或运行态深链失效时,确认提示后必须离开空详情页。
|
||||
window.alert('作品不存在或已下架,将返回首页。');
|
||||
pushAppHistoryPath(recoveryAction.nextPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasSeenPuzzleOnboarding() {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
@@ -5270,7 +5291,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPublicWorkDetailError(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
if (!maybeAlertRuntimeNotFoundAndReturnHome()) {
|
||||
if (!maybeAlertWorkNotFoundAndReturnHome()) {
|
||||
pushAppHistoryPath('/');
|
||||
}
|
||||
return false;
|
||||
@@ -6830,7 +6851,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPublicWorkDetailError(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
if (!maybeAlertRuntimeNotFoundAndReturnHome()) {
|
||||
if (!maybeAlertWorkNotFoundAndReturnHome()) {
|
||||
pushAppHistoryPath('/');
|
||||
}
|
||||
return;
|
||||
@@ -7053,7 +7074,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPublicWorkDetailError(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
if (!maybeAlertRuntimeNotFoundAndReturnHome()) {
|
||||
if (!maybeAlertWorkNotFoundAndReturnHome()) {
|
||||
pushAppHistoryPath('/');
|
||||
}
|
||||
return;
|
||||
@@ -8507,6 +8528,23 @@ export function PlatformEntryFlowShellImpl({
|
||||
const user = await getPublicAuthUserByCode(normalizedKeyword);
|
||||
setSearchedPublicUser(user);
|
||||
} catch (error) {
|
||||
if (selectionStage === 'work-detail') {
|
||||
setSelectedPublicWorkDetail(null);
|
||||
setSelectedDetailEntry(null);
|
||||
setSelectedPuzzleDetail(null);
|
||||
setPuzzleDetailReturnTarget(null);
|
||||
setPuzzleRun(null);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleError(null);
|
||||
setPublicWorkDetailError(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
if (!maybeAlertWorkNotFoundAndReturnHome()) {
|
||||
pushAppHistoryPath('/');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setPublicSearchError(
|
||||
resolveRpgCreationErrorMessage(error, '未找到对应的百梦号或作品号。'),
|
||||
);
|
||||
@@ -8527,6 +8565,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
refreshSquareHoleGallery,
|
||||
refreshVisualNovelGallery,
|
||||
squareHoleGalleryEntries,
|
||||
selectionStage,
|
||||
setPlatformTab,
|
||||
visualNovelGalleryEntries,
|
||||
],
|
||||
);
|
||||
@@ -9236,6 +9276,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'work-detail' && !selectedPublicWorkDetail && (
|
||||
<motion.div
|
||||
key="platform-work-detail-empty"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 items-center justify-center"
|
||||
>
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
{publicWorkDetailError || '正在读取作品详情...'}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'work-detail' && selectedPublicWorkDetail && (
|
||||
<motion.div
|
||||
key="platform-work-detail"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
@@ -31,6 +31,15 @@ const mocapMock = vi.hoisted(() => ({
|
||||
y: 0.58,
|
||||
}));
|
||||
|
||||
const debugModeMock = vi.hoisted(() => ({
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
vi.mock('../../config/debugMode', () => ({
|
||||
IS_DEBUG_MODE: debugModeMock.enabled,
|
||||
isDebugMode: () => debugModeMock.enabled,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/useMocapInput', () => ({
|
||||
useMocapInput: () => ({
|
||||
status: 'connected',
|
||||
@@ -44,6 +53,13 @@ vi.mock('../../services/useMocapInput', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
debugModeMock.enabled = true;
|
||||
mocapMock.state = 'grab';
|
||||
mocapMock.x = 0.42;
|
||||
mocapMock.y = 0.58;
|
||||
});
|
||||
|
||||
function createAuthValue() {
|
||||
return {
|
||||
user: null,
|
||||
@@ -157,7 +173,7 @@ const clearedRun: PuzzleRunSnapshot = {
|
||||
},
|
||||
};
|
||||
|
||||
test('拼图界面显示 mocap 连接状态和最近动作调试信息', () => {
|
||||
test('调试模式下拼图界面折叠展示 mocap 连接状态,展开后显示最近动作调试信息', () => {
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={{
|
||||
@@ -176,12 +192,42 @@ test('拼图界面显示 mocap 连接状态和最近动作调试信息', () => {
|
||||
|
||||
const debugPanel = screen.getByTestId('puzzle-mocap-debug');
|
||||
expect(within(debugPanel).getByText('mocap: connected')).toBeTruthy();
|
||||
const toggleButton = within(debugPanel).getByRole('button', {
|
||||
name: 'mocap: connected',
|
||||
});
|
||||
expect(toggleButton.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(within(debugPanel).queryByText('动作: grab')).toBeNull();
|
||||
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
expect(toggleButton.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(within(debugPanel).getByText('动作: grab')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText('手势: grab @ 0.42, 0.58')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText('解析: 无')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText(/原始:/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('非调试模式下拼图界面不渲染 mocap 调试面板', () => {
|
||||
debugModeMock.enabled = false;
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={{
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
},
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('puzzle-mocap-debug')).toBeNull();
|
||||
});
|
||||
|
||||
test('拼图界面在 mocap open_palm 时显示体感光标', () => {
|
||||
mocapMock.state = 'open_palm';
|
||||
mocapMock.x = 0.42;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Eye,
|
||||
Lightbulb,
|
||||
@@ -22,6 +24,7 @@ import type {
|
||||
PuzzleRuntimePropKind,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { isDebugMode } from '../../config/debugMode';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import {
|
||||
createRuntimeDragInputController,
|
||||
@@ -361,6 +364,7 @@ export function PuzzleRuntimeShell({
|
||||
const [isFreezeEffectVisible, setIsFreezeEffectVisible] = useState(false);
|
||||
const [isPropConfirming, setIsPropConfirming] = useState(false);
|
||||
const [propConfirmError, setPropConfirmError] = useState<string | null>(null);
|
||||
const [isMocapDebugExpanded, setIsMocapDebugExpanded] = useState(false);
|
||||
const [hintDemo, setHintDemo] = useState<PuzzleHintDemoState | null>(null);
|
||||
const [mergeFlash, setMergeFlash] = useState<PuzzleMergeFlashState | null>(
|
||||
null,
|
||||
@@ -462,6 +466,7 @@ export function PuzzleRuntimeShell({
|
||||
? mocapInput.latestCommand.parseWarnings.join(';')
|
||||
: '无';
|
||||
const mocapRawPacketLabel = mocapInput.rawPacketPreview?.text ?? '未收到';
|
||||
const shouldShowMocapDebugPanel = isDebugMode();
|
||||
|
||||
useEffect(() => {
|
||||
currentLevelRef.current = currentLevel;
|
||||
@@ -1744,19 +1749,45 @@ export function PuzzleRuntimeShell({
|
||||
已选择
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
data-testid="puzzle-mocap-debug"
|
||||
className="w-[min(92vw,34rem)] rounded-[0.9rem] border border-white/20 bg-slate-950/70 px-3 py-2 font-mono text-[10px] leading-4 text-white shadow-[0_12px_32px_rgba(15,23,42,0.25)] backdrop-blur"
|
||||
>
|
||||
<div>mocap: {mocapInput.status}</div>
|
||||
<div>动作: {mocapActionsLabel}</div>
|
||||
<div>手势: {mocapHandLabel}</div>
|
||||
<div>解析: {mocapParseWarningLabel}</div>
|
||||
<div className="max-h-20 overflow-auto break-all text-white/75">
|
||||
原始: {mocapRawPacketLabel}
|
||||
</div>
|
||||
{mocapInput.error ? <div>错误: {mocapInput.error}</div> : null}
|
||||
</div>
|
||||
{shouldShowMocapDebugPanel ? (
|
||||
<section
|
||||
data-testid="puzzle-mocap-debug"
|
||||
className="w-[min(92vw,34rem)] overflow-hidden rounded-[0.9rem] border border-white/20 bg-slate-950/70 font-mono text-[10px] leading-4 text-white shadow-[0_12px_32px_rgba(15,23,42,0.25)] backdrop-blur"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isMocapDebugExpanded}
|
||||
aria-controls="puzzle-mocap-debug-content"
|
||||
onClick={() => {
|
||||
setIsMocapDebugExpanded((current) => !current);
|
||||
}}
|
||||
className="flex min-h-9 w-full items-center justify-between gap-3 px-3 py-2 text-left transition hover:bg-white/10"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
mocap: {mocapInput.status}
|
||||
</span>
|
||||
{isMocapDebugExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronUp className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{isMocapDebugExpanded ? (
|
||||
<div
|
||||
id="puzzle-mocap-debug-content"
|
||||
className="border-t border-white/10 px-3 pb-2 pt-2"
|
||||
>
|
||||
<div>动作: {mocapActionsLabel}</div>
|
||||
<div>手势: {mocapHandLabel}</div>
|
||||
<div>解析: {mocapParseWarningLabel}</div>
|
||||
<div className="max-h-20 overflow-auto break-all text-white/75">
|
||||
原始: {mocapRawPacketLabel}
|
||||
</div>
|
||||
{mocapInput.error ? <div>错误: {mocapInput.error}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
{canShowNextAction ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -27,6 +27,10 @@ import type {
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
readPublicWorkCodeFromLocationSearch,
|
||||
resolveSelectionStageFromPath,
|
||||
} from '../../routing/appPageRoutes';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
@@ -1525,15 +1529,17 @@ function TestWrapper({
|
||||
onSelectWorld?: RpgEntryFlowShellProps['handleCustomWorldSelect'];
|
||||
} = {}) {
|
||||
const [selectionStage, setSelectionStage] = useState<SelectionStage>(() =>
|
||||
window.location.pathname === '/creation/rpg/agent'
|
||||
? 'agent-workspace'
|
||||
: 'platform',
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
);
|
||||
const [initialPublicWorkCode] = useState(() =>
|
||||
readPublicWorkCodeFromLocationSearch(window.location.search),
|
||||
);
|
||||
|
||||
const content = (
|
||||
<RpgEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
initialPublicWorkCode={initialPublicWorkCode}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
handleContinueGame={onContinueGame ?? (() => {})}
|
||||
@@ -4585,6 +4591,39 @@ test('missing puzzle public detail returns to platform home', async () => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('direct missing public work detail alert returns to platform home', async () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/works/detail?work=PZ-7A7B18D9',
|
||||
);
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect(await screen.findByText('正在读取作品详情...')).toBeTruthy();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('作品不存在或已下架,将返回首页。');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/');
|
||||
});
|
||||
expect(window.location.search).toBe('');
|
||||
await waitFor(() => {
|
||||
expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe(
|
||||
'false',
|
||||
);
|
||||
});
|
||||
expect(screen.queryByText('详情')).toBeNull();
|
||||
expect(screen.queryByText('未找到拼图作品。')).toBeNull();
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('public code search opens a published big fish work by BF code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bigFishWork: BigFishWorkSummary = {
|
||||
|
||||
23
src/config/debugMode.ts
Normal file
23
src/config/debugMode.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const DEBUG_MODE_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const DEBUG_MODE_FALSE_VALUES = new Set(['0', 'false', 'no', 'off']);
|
||||
|
||||
function parseOptionalBoolean(value: string | undefined) {
|
||||
const normalizedValue = value?.trim().toLowerCase();
|
||||
if (!normalizedValue) {
|
||||
return null;
|
||||
}
|
||||
if (DEBUG_MODE_TRUE_VALUES.has(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
if (DEBUG_MODE_FALSE_VALUES.has(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const IS_DEBUG_MODE =
|
||||
parseOptionalBoolean(import.meta.env.VITE_DEBUG_MODE) ?? import.meta.env.DEV;
|
||||
|
||||
export function isDebugMode() {
|
||||
return IS_DEBUG_MODE;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { resolveRuntimeNotFoundRecoveryAction } from './runtimeNotFoundRecovery';
|
||||
import {
|
||||
resolveRuntimeNotFoundRecoveryAction,
|
||||
resolveWorkNotFoundRecoveryAction,
|
||||
} from './runtimeNotFoundRecovery';
|
||||
|
||||
test('runtime not found recovery returns home after direct runtime route alert', () => {
|
||||
expect(resolveRuntimeNotFoundRecoveryAction('/runtime/puzzle')).toEqual({
|
||||
@@ -19,3 +22,21 @@ test('runtime not found recovery only handles direct runtime routes', () => {
|
||||
expect(resolveRuntimeNotFoundRecoveryAction('/gallery/puzzle/detail')).toBeNull();
|
||||
expect(resolveRuntimeNotFoundRecoveryAction('/creation/puzzle/result')).toBeNull();
|
||||
});
|
||||
|
||||
test('work not found recovery returns home for direct public detail routes', () => {
|
||||
expect(resolveWorkNotFoundRecoveryAction('/works/detail')).toEqual({
|
||||
nextStage: 'platform',
|
||||
nextPath: '/',
|
||||
shouldAlert: true,
|
||||
});
|
||||
expect(resolveWorkNotFoundRecoveryAction('/works/detail/')).toEqual({
|
||||
nextStage: 'platform',
|
||||
nextPath: '/',
|
||||
shouldAlert: true,
|
||||
});
|
||||
expect(resolveWorkNotFoundRecoveryAction('/gallery/puzzle/detail')).toEqual({
|
||||
nextStage: 'platform',
|
||||
nextPath: '/',
|
||||
shouldAlert: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,3 +28,27 @@ export function resolveRuntimeNotFoundRecoveryAction(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:公开作品详情页和运行态深链都可能在作品被删除或下架后失效。
|
||||
* 这类入口没有上一层可回退的详情数据,确认提示后统一回首页,避免空详情页白屏。
|
||||
*/
|
||||
export function resolveWorkNotFoundRecoveryAction(
|
||||
pathname: string,
|
||||
): RuntimeNotFoundRecoveryAction | null {
|
||||
const normalizedPath = pathname.trim().toLowerCase().replace(/\/+$/u, '');
|
||||
|
||||
if (
|
||||
normalizedPath === '/works/detail' ||
|
||||
normalizedPath === '/gallery/puzzle/detail' ||
|
||||
normalizedPath === '/gallery/visual-novel/detail'
|
||||
) {
|
||||
return {
|
||||
nextStage: 'platform',
|
||||
nextPath: '/',
|
||||
shouldAlert: true,
|
||||
};
|
||||
}
|
||||
|
||||
return resolveRuntimeNotFoundRecoveryAction(pathname);
|
||||
}
|
||||
|
||||
4
src/vite-env.d.ts
vendored
4
src/vite-env.d.ts
vendored
@@ -1 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_DEBUG_MODE?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user