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

This commit is contained in:
2026-05-02 20:43:41 +08:00
parent 543ccf2509
commit 5831703156
36 changed files with 799 additions and 254 deletions

View File

@@ -47,6 +47,11 @@ describe('PublishShareModal', () => {
);
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
expect(dialog.parentElement?.className).toContain('!items-center');
expect(dialog.parentElement?.className).toContain('platform-theme--light');
expect(dialog.className).toContain('platform-modal-shell');
expect(dialog.className).toContain('rounded-[1.75rem]');
expect(dialog.getAttribute('style')).toBeNull();
expect(within(dialog).getByText(//u)).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();

View File

@@ -2,6 +2,7 @@ import { Check, Copy, MessageCircle, Music2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { useAuthUi } from '../auth/AuthUiContext';
import {
buildPublishShareText,
type PublishShareModalPayload,
@@ -44,6 +45,7 @@ export function PublishShareModal({
payload,
onClose,
}: PublishShareModalProps) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
@@ -89,7 +91,8 @@ export function PublishShareModal({
title="分享给朋友"
onClose={onClose}
size="sm"
panelClassName="platform-remap-surface"
overlayClassName={`platform-theme platform-theme--${platformTheme} !items-center`}
panelClassName="platform-remap-surface rounded-[1.75rem]"
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
footerClassName="justify-center border-t-0 px-4 pb-5 pt-0 sm:px-5"
footer={

View File

@@ -132,6 +132,8 @@ import {
remixPuzzleGalleryWork,
} from '../../services/puzzle-gallery';
import {
advancePuzzleNextLevel,
startPuzzleRun,
submitPuzzleLeaderboard,
} from '../../services/puzzle-runtime';
import {
@@ -141,6 +143,7 @@ import {
extendLocalPuzzleTime,
isLocalPuzzleRun,
refreshLocalPuzzleTimer,
resolvePuzzleRestartLevelId,
restartLocalPuzzleLevel,
setLocalPuzzlePaused,
startLocalPuzzleRun,
@@ -876,31 +879,20 @@ function mergePuzzleServiceRuntimeState(
}
const serviceLevel = serviceRun.currentLevel;
if (
currentRun.currentLevel.status === 'cleared' &&
serviceLevel.status !== 'cleared'
) {
return {
...currentRun,
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
nextLevelMode: serviceRun.nextLevelMode,
nextLevelProfileId: serviceRun.nextLevelProfileId,
nextLevelId: serviceRun.nextLevelId,
recommendedNextWorks: serviceRun.recommendedNextWorks,
leaderboardEntries:
currentRun.currentLevel.leaderboardEntries.length > 0
? currentRun.currentLevel.leaderboardEntries
: currentRun.leaderboardEntries,
};
}
const leaderboardEntries =
serviceLevel.leaderboardEntries.length > 0
? serviceLevel.leaderboardEntries
: serviceRun.leaderboardEntries;
// 中文注释:拼块布局和通关状态由前端即时裁决;后端快照只合并榜单与下一关 handoff。
return {
...currentRun,
runId: serviceRun.runId,
entryProfileId: serviceRun.entryProfileId,
clearedLevelCount: Math.max(
currentRun.clearedLevelCount,
serviceRun.clearedLevelCount,
),
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
nextLevelMode: serviceRun.nextLevelMode,
nextLevelProfileId: serviceRun.nextLevelProfileId,
@@ -909,18 +901,10 @@ function mergePuzzleServiceRuntimeState(
leaderboardEntries,
currentLevel: {
...currentRun.currentLevel,
status: serviceLevel.status,
startedAtMs: serviceLevel.startedAtMs,
clearedAtMs: serviceLevel.clearedAtMs,
elapsedMs: serviceLevel.elapsedMs,
timeLimitMs: serviceLevel.timeLimitMs,
remainingMs: serviceLevel.remainingMs,
pausedAccumulatedMs: serviceLevel.pausedAccumulatedMs,
pauseStartedAtMs: serviceLevel.pauseStartedAtMs,
freezeAccumulatedMs: serviceLevel.freezeAccumulatedMs,
freezeStartedAtMs: serviceLevel.freezeStartedAtMs,
freezeUntilMs: serviceLevel.freezeUntilMs,
leaderboardEntries,
leaderboardEntries:
leaderboardEntries.length > 0
? leaderboardEntries
: currentRun.currentLevel.leaderboardEntries,
},
};
}
@@ -2181,8 +2165,12 @@ export function PlatformEntryFlowShellImpl({
setPuzzleError(null);
try {
const item = detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
const run = startLocalPuzzleRun(item, levelId ?? null);
const item =
detailItem ?? (await getPuzzleGalleryDetail(profileId)).item;
const { run } = await startPuzzleRun({
profileId: item.profileId,
levelId: levelId ?? null,
});
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
setPuzzleRuntimeReturnStage(returnStage);
@@ -2411,14 +2399,20 @@ export function PlatformEntryFlowShellImpl({
setIsPuzzleBusy(true);
setPuzzleError(null);
try {
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
setPuzzleRun(
swapLocalPuzzlePieces(
puzzleRun,
payload,
isLocalPuzzleRun(puzzleRun) ? selectedPuzzleDetail : null,
),
);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。'));
} finally {
setIsPuzzleBusy(false);
}
},
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, selectedPuzzleDetail],
);
const dragPuzzlePiece = useCallback(
@@ -2430,14 +2424,20 @@ export function PlatformEntryFlowShellImpl({
setIsPuzzleBusy(true);
setPuzzleError(null);
try {
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
setPuzzleRun(
dragLocalPuzzlePiece(
puzzleRun,
payload,
isLocalPuzzleRun(puzzleRun) ? selectedPuzzleDetail : null,
),
);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。'));
} finally {
setIsPuzzleBusy(false);
}
},
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, selectedPuzzleDetail],
);
useEffect(() => {
@@ -2515,18 +2515,46 @@ export function PlatformEntryFlowShellImpl({
);
const restartPuzzleCurrentLevel = useCallback(async () => {
const currentLevel = puzzleRun?.currentLevel ?? null;
if (!puzzleRun || !currentLevel || isPuzzleBusy) {
const currentRun = puzzleRunRef.current ?? puzzleRun;
const currentLevel = currentRun?.currentLevel ?? null;
if (!currentRun || !currentLevel || isPuzzleBusy) {
return;
}
setPuzzleError(null);
const nextRun = restartLocalPuzzleLevel(puzzleRunRef.current ?? puzzleRun);
puzzleRunRef.current = nextRun;
setPuzzleRun(nextRun);
setIsPuzzleBusy(true);
try {
if (isLocalPuzzleRun(currentRun)) {
const nextRun = restartLocalPuzzleLevel(currentRun);
puzzleRunRef.current = nextRun;
setPuzzleRun(nextRun);
return;
}
const detailItem =
selectedPuzzleDetail?.profileId === currentLevel.profileId
? selectedPuzzleDetail
: await getPuzzleGalleryDetail(currentLevel.profileId).then(
(response) => response.item,
);
const { run } = await startPuzzleRun({
profileId: currentLevel.profileId,
levelId: resolvePuzzleRestartLevelId(currentRun, detailItem),
});
setSelectedPuzzleDetail(detailItem);
puzzleRunRef.current = run;
setPuzzleRun(run);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '重新开始拼图关卡失败。'));
} finally {
setIsPuzzleBusy(false);
}
}, [
isPuzzleBusy,
puzzleRun,
resolvePuzzleErrorMessage,
selectedPuzzleDetail,
setIsPuzzleBusy,
setPuzzleError,
]);
@@ -2565,14 +2593,19 @@ export function PlatformEntryFlowShellImpl({
gameState.currentLevelId.trim()
? gameState.currentLevelId
: null;
const item = selectedPuzzleDetail?.profileId === profileId
? selectedPuzzleDetail
: await getPuzzleGalleryDetail(profileId).then((response) => response.item);
const nextRun = startLocalPuzzleRun(item, levelId);
setSelectedPuzzleDetail(item);
setPuzzleRun(nextRun);
setPuzzleRuntimeReturnStage('platform');
setSelectionStage('puzzle-runtime');
const item =
selectedPuzzleDetail?.profileId === profileId
? selectedPuzzleDetail
: await getPuzzleGalleryDetail(profileId).then(
(response) => response.item,
);
await startPuzzleRunFromProfile(
item.profileId,
'platform',
item,
false,
levelId,
);
} catch (error) {
platformBootstrap.setSaveError(
resolvePuzzleErrorMessage(error, '恢复拼图存档失败。'),
@@ -2587,7 +2620,7 @@ export function PlatformEntryFlowShellImpl({
selectedPuzzleDetail,
resolvePuzzleErrorMessage,
setPuzzleError,
setSelectionStage,
startPuzzleRunFromProfile,
],
);
@@ -2651,7 +2684,7 @@ export function PlatformEntryFlowShellImpl({
]);
const advancePuzzleLevel = useCallback(
async (target?: { profileId?: string; levelId?: string | null }) => {
async (_target?: { profileId?: string; levelId?: string | null }) => {
if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) {
return;
}
@@ -2665,12 +2698,43 @@ export function PlatformEntryFlowShellImpl({
setPuzzleError(null);
try {
const nextRun = advanceLocalPuzzleLevel(
puzzleRun,
selectedPuzzleDetail,
target,
);
setPuzzleRun(nextRun);
if (isLocalPuzzleRun(puzzleRun)) {
const nextRun = advanceLocalPuzzleLevel(
puzzleRun,
selectedPuzzleDetail,
_target,
);
setPuzzleRun(nextRun);
return;
}
const targetProfileId = _target?.profileId?.trim() ?? '';
if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) {
const itemPromise =
selectedPuzzleDetail?.profileId === targetProfileId
? Promise.resolve(selectedPuzzleDetail)
: getPuzzleGalleryDetail(targetProfileId).then(
(response) => response.item,
);
const [{ run }, item] = await Promise.all([
advancePuzzleNextLevel(puzzleRun.runId, {
targetProfileId,
}),
itemPromise,
]);
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
pushAppHistoryPath(
buildPublicWorkStagePath(
'puzzle-runtime',
buildPuzzlePublicWorkCode(item.profileId),
),
);
return;
}
const { run } = await advancePuzzleNextLevel(puzzleRun.runId);
setPuzzleRun(run);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
} finally {
@@ -3563,7 +3627,10 @@ export function PlatformEntryFlowShellImpl({
}
if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) {
const work = mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail);
const work =
selectedPuzzleDetail?.profileId === selectedPublicWorkDetail.profileId
? selectedPuzzleDetail
: mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail);
if (!work) {
setPublicWorkDetailError(
'当前拼图作品信息不完整,暂时无法进入玩法。',
@@ -3628,10 +3695,11 @@ export function PlatformEntryFlowShellImpl({
isPublicWorkDetailBusy,
runProtectedAction,
selectedDetailEntry,
selectedPuzzleDetail,
selectedPublicWorkDetail,
startBigFishRunFromWork,
startMatch3DRunFromProfile,
startPuzzleRunFromProfile,
startMatch3DRunFromProfile,
]);
const remixPublicWork = useCallback(

View File

@@ -132,7 +132,7 @@ function syncDraftFromEditState(
workTitle: editState.workTitle.trim() || draft.workTitle,
workDescription: editState.workDescription.trim(),
levelName: primaryLevel.levelName,
summary: primaryLevel.pictureDescription,
summary: editState.workDescription.trim(),
themeTags: editState.themeTags,
candidates: primaryLevel.candidates,
selectedCandidateId: primaryLevel.selectedCandidateId,
@@ -1378,7 +1378,7 @@ export function PuzzleResultView({
workTitle: normalizedState.workTitle,
workDescription: normalizedState.workDescription,
levelName: firstLevel.levelName,
summary: firstLevel.pictureDescription,
summary: normalizedState.workDescription,
themeTags: normalizedState.themeTags,
coverImageSrc: resolveLevelFormalImageSrc(firstLevel) || null,
coverAssetId: firstLevel.coverAssetId ?? null,
@@ -1531,7 +1531,7 @@ export function PuzzleResultView({
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
levelName: firstLevel.levelName.trim(),
summary: firstLevel.pictureDescription.trim(),
summary: editState.workDescription.trim(),
themeTags: editState.themeTags,
levelsJson: JSON.stringify(editState.levels),
});
@@ -1555,7 +1555,7 @@ export function PuzzleResultView({
candidateCount: 1,
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
summary: activeLevel.pictureDescription.trim(),
summary: editState.workDescription.trim(),
themeTags: editState.themeTags,
levelsJson: JSON.stringify(editState.levels),
});

View File

@@ -1743,26 +1743,30 @@ export function PuzzleRuntimeShell({
{isExitRemodelPromptOpen ? (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-slate-950/72 px-4 py-6 backdrop-blur-sm"
className="absolute inset-0 z-50 flex items-center justify-center bg-slate-950/76 px-4 py-6 backdrop-blur-md"
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-exit-remodel-title"
className="flex w-full max-w-[22rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
className="relative flex w-full max-w-[21rem] flex-col overflow-hidden rounded-[1.35rem] border border-amber-200/24 bg-[linear-gradient(180deg,rgba(30,41,59,0.98),rgba(2,6,23,0.98))] shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
onClick={(event) => event.stopPropagation()}
>
<header className="px-5 pt-6 text-center">
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-amber-200/70 to-transparent" />
<header className="flex flex-col items-center px-6 pt-7 text-center">
<div className="mb-4 grid h-14 w-14 place-items-center rounded-2xl border border-amber-200/28 bg-amber-200/12 shadow-[0_16px_42px_rgba(251,191,36,0.18)]">
<Sparkles className="h-7 w-7 text-amber-200" />
</div>
<h2
id="puzzle-exit-remodel-title"
className="text-2xl font-black leading-tight text-white"
className="text-[1.75rem] font-black leading-[1.08] text-white"
>
<br />
!
</h2>
</header>
<footer className="grid gap-3 px-5 py-5">
<footer className="grid gap-3 px-5 pb-5 pt-6">
<button
type="button"
disabled={isBusy}
@@ -1770,7 +1774,7 @@ export function PuzzleRuntimeShell({
setIsExitRemodelPromptOpen(false);
void onRemodelWork?.(exitPromptProfileId);
}}
className="rounded-full bg-amber-200 px-5 py-3 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
className="min-h-[3.25rem] rounded-2xl bg-amber-200 px-5 text-sm font-black text-slate-950 shadow-[0_14px_34px_rgba(251,191,36,0.24)] transition hover:bg-amber-100 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
@@ -1780,7 +1784,7 @@ export function PuzzleRuntimeShell({
setIsExitRemodelPromptOpen(false);
onBack();
}}
className="rounded-full border border-white/14 bg-black/24 px-5 py-3 text-sm font-black text-white transition hover:bg-white/10"
className="min-h-[3rem] rounded-2xl border border-white/14 bg-white/8 px-5 text-sm font-bold text-white/92 transition hover:bg-white/12 active:translate-y-px"
>
退
</button>

View File

@@ -1782,6 +1782,21 @@ beforeEach(() => {
vi.mocked(remixPuzzleGalleryWork).mockRejectedValue(
new Error('未启用拼图 remix'),
);
vi.mocked(startPuzzleRun).mockImplementation(async (payload) => {
const run = buildMockPuzzleRun(payload.profileId, '后端拼图关卡');
return {
run: {
...run,
currentLevel: run.currentLevel
? {
...run.currentLevel,
levelId: payload.levelId ?? run.currentLevel.levelId,
startedAtMs: Date.now(),
}
: run.currentLevel,
},
};
});
vi.mocked(advancePuzzleNextLevel).mockImplementation(async (runId) => ({
run: buildMockPuzzleRun(`${runId}-next-profile`, '后端推荐下一关'),
}));
@@ -2496,12 +2511,6 @@ test('published puzzle works appear on home and mobile game category channel', a
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
vi.mocked(startPuzzleRun).mockResolvedValue({
run: buildMockPuzzleRun(
publishedPuzzleWork.profileId,
publishedPuzzleWork.levelName,
),
});
render(<TestWrapper />);
@@ -2587,12 +2596,6 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
vi.mocked(startPuzzleRun).mockResolvedValue({
run: buildMockPuzzleRun(
publishedPuzzleWork.profileId,
publishedPuzzleWork.levelName,
),
});
render(<TestWrapper withAuth />);
@@ -2959,63 +2962,24 @@ test('published puzzle work card restores its source session for editing', async
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
});
test('formal puzzle next level uses backend run and leaderboard keeps frontend level snapshot', async () => {
test('formal puzzle runtime uses frontend move merge logic and backend leaderboard next level', async () => {
const user = userEvent.setup();
const firstLevelLeaderboardEntries = [
{
rank: 1,
nickname: '测试玩家',
elapsedMs: 12_000,
isCurrentPlayer: true,
},
];
const firstLevel = buildClearedPuzzleRun({
const clearedFirstLevel = buildClearedPuzzleRun({
runId: 'run-puzzle-profile-public-1',
entryProfileId: 'puzzle-profile-public-1',
profileId: 'puzzle-profile-public-1',
levelName: '雨夜猫塔',
levelIndex: 1,
elapsedMs: 12_000,
recommendedNextProfileId: 'puzzle-profile-public-2',
leaderboardEntries: firstLevelLeaderboardEntries,
elapsedMs: 18_000,
});
const secondLevelBase = buildMockPuzzleRun(
'puzzle-profile-public-2',
'星桥机关',
);
const secondLevel: PuzzleRunSnapshot = {
...secondLevelBase,
runId: firstLevel.runId,
entryProfileId: firstLevel.entryProfileId,
currentLevelIndex: 2,
playedProfileIds: [
'puzzle-profile-public-1',
'puzzle-profile-public-2',
],
currentLevel: {
...secondLevelBase.currentLevel!,
runId: firstLevel.runId,
levelIndex: 2,
startedAtMs: Date.now(),
},
const clearedFirstLevelWithNext = {
...clearedFirstLevel,
recommendedNextProfileId: 'puzzle-profile-public-1',
nextLevelMode: 'sameWork' as const,
nextLevelProfileId: 'puzzle-profile-public-1',
nextLevelId: 'puzzle-level-2',
recommendedNextWorks: [],
};
const clearedSecondLevel = buildClearedPuzzleRun({
runId: firstLevel.runId,
entryProfileId: firstLevel.entryProfileId,
profileId: 'puzzle-profile-public-2',
levelName: '星桥机关',
levelIndex: 2,
elapsedMs: 18_000,
});
const serviceLeaderboardRun = buildClearedPuzzleRun({
runId: firstLevel.runId,
entryProfileId: firstLevel.entryProfileId,
profileId: 'puzzle-profile-public-1',
levelName: '雨夜猫塔',
levelIndex: 1,
elapsedMs: 18_000,
recommendedNextProfileId: 'puzzle-profile-public-2',
});
const leaderboardEntries = [
{
rank: 1,
@@ -3024,27 +2988,49 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
isCurrentPlayer: true,
},
];
vi.mocked(startPuzzleRun).mockResolvedValue({ run: firstLevel });
vi.mocked(advancePuzzleNextLevel).mockResolvedValue({ run: secondLevel });
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
run: {
...serviceLeaderboardRun,
const backendLeaderboardRun = {
...clearedFirstLevelWithNext,
leaderboardEntries,
currentLevel: {
...clearedFirstLevelWithNext.currentLevel!,
leaderboardEntries,
},
};
const backendSecondLevel = {
...buildMockPuzzleRun('puzzle-profile-public-1', '星桥机关'),
runId: clearedFirstLevel.runId,
entryProfileId: clearedFirstLevel.entryProfileId,
currentLevelIndex: 2,
currentLevel: {
...buildMockPuzzleRun('puzzle-profile-public-1', '星桥机关')
.currentLevel!,
runId: clearedFirstLevel.runId,
levelIndex: 2,
levelId: 'puzzle-level-2',
startedAtMs: Date.now(),
},
};
const backendStartedRun = buildMockPuzzleRun(
'puzzle-profile-public-1',
'雨夜猫塔',
);
vi.mocked(startPuzzleRun).mockResolvedValue({
run: {
...backendStartedRun,
currentLevel: {
...serviceLeaderboardRun.currentLevel!,
leaderboardEntries,
...backendStartedRun.currentLevel!,
startedAtMs: Date.now(),
},
},
});
vi.mocked(dragPuzzlePieceOrGroup).mockResolvedValue({
run: clearedSecondLevel,
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
run: backendLeaderboardRun,
});
vi.mocked(swapPuzzlePieces).mockResolvedValue({
run: clearedSecondLevel,
vi.mocked(advancePuzzleNextLevel).mockResolvedValue({
run: backendSecondLevel,
});
vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedSecondLevel);
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedSecondLevel);
vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedFirstLevel);
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedFirstLevel);
const puzzleWork: PuzzleWorkSummary = {
workId: 'puzzle-work-public-1',
@@ -3063,6 +3049,28 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
playCount: 8,
likeCount: 0,
publishReady: true,
levels: [
{
levelId: 'puzzle-level-1',
levelName: '雨夜猫塔',
pictureDescription: '雨夜猫塔首关。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'ready',
},
{
levelId: 'puzzle-level-2',
levelName: '星桥机关',
pictureDescription: '星桥机关第二关。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'ready',
},
],
};
vi.mocked(listPuzzleGallery).mockResolvedValue({
@@ -3071,7 +3079,6 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: puzzleWork,
});
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
@@ -3082,39 +3089,216 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
await user.click(await screen.findByRole('button', { name: '启动' }));
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith({
levelId: null,
profileId: 'puzzle-profile-public-1',
});
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
});
await user.click(
await screen.findByRole('button', { name: '下一关' }, { timeout: 3000 }),
);
await waitFor(() => {
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(firstLevel.runId);
expect(startPuzzleRun).toHaveBeenCalledWith({
profileId: 'puzzle-profile-public-1',
levelId: null,
});
expect(advancePuzzleNextLevel).toHaveBeenCalledTimes(1);
expect((await screen.findAllByText('星桥机关')).length).toBeGreaterThan(0);
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
await waitFor(() => {
expect(submitPuzzleLeaderboard).toHaveBeenCalledWith(firstLevel.runId, {
profileId: 'puzzle-profile-public-2',
gridSize: 3,
elapsedMs: 18_000,
nickname: '测试玩家',
});
expect(swapLocalPuzzlePieces).toHaveBeenCalled();
});
expect(swapPuzzlePieces).not.toHaveBeenCalled();
expect(dragPuzzlePieceOrGroup).not.toHaveBeenCalled();
await waitFor(() => {
expect(submitPuzzleLeaderboard).toHaveBeenCalledWith(
clearedFirstLevel.runId,
{
profileId: 'puzzle-profile-public-1',
gridSize: 3,
elapsedMs: 18_000,
nickname: '测试玩家',
},
);
});
expect(
await screen.findByRole('dialog', { name: '通关完成' }, { timeout: 3000 }),
).toBeTruthy();
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
const dialog = await screen.findByRole(
'dialog',
{ name: '通关完成' },
{ timeout: 3000 },
);
expect(dialog).toBeTruthy();
expect(screen.getByText('测试玩家')).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
await waitFor(() => {
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedFirstLevel.runId,
);
});
expect(
(await screen.findAllByText('星桥机关', undefined, {
timeout: 3000,
})).length,
).toBeGreaterThan(0);
});
test('formal puzzle similar work keeps current run level progression', async () => {
const user = userEvent.setup();
const clearedThirdLevel = buildClearedPuzzleRun({
runId: 'run-puzzle-profile-public-1',
entryProfileId: 'puzzle-profile-public-1',
profileId: 'puzzle-profile-public-1',
levelName: '雨夜猫塔',
levelIndex: 3,
elapsedMs: 18_000,
recommendedNextProfileId: 'puzzle-profile-similar-2',
});
const clearedThirdLevelWithCandidates: PuzzleRunSnapshot = {
...clearedThirdLevel,
nextLevelMode: 'similarWorks',
nextLevelProfileId: 'puzzle-profile-similar-1',
nextLevelId: null,
recommendedNextWorks: [
{
profileId: 'puzzle-profile-similar-1',
levelName: '雾海遗迹',
authorDisplayName: '星桥旅人',
themeTags: ['奇幻', '遗迹'],
coverImageSrc: null,
similarityScore: 0.91,
},
{
profileId: 'puzzle-profile-similar-2',
levelName: '风塔试炼',
authorDisplayName: '晨风',
themeTags: ['奇幻', '机关'],
coverImageSrc: null,
similarityScore: 0.84,
},
],
};
const similarFourthLevel = {
...buildMockPuzzleRun('puzzle-profile-similar-2', '风塔试炼'),
runId: clearedThirdLevel.runId,
entryProfileId: clearedThirdLevel.entryProfileId,
currentLevelIndex: 4,
currentGridSize: 5 as const,
playedProfileIds: [
'puzzle-profile-public-1',
'puzzle-profile-similar-2',
],
currentLevel: {
...buildMockPuzzleRun('puzzle-profile-similar-2', '风塔试炼')
.currentLevel!,
runId: clearedThirdLevel.runId,
levelIndex: 4,
levelId: 'similar-level-1',
gridSize: 5 as const,
timeLimitMs: 210_000,
remainingMs: 210_000,
startedAtMs: Date.now(),
board: {
rows: 5,
cols: 5,
selectedPieceId: null,
allTilesResolved: false,
mergedGroups: [],
pieces: Array.from({ length: 25 }, (_, index) => ({
pieceId: `piece-${index}`,
correctRow: Math.floor(index / 5),
correctCol: index % 5,
currentRow: Math.floor(index / 5),
currentCol: index % 5,
mergedGroupId: null,
})),
},
},
};
const backendStartedRun = buildMockPuzzleRun(
'puzzle-profile-public-1',
'雨夜猫塔',
);
vi.mocked(startPuzzleRun).mockResolvedValue({
run: {
...backendStartedRun,
currentLevel: {
...backendStartedRun.currentLevel!,
startedAtMs: Date.now(),
},
},
});
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
run: clearedThirdLevelWithCandidates,
});
vi.mocked(advancePuzzleNextLevel).mockResolvedValue({
run: similarFourthLevel,
});
vi.mocked(dragLocalPuzzlePiece).mockReturnValue(clearedThirdLevel);
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedThirdLevel);
const entryWork: PuzzleWorkSummary = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: null,
authorDisplayName: '拼图作者',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
likeCount: 0,
publishReady: true,
};
const similarWork: PuzzleWorkSummary = {
...entryWork,
workId: 'puzzle-work-similar-2',
profileId: 'puzzle-profile-similar-2',
levelName: '风塔试炼',
summary: '另一套奇幻机关拼图。',
};
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [entryWork],
});
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
item: profileId === similarWork.profileId ? similarWork : entryWork,
}));
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
await user.click(await screen.findByRole('button', { name: '启动' }));
await waitFor(() => {
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
});
vi.mocked(startPuzzleRun).mockClear();
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
const dialog = await screen.findByRole(
'dialog',
{ name: '通关完成' },
{ timeout: 3000 },
);
await user.click(within(dialog).getByRole('button', { name: //u }));
await waitFor(() => {
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedThirdLevel.runId,
{ targetProfileId: 'puzzle-profile-similar-2' },
);
});
expect(startPuzzleRun).not.toHaveBeenCalled();
expect(await screen.findByText('第 4 关')).toBeTruthy();
await waitFor(() => {
expect(document.querySelectorAll('[data-piece-id]').length).toBe(25);
});
});
test('first puzzle runtime back click can open remix result page', async () => {
@@ -3177,9 +3361,6 @@ test('first puzzle runtime back click can open remix result page', async () => {
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: puzzleWork,
});
vi.mocked(startPuzzleRun).mockResolvedValue({
run: buildMockPuzzleRun(puzzleWork.profileId, puzzleWork.levelName),
});
vi.mocked(remixPuzzleGalleryWork).mockResolvedValue({
session: remixSession,
});
@@ -4795,7 +4976,7 @@ test('creation hub published work experience button enters world directly', asyn
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
});
test('creation hub published work card no longer exposes direct delete action', async () => {
test('creation hub published work card keeps delete action guarded by detail flow', async () => {
const user = userEvent.setup();
const publishedWork = {
@@ -4867,6 +5048,6 @@ test('creation hub published work card no longer exposes direct delete action',
await openCreationHub(user);
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
});