Add public work read model and smooth puzzle transitions
This commit is contained in:
@@ -6245,6 +6245,260 @@ test('home recommendation keeps logged-in puzzle start on default auth instead o
|
||||
);
|
||||
});
|
||||
|
||||
test('logged out home recommendation next starts the next puzzle work', async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstWork = {
|
||||
workId: 'puzzle-work-public-next-1',
|
||||
profileId: 'puzzle-profile-public-next-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-public-next-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '家常菜',
|
||||
summary: '酱猪蹄不是酱肘子。',
|
||||
themeTags: ['家常菜', '拼图'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
publishedAt: '2026-04-25T10:00:00.000Z',
|
||||
playCount: 47,
|
||||
likeCount: 1,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
const secondWork = {
|
||||
...firstWork,
|
||||
workId: 'puzzle-work-public-next-2',
|
||||
profileId: 'puzzle-profile-public-next-2',
|
||||
ownerUserId: 'user-3',
|
||||
sourceSessionId: 'puzzle-session-public-next-2',
|
||||
authorDisplayName: '贝壳作者',
|
||||
levelName: '贝壳',
|
||||
summary: '第二个公开拼图。',
|
||||
themeTags: ['贝壳', '拼图'],
|
||||
playCount: 1,
|
||||
likeCount: 0,
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [firstWork, secondWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
|
||||
item: profileId === secondWork.profileId ? secondWork : firstWork,
|
||||
}));
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action) => action(),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const recommendNavButton = document.querySelector<HTMLButtonElement>(
|
||||
'.platform-bottom-nav [aria-label="推荐"]',
|
||||
);
|
||||
expect(recommendNavButton).toBeTruthy();
|
||||
await user.click(recommendNavButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: firstWork.profileId,
|
||||
levelId: null,
|
||||
},
|
||||
expect.objectContaining({
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '下一个' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: secondWork.profileId,
|
||||
levelId: null,
|
||||
},
|
||||
expect.objectContaining({
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation puzzle next level switches to similar work detail', async () => {
|
||||
const user = userEvent.setup();
|
||||
const entryWork = {
|
||||
workId: 'puzzle-work-public-guest-1',
|
||||
profileId: 'puzzle-profile-public-guest-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-public-guest-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '雨夜猫塔',
|
||||
summary: '旋转碎片并接通星桥机关。',
|
||||
themeTags: ['机关', '星桥'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
playCount: 3,
|
||||
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',
|
||||
},
|
||||
],
|
||||
} satisfies PuzzleWorkSummary;
|
||||
const similarWork = {
|
||||
...entryWork,
|
||||
workId: 'puzzle-work-similar-guest-1',
|
||||
profileId: 'puzzle-profile-similar-guest-1',
|
||||
levelName: '风塔试炼',
|
||||
summary: '另一套奇幻机关拼图。',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'similar-level-1',
|
||||
levelName: '风塔试炼',
|
||||
pictureDescription: '相似作品首关。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
} satisfies PuzzleWorkSummary;
|
||||
const clearedRun = buildClearedPuzzleRun({
|
||||
runId: 'run-puzzle-profile-public-guest-1',
|
||||
entryProfileId: entryWork.profileId,
|
||||
profileId: entryWork.profileId,
|
||||
levelName: entryWork.levelName,
|
||||
levelIndex: 1,
|
||||
elapsedMs: 18_000,
|
||||
});
|
||||
const clearedRunWithSameWorkNext: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
recommendedNextProfileId: entryWork.profileId,
|
||||
nextLevelMode: 'sameWork',
|
||||
nextLevelProfileId: entryWork.profileId,
|
||||
nextLevelId: 'puzzle-level-2',
|
||||
recommendedNextWorks: [],
|
||||
};
|
||||
const startedRun = buildMockPuzzleRun(entryWork.profileId, entryWork.levelName);
|
||||
const similarRun = {
|
||||
...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName),
|
||||
runId: clearedRun.runId,
|
||||
entryProfileId: entryWork.profileId,
|
||||
currentLevelIndex: 2,
|
||||
currentLevel: {
|
||||
...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName)
|
||||
.currentLevel!,
|
||||
runId: clearedRun.runId,
|
||||
levelIndex: 2,
|
||||
levelId: 'similar-level-1',
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [entryWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
|
||||
item: profileId === similarWork.profileId ? similarWork : entryWork,
|
||||
}));
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: {
|
||||
...startedRun,
|
||||
currentLevel: {
|
||||
...startedRun.currentLevel!,
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({
|
||||
run: clearedRunWithSameWorkNext,
|
||||
});
|
||||
let resolveAdvancePuzzleNextLevel!: (value: { run: PuzzleRunSnapshot }) => void;
|
||||
vi.mocked(advancePuzzleNextLevel).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveAdvancePuzzleNextLevel = resolve;
|
||||
}),
|
||||
);
|
||||
vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedRun);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: entryWork.profileId,
|
||||
levelId: null,
|
||||
},
|
||||
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||
});
|
||||
|
||||
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: '下一关' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
||||
clearedRun.runId,
|
||||
{ preferSimilarWork: true },
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||
expect(screen.queryByText('加载中...')).toBeNull();
|
||||
|
||||
resolveAdvancePuzzleNextLevel({ run: similarRun });
|
||||
await waitFor(() => {
|
||||
expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(similarWork.profileId);
|
||||
});
|
||||
expect(
|
||||
await screen.findByLabelText('风塔试炼 作品信息', undefined, {
|
||||
timeout: 3000,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText('风塔试炼').length).toBeGreaterThan(0);
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => {
|
||||
const match3dCard: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-card-1',
|
||||
@@ -7321,6 +7575,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelId: null,
|
||||
},
|
||||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
vi.mocked(listProfileSaveArchives).mockClear();
|
||||
vi.mocked(listProfileSaveArchives).mockRejectedValueOnce(
|
||||
|
||||
@@ -195,8 +195,8 @@ export interface RpgEntryHomeViewProps {
|
||||
activeRecommendEntryKey?: string | null;
|
||||
isStartingRecommendEntry?: boolean;
|
||||
recommendRuntimeError?: string | null;
|
||||
onSelectNextRecommendEntry?: () => void;
|
||||
onSelectPreviousRecommendEntry?: () => void;
|
||||
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onOpenLibraryDetail: (
|
||||
@@ -5056,6 +5056,9 @@ export function RpgEntryHomeView({
|
||||
const [recommendShareState, setRecommendShareState] = useState<
|
||||
'idle' | 'copied' | 'failed'
|
||||
>('idle');
|
||||
const activeRecommendEntryKeyForSelection = activeRecommendEntry
|
||||
? buildPublicGalleryCardKey(activeRecommendEntry)
|
||||
: null;
|
||||
const recommendShareResetTimerRef = useRef<number | null>(null);
|
||||
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
|
||||
const recommendDragStartRef = useRef<{
|
||||
@@ -5078,15 +5081,16 @@ export function RpgEntryHomeView({
|
||||
);
|
||||
window.setTimeout(() => {
|
||||
if (direction === 1) {
|
||||
onSelectNextRecommendEntry?.();
|
||||
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||||
} else {
|
||||
onSelectPreviousRecommendEntry?.();
|
||||
onSelectPreviousRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||||
}
|
||||
setRecommendDragOffsetY(0);
|
||||
setRecommendDragCommitDirection(null);
|
||||
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
|
||||
},
|
||||
[
|
||||
activeRecommendEntryKeyForSelection,
|
||||
onSelectNextRecommendEntry,
|
||||
onSelectPreviousRecommendEntry,
|
||||
recommendDragCommitDirection,
|
||||
@@ -5186,9 +5190,10 @@ export function RpgEntryHomeView({
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectNextRecommendEntry?.();
|
||||
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||||
}, [
|
||||
activeRecommendEntry,
|
||||
activeRecommendEntryKeyForSelection,
|
||||
commitRecommendDrag,
|
||||
isAuthenticated,
|
||||
onSelectNextRecommendEntry,
|
||||
|
||||
Reference in New Issue
Block a user