1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-11 20:57:16 +08:00
81 changed files with 3410 additions and 132 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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);
});
});

View File

@@ -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"

View File

@@ -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;

View File

@@ -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"

View File

@@ -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
View 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;
}

View File

@@ -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,
});
});

View File

@@ -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
View File

@@ -1 +1,5 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_DEBUG_MODE?: string;
}