Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes

This commit is contained in:
kdletters
2026-06-06 20:01:52 +08:00
425 changed files with 16451 additions and 6022 deletions

View File

@@ -13,14 +13,16 @@ import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
JumpHopRuntimeRunSnapshotResponse,
JumpHopWorkDetailResponse,
JumpHopWorkProfileResponse,
JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
JumpHopWorkDetailResponse,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
@@ -40,6 +42,7 @@ import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
@@ -50,6 +53,7 @@ import { ApiClientError } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService';
import {
createBarkBattleDraft,
deleteBarkBattleWork,
generateAllBarkBattleImageAssets,
listBarkBattleGallery,
listBarkBattleWorks,
@@ -67,6 +71,7 @@ import {
submitBigFishInput,
} from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
import {
type CreationEntryConfig,
fetchCreationEntryConfig,
@@ -86,7 +91,6 @@ import {
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
import { match3dCreationClient } from '../../services/match3d-creation';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import {
@@ -607,6 +611,24 @@ vi.mock('../../services/puzzle-runtime', () => ({
usePuzzleRuntimeProp: vi.fn(),
}));
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
jumpHopClient: {
createSession: vi.fn(),
deleteWork: vi.fn(),
executeAction: vi.fn(),
getGalleryDetail: vi.fn(),
getLeaderboard: vi.fn(),
getSession: vi.fn(),
getWorkDetail: vi.fn(),
listGallery: vi.fn(),
listWorks: vi.fn(),
publishWork: vi.fn(),
restartRun: vi.fn(),
startRun: vi.fn(),
submitJump: vi.fn(),
},
}));
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
...rpgEntryLibraryServiceMocks,
}));
@@ -634,6 +656,7 @@ vi.mock('../../services/big-fish-runtime', () => ({
vi.mock('../../services/bark-battle-creation', () => ({
createBarkBattleDraft: vi.fn(),
deleteBarkBattleWork: vi.fn(),
generateAllBarkBattleImageAssets: vi.fn(),
listBarkBattleGallery: vi.fn(),
listBarkBattleWorks: vi.fn(),
@@ -653,34 +676,24 @@ vi.mock('../../services/edutainment-baby-object', () => ({
saveBabyObjectMatchDraft: vi.fn(),
}));
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
jumpHopClient: {
createSession: vi.fn(),
executeAction: vi.fn(),
getGalleryDetail: vi.fn(),
getSession: vi.fn(),
getWorkDetail: vi.fn(),
listGallery: vi.fn(),
listWorks: vi.fn(),
publishWork: vi.fn(),
restartRun: vi.fn(),
startRun: vi.fn(),
submitJump: vi.fn(),
},
}));
vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
woodenFishClient: {
checkpointRun: vi.fn(),
createSession: vi.fn(),
deleteWork: vi.fn(),
executeAction: vi.fn(),
finishRun: vi.fn(),
getGalleryDetail: vi.fn(),
getSession: vi.fn(),
getWorkDetail: vi.fn(),
listGallery: vi.fn(),
listWorks: vi.fn(),
listGallery: vi.fn(async () => ({
hasMore: false,
items: [],
nextCursor: null,
})),
listWorks: vi.fn(async () => ({ items: [] })),
publishWork: vi.fn(),
restartRun: vi.fn(),
startRun: vi.fn(),
},
}));
@@ -798,22 +811,6 @@ vi.mock('../../services/visual-novel-works', () => ({
updateVisualNovelWork: vi.fn(),
}));
vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
woodenFishClient: {
checkpointRun: vi.fn(),
createSession: vi.fn(),
executeAction: vi.fn(),
finishRun: vi.fn(),
getGalleryDetail: vi.fn(),
getSession: vi.fn(),
getWorkDetail: vi.fn(),
listGallery: vi.fn(),
listWorks: vi.fn(),
publishWork: vi.fn(),
startRun: vi.fn(),
},
}));
vi.mock('../../services/visual-novel-creation', () => ({
compileVisualNovelWorkProfile: vi.fn(),
createVisualNovelSession: vi.fn(),
@@ -1629,6 +1626,7 @@ function buildMockJumpHopWork(
templateId: 'jump-hop',
templateName: '跳一跳',
profileId,
themeText: '云朵跳台',
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵', '星空'],
@@ -1642,6 +1640,7 @@ function buildMockJumpHopWork(
tileAssets,
path,
coverComposite: 'data:image/png;base64,cover',
backButtonAsset: null,
generationStatus: 'ready' as const,
};
@@ -1652,6 +1651,7 @@ function buildMockJumpHopWork(
profileId,
ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-1',
themeText: draft.themeText,
workTitle: draft.workTitle,
workDescription: draft.workDescription,
themeTags: draft.themeTags,
@@ -1671,6 +1671,7 @@ function buildMockJumpHopWork(
characterAsset,
tileAtlasAsset,
tileAssets,
backButtonAsset: null,
};
}
@@ -2725,6 +2726,7 @@ beforeEach(() => {
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(deleteBarkBattleWork).mockResolvedValue({ items: [] });
vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] });
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
vi.mocked(woodenFishClient.listGallery).mockResolvedValue({
@@ -2733,6 +2735,7 @@ beforeEach(() => {
nextCursor: null,
});
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] });
vi.mocked(woodenFishClient.deleteWork).mockResolvedValue({ items: [] });
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
vi.mocked(jumpHopClient.listGallery).mockResolvedValue({
@@ -2741,6 +2744,7 @@ beforeEach(() => {
nextCursor: null,
});
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({ items: [] });
vi.mocked(jumpHopClient.deleteWork).mockResolvedValue({ items: [] });
vi.mocked(jumpHopClient.getSession).mockRejectedValue(
new Error('未找到跳一跳会话'),
);
@@ -2753,6 +2757,7 @@ beforeEach(() => {
nextCursor: null,
});
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] });
vi.mocked(woodenFishClient.deleteWork).mockResolvedValue({ items: [] });
vi.mocked(woodenFishClient.getSession).mockRejectedValue(
new Error('未找到敲木鱼会话'),
);
@@ -4228,6 +4233,115 @@ test('background match3d draft failure notifies and reopens failed retry page',
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1);
});
test('failed match3d draft retry reuses current session instead of creating another draft', async () => {
const user = userEvent.setup();
const failedSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-retry-failed-session',
draft: null,
stage: 'collecting_config',
updatedAt: '2026-05-18T12:05:00.000Z',
});
const persistedFailedWork: Match3DWorkSummary = {
workId: 'match3d-retry-failed-work',
profileId: 'match3d-retry-failed-profile',
ownerUserId: 'user-1',
sourceSessionId: failedSession.sessionId,
gameName: '重试抓鹅',
themeText: '霓虹水果摊',
summary: '抓大鹅素材生成失败,可重新打开处理。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-18T12:05:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
generatedItemAssets: [],
};
let rejectCompile!: (reason?: unknown) => void;
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: failedSession,
});
vi.mocked(match3dCreationClient.executeAction).mockReturnValueOnce(
new Promise((_, reject) => {
rejectCompile = reject;
}),
);
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: failedSession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
await screen.findByRole('progressbar', { name: '抓大鹅草稿生成进度' });
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [persistedFailedWork],
});
await act(async () => {
rejectCompile(new Error('抓大鹅素材服务失败'));
await Promise.resolve();
});
const failureDialog = await screen.findByRole('dialog', {
name: '发生错误',
});
await user.click(within(failureDialog).getByRole('button', { name: '关闭' }));
const draftPanel = getPlatformTabPanel('saves');
await user.click(
await within(draftPanel).findByRole('button', {
name: /继续创作《(?:重试抓鹅|抓大鹅草稿)》/u,
}),
);
const reopenedFailureDialog = await screen.findByRole('dialog', {
name: '发生错误',
});
await user.click(
within(reopenedFailureDialog).getByRole('button', { name: '关闭' }),
);
vi.mocked(match3dCreationClient.executeAction).mockResolvedValueOnce({
session: buildMockMatch3DAgentSession({
sessionId: failedSession.sessionId,
stage: 'draft_ready',
draft: {
profileId: persistedFailedWork.profileId,
gameName: persistedFailedWork.gameName,
themeText: persistedFailedWork.themeText,
summary: persistedFailedWork.summary,
tags: persistedFailedWork.tags,
coverImageSrc: null,
referenceImageSrc: null,
clearCount: persistedFailedWork.clearCount,
difficulty: persistedFailedWork.difficulty,
generatedItemAssets: [],
},
}),
});
await user.click(await screen.findByRole('button', { name: '重新生成草稿' }));
await waitFor(() => {
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(2);
});
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1);
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
2,
failedSession.sessionId,
expect.objectContaining({ action: 'match3d_compile_draft' }),
);
});
test('running match3d persisted draft reopens progress instead of unfinished result', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
@@ -4921,6 +5035,113 @@ test('failed parallel puzzle generations stay as separate non-generating drafts'
.toBeTruthy();
});
test('failed puzzle draft retry reuses current session instead of creating another draft', async () => {
const user = userEvent.setup();
const failedSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-retry-failed-session',
draft: null,
stage: 'collecting_anchors',
updatedAt: '2026-05-18T12:00:00.000Z',
});
const persistedFailedWork: PuzzleWorkSummary = {
workId: `puzzle-work-${failedSession.sessionId}`,
profileId: `puzzle-profile-${failedSession.sessionId}`,
ownerUserId: 'user-1',
sourceSessionId: failedSession.sessionId,
authorDisplayName: '测试玩家',
workTitle: '',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '第1关',
summary: '一套雨夜猫街主题拼图。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-05-18T12:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
generationStatus: 'failed',
levels: [],
};
let rejectCompile!: (reason?: unknown) => void;
vi.mocked(createPuzzleAgentSession).mockResolvedValue({
session: failedSession,
});
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: failedSession,
});
vi.mocked(executePuzzleAgentAction).mockReturnValueOnce(
new Promise((_, reject) => {
rejectCompile = reject;
}),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await screen.findByRole('progressbar', { name: '拼图图片生成进度' });
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [persistedFailedWork],
});
await act(async () => {
rejectCompile(new Error('拼图图片生成失败'));
await Promise.resolve();
});
const failureDialog = await screen.findByRole('dialog', {
name: '发生错误',
});
await user.click(within(failureDialog).getByRole('button', { name: '关闭' }));
const draftPanel = getPlatformTabPanel('saves');
await user.click(
await within(draftPanel).findByRole('button', {
name: /继续创作《[^》]+》/u,
}),
);
const reopenedFailureDialog = await screen.findByRole('dialog', {
name: '发生错误',
});
await user.click(
within(reopenedFailureDialog).getByRole('button', { name: '关闭' }),
);
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'compile-puzzle-retry',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: buildMockPuzzleAgentSession({
sessionId: failedSession.sessionId,
stage: 'ready_to_publish',
progressPercent: 100,
draft: buildReadyPuzzleDraft(),
}),
});
await user.click(await screen.findByRole('button', { name: '重新生成图片' }));
await waitFor(() => {
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
});
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
2,
failedSession.sessionId,
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
});
test('running puzzle draft opens generation progress from draft tab', async () => {
const user = userEvent.setup();
const runningSession = buildMockPuzzleAgentSession({
@@ -5127,7 +5348,7 @@ test('match3d result trial passes generated models into first runtime mount', as
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-draft-1',
{},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -5220,7 +5441,7 @@ test('match3d result trial passes generated 2D image views into first runtime mo
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-draft-2d-1',
{},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -6244,10 +6465,10 @@ test('opening a compiled draft with a missing agent session falls back to draft
await waitFor(() => {
expect(fallbackDraftPanel.getAttribute('aria-hidden')).toBe('false');
expect(
within(fallbackDraftPanel).getByText(
screen.getAllByText(
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
),
).toBeTruthy();
).length,
).toBeGreaterThan(0);
});
expect(window.location.search).toBe('');
@@ -6363,6 +6584,213 @@ test('logged out public detail gates puzzle start and remix before real actions'
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
});
test('logged out public jump-hop detail starts runtime without requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
const publishedJumpHopWork: JumpHopWorkProfileResponse = {
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-public-1',
profileId: 'jump-hop-profile-public-12345678',
ownerUserId: 'user-2',
sourceSessionId: 'jump-hop-session-public-1',
themeText: '云上方块',
workTitle: '云上方块跳一跳',
workDescription: '在云层地块之间连续弹跳。',
themeTags: ['云层', '跳跃'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: null,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-05-29T10:00:00.000Z',
publishedAt: '2026-05-29T10:00:00.000Z',
publishReady: true,
generationStatus: 'ready',
},
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'jump-hop-profile-public-12345678',
themeText: '云上方块',
workTitle: '云上方块跳一跳',
workDescription: '在云层地块之间连续弹跳。',
themeTags: ['云层', '跳跃'],
difficulty: 'standard',
stylePreset: 'paper-toy',
defaultCharacter: {
characterId: 'builtin-default',
displayName: '默认角色',
modelKind: 'builtin-three',
bodyColor: '#df7f40',
accentColor: '#2563eb',
},
characterPrompt: '',
tilePrompt: '云上方块',
endMoodPrompt: null,
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: null,
generationStatus: 'ready',
},
path: {
seed: 'jump-hop-public-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-0',
tileType: 'start',
x: 0,
y: 0,
width: 1,
height: 1,
landingRadius: 0.7,
perfectRadius: 0.25,
scoreValue: 1,
},
{
platformId: 'platform-1',
tileType: 'normal',
x: 1,
y: 1,
width: 1,
height: 1,
landingRadius: 0.7,
perfectRadius: 0.25,
scoreValue: 1,
},
{
platformId: 'platform-2',
tileType: 'normal',
x: -1,
y: 2,
width: 1,
height: 1,
landingRadius: 0.7,
perfectRadius: 0.25,
scoreValue: 1,
},
],
finishIndex: 2,
cameraPreset: 'portrait-top-down',
scoring: {
chargeToDistanceRatio: 1,
maxChargeMs: 1800,
hitBonus: 0,
perfectBonus: 0,
},
},
defaultCharacter: {
characterId: 'builtin-default',
displayName: '默认角色',
modelKind: 'builtin-three',
bodyColor: '#df7f40',
accentColor: '#2563eb',
},
characterAsset: {
assetId: 'builtin-character',
imageSrc: '',
imageObjectKey: '',
assetObjectId: '',
generationProvider: 'builtin',
prompt: '',
width: 1,
height: 1,
},
tileAtlasAsset: {
assetId: 'tile-atlas-1',
imageSrc: '/generated-jump-hop-assets/public/atlas.png',
imageObjectKey: 'generated-jump-hop-assets/public/atlas.png',
assetObjectId: 'asset-tile-atlas-1',
generationProvider: 'gpt-image-2',
prompt: '云上方块',
width: 1024,
height: 1024,
},
tileAssets: [],
};
const publishedJumpHopRun: JumpHopRuntimeRunSnapshotResponse = {
runId: 'jump-hop-run-public-1',
profileId: publishedJumpHopWork.summary.profileId,
ownerUserId: '',
status: 'playing',
currentPlatformIndex: 0,
successfulJumpCount: 0,
durationMs: 0,
score: 0,
combo: 0,
path: publishedJumpHopWork.path,
lastJump: null,
startedAtMs: 1_779_999_000_000,
finishedAtMs: null,
};
window.history.replaceState(null, '', '/works/detail?work=JH-12345678');
vi.mocked(jumpHopClient.listGallery).mockResolvedValue({
items: [
{
publicWorkCode: 'JH-12345678',
workId: publishedJumpHopWork.summary.workId,
profileId: publishedJumpHopWork.summary.profileId,
ownerUserId: publishedJumpHopWork.summary.ownerUserId,
authorDisplayName: '跳跃作者',
themeText: publishedJumpHopWork.summary.themeText,
workTitle: publishedJumpHopWork.summary.workTitle,
workDescription: publishedJumpHopWork.summary.workDescription,
coverImageSrc: null,
themeTags: publishedJumpHopWork.summary.themeTags,
difficulty: publishedJumpHopWork.summary.difficulty,
stylePreset: publishedJumpHopWork.summary.stylePreset,
publicationStatus: publishedJumpHopWork.summary.publicationStatus,
playCount: publishedJumpHopWork.summary.playCount,
updatedAt: publishedJumpHopWork.summary.updatedAt,
publishedAt: publishedJumpHopWork.summary.publishedAt,
generationStatus: publishedJumpHopWork.summary.generationStatus,
},
],
hasMore: false,
nextCursor: null,
});
vi.mocked(jumpHopClient.getWorkDetail).mockResolvedValue({
item: publishedJumpHopWork,
});
vi.mocked(jumpHopClient.startRun).mockResolvedValue({
run: publishedJumpHopRun,
});
vi.mocked(jumpHopClient.getLeaderboard).mockResolvedValue({
profileId: publishedJumpHopWork.summary.profileId,
items: [],
viewerBest: null,
});
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth,
})}
/>,
);
expect(await screen.findByText('详情')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
await waitFor(() => {
expect(jumpHopClient.startRun).toHaveBeenCalledWith(
publishedJumpHopWork.summary.profileId,
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
runtimeMode: 'published',
}),
);
});
expect(requireAuth).not.toHaveBeenCalled();
});
test('owned public puzzle detail edits original draft instead of remixing', async () => {
const user = userEvent.setup();
const ownedPuzzleWork = {
@@ -7823,6 +8251,7 @@ test('direct jump hop result route restores work detail by profile id', async ()
profileId: 'jump-hop-profile-restore-1',
ownerUserId: 'user-1',
sourceSessionId: null,
themeText: '恢复后的云端跳台',
workTitle: '恢复后的云端跳台',
workDescription: '从 profileId 回读完整跳一跳结果。',
themeTags: ['云朵'],
@@ -8995,7 +9424,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-public-1',
{},
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -9067,7 +9496,7 @@ test('published Match3D runtime receives persisted generated models', async () =
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-model-1',
{},
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -9135,10 +9564,10 @@ test('starting draft generation leaves the agent workspace and shows the generat
}),
).toBeTruthy();
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
expect(screen.getByText('当前世界信息')).toBeTruthy();
expect(screen.queryByText('当前世界信息')).toBeNull();
expect(screen.queryByText('回到工作区')).toBeNull();
expect(screen.getByText('世界承诺')).toBeTruthy();
expect(screen.getByText(/灯塔与禁航令共同决定谁能穿过死潮/u)).toBeTruthy();
expect(screen.queryByText('世界承诺')).toBeNull();
expect(screen.queryByText(/灯塔与禁航令共同决定谁能穿过死潮/u)).toBeNull();
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
});
@@ -11364,3 +11793,145 @@ test('creation hub published work card reveals delete action after card action r
expect(within(dialog).getByRole('button', { name: '确认删除' })).toBeTruthy();
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
});
test('creation hub gives jump hop wooden fish and bark battle cards the shared delete interaction', async () => {
const user = userEvent.setup();
const jumpHopWork = {
...buildMockJumpHopWork({
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-delete',
profileId: 'jump-hop-profile-delete',
ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-delete',
workTitle: '跳台删除草稿',
workDescription: '跳一跳草稿也应接入统一删除。',
themeTags: ['跳台'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-21T10:20:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
}).summary,
} satisfies JumpHopWorkSummaryResponse;
const woodenFishWork = {
runtimeKind: 'wooden-fish',
workId: 'wooden-fish-work-delete',
profileId: 'wooden-fish-profile-delete',
ownerUserId: 'user-1',
sourceSessionId: 'wooden-fish-session-delete',
workTitle: '木鱼删除草稿',
workDescription: '敲木鱼草稿也应接入统一删除。',
themeTags: ['木鱼'],
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-21T10:10:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
} satisfies WoodenFishWorkSummaryResponse;
const barkBattleWork = buildMockBarkBattleWork({
workId: 'bark-battle-work-delete',
draftId: 'bark-battle-draft-delete',
title: '声浪删除已发布',
summary: '汪汪声浪已发布作品也应接入统一删除。',
updatedAt: '2026-05-21T10:00:00.000Z',
publishedAt: '2026-05-21T10:00:00.000Z',
});
vi.mocked(fetchCreationEntryConfig).mockResolvedValueOnce({
...testCreationEntryConfig,
creationTypes: [
...testCreationEntryConfig.creationTypes,
{
id: 'jump-hop',
title: '跳一跳',
subtitle: '俯视角跳台挑战',
badge: '可创建',
imageSrc: '/creation-type-references/jump-hop.webp',
visible: true,
open: true,
sortOrder: 46,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
id: 'wooden-fish',
title: '敲木鱼',
subtitle: '功德敲击小游戏',
badge: '可创建',
imageSrc: '/creation-type-references/wooden-fish.webp',
visible: true,
open: true,
sortOrder: 47,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
],
});
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({
items: [jumpHopWork],
});
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({
items: [woodenFishWork],
});
vi.mocked(listBarkBattleWorks).mockResolvedValue({
items: [barkBattleWork],
});
vi.mocked(jumpHopClient.deleteWork).mockResolvedValueOnce({ items: [] });
vi.mocked(woodenFishClient.deleteWork).mockResolvedValueOnce({ items: [] });
vi.mocked(deleteBarkBattleWork).mockResolvedValueOnce({ items: [] });
render(<TestWrapper withAuth />);
await openDraftHub(user);
async function revealAndConfirmDelete(
cardName: RegExp,
title: string,
): Promise<void> {
const card = await screen.findByRole('button', { name: cardName });
card.focus();
await user.keyboard('{ArrowLeft}');
const shell = card.closest('.creation-work-card-shell');
if (!shell) {
throw new Error('作品卡应位于统一操作壳内');
}
await user.click(within(shell as HTMLElement).getByRole('button', { name: '删除' }));
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
expect(within(dialog).getByText(`${title}`)).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: '确认删除' }));
}
await revealAndConfirmDelete(/继续创作《跳台删除草稿》/u, '跳台删除草稿');
await waitFor(() => {
expect(jumpHopClient.deleteWork).toHaveBeenCalledWith(
'jump-hop-profile-delete',
);
});
await revealAndConfirmDelete(/继续创作《木鱼删除草稿》/u, '木鱼删除草稿');
await waitFor(() => {
expect(woodenFishClient.deleteWork).toHaveBeenCalledWith(
'wooden-fish-profile-delete',
);
});
await revealAndConfirmDelete(/查看详情《声浪删除已发布》/u, '声浪删除已发布');
await waitFor(() => {
expect(deleteBarkBattleWork).toHaveBeenCalledWith(
'bark-battle-work-delete',
);
});
});

View File

@@ -32,8 +32,10 @@ import {
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
type PlatformEdutainmentGalleryCard,
type PlatformJumpHopGalleryCard,
type PlatformPublicGalleryCard,
type PlatformPuzzleGalleryCard,
type PlatformWoodenFishGalleryCard,
} from './rpgEntryWorldPresentation';
const {
@@ -321,6 +323,7 @@ const {
const {
mockGetPublicAuthUserByCode,
mockGetPublicAuthUserById,
mockRefreshStoredAccessToken,
mockUpdateAuthProfile,
} = vi.hoisted(() => ({
mockGetPublicAuthUserByCode: vi.fn(
@@ -341,9 +344,14 @@ const {
avatarUrl: null,
}),
),
mockRefreshStoredAccessToken: vi.fn(async () => 'jwt-refreshed-token'),
mockUpdateAuthProfile: vi.fn(),
}));
vi.mock('../../services/apiClient', () => ({
refreshStoredAccessToken: mockRefreshStoredAccessToken,
}));
vi.mock('../../services/authService', () => ({
getPublicAuthUserByCode: mockGetPublicAuthUserByCode,
getPublicAuthUserById: mockGetPublicAuthUserById,
@@ -413,11 +421,6 @@ const originalUserAgent = navigator.userAgent;
const originalMaxTouchPoints = navigator.maxTouchPoints;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const DEFAULT_PROFILE_CREATED_AT = '2026-04-01T00:00:00.000Z';
function buildFreshProfileCreatedAt() {
return new Date().toISOString();
}
function dispatchPointerEvent(
target: HTMLElement,
@@ -481,6 +484,53 @@ const puzzlePublicEntry = {
updatedAt: '2026-04-25T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
const jumpHopPublicEntry = {
sourceType: 'jump-hop',
workId: 'jump-hop-work-public-1',
profileId: 'jump-hop-profile-public-1',
sourceSessionId: 'jump-hop-session-public-1',
publicWorkCode: 'JH-EPUBLIC1',
ownerUserId: 'jump-hop-user-1',
authorDisplayName: '跳台作者',
worldName: '星桥跳台',
subtitle: '标准路线',
summaryText: '一条用于公开分享的跳一跳路线。',
coverImageSrc: null,
themeTags: ['跳一跳'],
playCount: 8,
remixCount: 1,
likeCount: 3,
recentPlayCount7d: 2,
visibility: 'published',
publishedAt: '2026-05-20T10:00:00.000Z',
updatedAt: '2026-05-20T10:00:00.000Z',
difficulty: 'standard',
stylePreset: 'storybook',
} satisfies PlatformJumpHopGalleryCard;
const woodenFishPublicEntry = {
sourceType: 'wooden-fish',
workId: 'wooden-fish-work-public-1',
profileId: 'wooden-fish-profile-public-1',
sourceSessionId: 'wooden-fish-session-public-1',
publicWorkCode: 'WF-EPUBLIC1',
ownerUserId: 'wooden-fish-user-1',
authorUsername: null,
authorDisplayName: '木鱼作者',
worldName: '莲台木鱼',
subtitle: '敲木鱼',
summaryText: '一件用于公开分享的敲木鱼作品。',
coverImageSrc: null,
themeTags: ['敲木鱼'],
playCount: 9,
remixCount: 2,
likeCount: 4,
recentPlayCount7d: 3,
visibility: 'published',
publishedAt: '2026-05-21T10:00:00.000Z',
updatedAt: '2026-05-21T10:00:00.000Z',
} satisfies PlatformWoodenFishGalleryCard;
const remixRankEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-remix-rank',
@@ -823,6 +873,7 @@ function renderLoggedOutHomeView(
| 'recommendRuntimeContent'
| 'activeRecommendEntryKey'
| 'isStartingRecommendEntry'
| 'isRecommendRuntimeReady'
| 'recommendRuntimeError'
| 'onSelectNextRecommendEntry'
| 'onSelectPreviousRecommendEntry'
@@ -883,6 +934,7 @@ function renderLoggedOutHomeView(
}
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
isRecommendRuntimeReady={overrides.isRecommendRuntimeReady}
recommendRuntimeError={overrides.recommendRuntimeError}
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={
@@ -1081,6 +1133,7 @@ afterEach(() => {
mockBuildReferralCenter(),
);
mockGetRpgProfileTasks.mockResolvedValue(mockBuildTaskCenter());
mockRefreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
mockClaimRpgProfileTaskReward.mockResolvedValue({
taskId: 'daily_login',
dayKey: 20260503,
@@ -2447,7 +2500,7 @@ test('profile daily task shortcut reflects task progress and claim updates', asy
await waitFor(() => {
expect(within(dailyTask).getByText('1 / 1')).toBeTruthy();
});
expect(within(dailyTask).getByText('领取')).toBeTruthy();
expect(dailyTask.querySelector('.platform-profile-daily-task-card__action')).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
@@ -2465,7 +2518,80 @@ test('profile daily task shortcut reflects task progress and claim updates', asy
expect(await screen.findByText('已领取 10 泥点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '已领取' })).toBeNull();
expect(screen.getByText('暂无任务')).toBeTruthy();
expect(within(dailyTask).getByText('已完成')).toBeTruthy();
expect(within(dailyTask).queryByText('已完成')).toBeNull();
});
test('profile daily task refreshes at Beijing midnight reset', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-03T15:59:58.000Z'));
mockGetRpgProfileTasks
.mockResolvedValueOnce(
mockBuildTaskCenter({
walletBalance: 10,
tasks: [
{
taskId: 'daily_login',
title: '每日登录',
description: '',
eventKey: 'profile.login.daily',
cycle: 'daily',
threshold: 1,
progressCount: 1,
rewardPoints: 10,
status: 'claimed',
dayKey: 20260503,
claimedAt: '2026-05-03T15:59:00Z',
updatedAt: '2026-05-03T15:59:00Z',
},
],
updatedAt: '2026-05-03T15:59:00Z',
}),
)
.mockResolvedValueOnce(
mockBuildTaskCenter({
walletBalance: 10,
tasks: [
{
taskId: 'daily_login',
title: '每日登录',
description: '',
eventKey: 'profile.login.daily',
cycle: 'daily',
threshold: 1,
progressCount: 1,
rewardPoints: 10,
status: 'claimable',
dayKey: 20260504,
claimedAt: null,
updatedAt: '2026-05-03T16:00:00Z',
},
],
updatedAt: '2026-05-03T16:00:00Z',
}),
);
renderProfileView();
await act(async () => {
await Promise.resolve();
});
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(2000);
await Promise.resolve();
await Promise.resolve();
});
expect(mockRefreshStoredAccessToken).toHaveBeenCalledWith({
clearOnFailure: false,
});
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('button', { name: '领取' })).toBeTruthy();
});
test('profile task center keeps only the highest priority actionable task', async () => {
@@ -2534,7 +2660,7 @@ test('profile total play time card always uses hours', async () => {
});
const playTimeCard = screen.getByRole('button', {
name: //u,
name: //u,
});
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
@@ -2548,10 +2674,11 @@ test('profile played works card shows count unit', async () => {
});
const playedCard = screen.getByRole('button', {
name: /\s*1/u,
name: /\s*1/u,
});
expect(within(playedCard).getByText('1个')).toBeTruthy();
expect(within(playedCard).queryByText('已玩游戏数量')).toBeNull();
await screen.findByText('1 / 1');
});
@@ -2563,8 +2690,8 @@ test('profile stats cards are centered without update timestamp', async () => {
const walletCard = screen.getByRole('button', {
name: /\s*0/u,
});
const playTimeCard = screen.getByRole('button', { name: /|/u });
const playedCard = screen.getByRole('button', { name: /\s*0/u });
const playTimeCard = screen.getByRole('button', { name: /\s*0/u });
const playedCard = screen.getByRole('button', { name: /\s*0/u });
for (const card of [walletCard, playTimeCard, playedCard]) {
expect(card.className).toContain('platform-profile-stat-card');
@@ -2616,8 +2743,8 @@ test('mobile profile page matches the reference layout sections', async () => {
expect(statPanel.className).toContain('platform-profile-stats-panel');
expect(statPanel.querySelector('.platform-profile-stats-grid')).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*70/u })).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*0/u })).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*0/u })).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*0/u })).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*0/u })).toBeTruthy();
expect(
within(statPanel).getByRole('button', { name: /\s*70/u }).className,
).toContain('platform-profile-stat-card');
@@ -2628,6 +2755,8 @@ test('mobile profile page matches the reference layout sections', async () => {
expect(dailyTask.querySelector('.platform-profile-daily-task-card__title')).toBeTruthy();
expect(dailyTask.querySelector('.platform-profile-daily-task-card__desc')).toBeTruthy();
expect(dailyTask.querySelector('.platform-profile-daily-task-card__progress')).toBeTruthy();
expect(dailyTask.querySelector('.platform-profile-daily-task-card__action')).toBeNull();
expect(dailyTask.textContent).not.toContain('去完成');
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
expect(await within(dailyTask).findByText('1 / 1')).toBeTruthy();
@@ -2668,13 +2797,22 @@ test('mobile profile page matches the reference layout sections', async () => {
within(shortcutRegion).getByRole('button', { name: new RegExp(label, 'u') }),
).toBeTruthy();
}
expect(
within(
within(shortcutRegion).getByRole('button', { name: //u }),
).getByText('帮我们优化产品'),
).toBeTruthy();
expect(
within(
within(shortcutRegion).getByRole('button', { name: //u }),
).queryByText('帮助我们做得更好'),
).toBeNull();
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
for (const label of ['主题设置', '账号与安全', '通用设置']) {
expect(
within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }),
).toBeTruthy();
}
expect(within(settingsRegion).getByRole('button', { name: //u })).toBeTruthy();
expect(within(settingsRegion).queryByRole('button', { name: //u })).toBeNull();
expect(within(settingsRegion).queryByRole('button', { name: //u })).toBeNull();
expect(settingsRegion.querySelectorAll('.platform-profile-settings-row')).toHaveLength(1);
expect(
within(settingsRegion).queryByRole('button', { name: //u }),
).toBeNull();
@@ -2805,7 +2943,8 @@ test('profile community shortcut shows reward subtitle and invited users', async
expect(screen.queryByRole('button', { name: //u })).toBeNull();
const communityButton = screen.getByRole('button', { name: //u });
expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy();
expect(within(communityButton).getByText('交流心得')).toBeTruthy();
expect(within(communityButton).queryByText('交流心得 领取福利')).toBeNull();
await user.click(communityButton);
@@ -2982,8 +3121,12 @@ test('profile page shows legal entries and hides archive shortcuts', async () =>
const dailyTask = screen.getByRole('button', { name: //u });
expect(dailyTask).toBeTruthy();
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
expect(dailyTask.querySelector('.platform-profile-daily-task-card__action')).toBeNull();
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
expect(within(settingsRegion).getByRole('button', { name: //u })).toBeTruthy();
expect(within(settingsRegion).queryByRole('button', { name: //u })).toBeNull();
expect(within(settingsRegion).queryByRole('button', { name: //u })).toBeNull();
expect(
within(settingsRegion).queryByRole('button', { name: //u }),
).toBeNull();
@@ -3590,6 +3733,53 @@ test('logged out recommend page can enter runtime without login gate', () => {
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
test('mobile recommend meta matches active jump hop runtime entry', () => {
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry, jumpHopPublicEntry],
activeRecommendEntryKey: 'jump-hop:jump-hop-user-1:jump-hop-profile-public-1',
recommendRuntimeContent: (
<div data-testid="recommend-runtime"></div>
),
});
expect(screen.getByTestId('recommend-runtime').textContent).toContain(
'跳一跳运行内容',
);
const meta = document.querySelector(
'.platform-recommend-work-meta[data-active="true"]',
) as HTMLElement | null;
expect(meta?.getAttribute('aria-label')).toBe('星桥跳台 作品信息');
if (!meta) {
throw new Error('缺少当前推荐作品信息');
}
expect(within(meta).getByText('跳台作者')).toBeTruthy();
expect(within(meta).getByText('星桥跳台')).toBeTruthy();
});
test('mobile recommend meta matches active wooden fish runtime entry', () => {
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry, woodenFishPublicEntry],
activeRecommendEntryKey:
'wooden-fish:wooden-fish-user-1:wooden-fish-profile-public-1',
recommendRuntimeContent: (
<div data-testid="recommend-runtime"></div>
),
});
expect(screen.getByTestId('recommend-runtime').textContent).toContain(
'敲木鱼运行内容',
);
const meta = document.querySelector(
'.platform-recommend-work-meta[data-active="true"]',
) as HTMLElement | null;
expect(meta?.getAttribute('aria-label')).toBe('莲台木鱼 作品信息');
if (!meta) {
throw new Error('缺少当前推荐作品信息');
}
expect(within(meta).getByText('木鱼作者')).toBeTruthy();
expect(within(meta).getByText('莲台木鱼')).toBeTruthy();
});
test('logged out desktop recommend rail enters runtime without login modal', async () => {
mockDesktopLayout();
const user = userEvent.setup();
@@ -3703,7 +3893,10 @@ test('logged out mobile recommend page renders runtime instead of cover', () =>
);
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
expect(
document.querySelector('.platform-recommend-runtime-cover'),
).toBeTruthy();
expect(screen.queryByText('加载中...')).toBeNull();
expect(
document.querySelector('.platform-public-work-card__cover'),
).toBeNull();
@@ -3712,7 +3905,7 @@ test('logged out mobile recommend page renders runtime instead of cover', () =>
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
test('mobile recommend loading state is themed instead of hardcoded black', () => {
test('mobile recommend startup keeps cover visible without loading copy', () => {
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
@@ -3720,8 +3913,123 @@ test('mobile recommend loading state is themed instead of hardcoded black', () =
recommendRuntimeContent: null,
});
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
expect(screen.getByText('加载中...')).toBeTruthy();
expect(
document.querySelector('.platform-recommend-runtime-cover'),
).toBeTruthy();
expect(screen.queryByText('加载中...')).toBeNull();
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
});
test('mobile recommend next level keeps runtime visual stable when active work changes', async () => {
const animationCallbacks: FrameRequestCallback[] = [];
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
writable: true,
value: vi.fn((callback: FrameRequestCallback) => {
animationCallbacks.push(callback);
return animationCallbacks.length;
}),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
writable: true,
value: vi.fn(),
});
const firstEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-feed-1',
profileId: 'puzzle-profile-feed-1',
ownerUserId: 'user-feed-1',
publicWorkCode: 'PZ-FEED1',
worldName: '当前拼图',
coverImageSrc: 'current-cover.png',
} satisfies PlatformPublicGalleryCard;
const similarEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-similar-1',
profileId: 'puzzle-profile-similar-1',
ownerUserId: 'user-feed-2',
publicWorkCode: 'PZ-SIMILAR1',
worldName: '相似拼图',
coverImageSrc: 'similar-cover.png',
} satisfies PlatformPublicGalleryCard;
const { rerender } = renderLoggedOutHomeView(vi.fn(), {
latestEntries: [firstEntry, similarEntry],
activeRecommendEntryKey: 'puzzle:user-feed-1:puzzle-profile-feed-1',
isRecommendRuntimeReady: true,
});
act(() => {
animationCallbacks.splice(0).forEach((callback) => callback(16));
});
await waitFor(() => {
expect(
document.querySelector('.platform-recommend-runtime-cover')?.className,
).toContain('platform-recommend-runtime-cover--hidden');
});
rerender(
<AuthUiContext.Provider
value={{
user: null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
requireAuth: vi.fn(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="home"
isDesktopLayout={false}
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[firstEntry, similarEntry]}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
recommendRuntimeContent={<div data-testid="recommend-runtime" />}
activeRecommendEntryKey="puzzle:user-feed-2:puzzle-profile-similar-1"
isRecommendRuntimeReady
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
/>
</AuthUiContext.Provider>,
);
const rail = document.querySelector(
'.platform-recommend-swipe-rail',
) as HTMLElement | null;
expect(rail?.className).toContain('platform-recommend-swipe-rail--settled');
expect(rail?.style.transform).toBe('translate3d(0, 0px, 0)');
expect(screen.getByLabelText('相似拼图 作品信息')).toBeTruthy();
expect(
document.querySelector('.platform-recommend-runtime-cover')?.className,
).toContain('platform-recommend-runtime-cover--hidden');
});
test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => {

View File

@@ -14,9 +14,9 @@ import {
Gamepad2,
GitFork,
Heart,
Loader2,
LogIn,
MessageCircle,
Loader2,
Palette,
Pencil,
Plus,
@@ -24,7 +24,6 @@ import {
Search,
Settings,
Share2,
ShieldCheck,
SlidersHorizontal,
Sparkles,
Star,
@@ -39,6 +38,7 @@ import {
type CSSProperties,
type PointerEvent,
type ReactNode,
Suspense,
useCallback,
useEffect,
useMemo,
@@ -80,6 +80,7 @@ import type {
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import { refreshStoredAccessToken } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService';
import {
getPublicAuthUserByCode,
@@ -136,6 +137,7 @@ import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntry
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
buildPlatformPublicGalleryCardKey,
buildPlatformWorldDisplayTags,
describePlatformThemeLabel,
formatPlatformWorkDisplayName,
@@ -153,8 +155,8 @@ import {
isWoodenFishGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformPublicWorkCode,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldFallbackCoverImage,
@@ -196,6 +198,7 @@ export interface RpgEntryHomeViewProps {
recommendRuntimeContent?: ReactNode;
activeRecommendEntryKey?: string | null;
isStartingRecommendEntry?: boolean;
isRecommendRuntimeReady?: boolean;
recommendRuntimeError?: string | null;
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
@@ -247,6 +250,9 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'saves',
'profile',
];
const PROFILE_TASK_DAY_MS = 24 * 60 * 60 * 1000;
const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000;
const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
const AVATAR_OUTPUT_SIZE = 256;
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
@@ -302,15 +308,8 @@ function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) {
const progressCount = Math.min(task?.progressCount ?? 0, threshold);
const rewardPoints =
task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS;
const actionLabel =
task?.status === 'claimable'
? '领取'
: task?.status === 'claimed'
? '已完成'
: '去完成';
return {
actionLabel,
progressCount,
progressPercent: Math.round((progressCount / threshold) * 100),
rewardPoints,
@@ -318,6 +317,15 @@ function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) {
};
}
function getDelayUntilNextProfileTaskReset(nowMs = Date.now()) {
const shiftedNow = nowMs + PROFILE_TASK_BEIJING_OFFSET_MS;
const nextDayStart =
Math.floor(shiftedNow / PROFILE_TASK_DAY_MS) * PROFILE_TASK_DAY_MS +
PROFILE_TASK_DAY_MS;
const nextResetAt = nextDayStart - PROFILE_TASK_BEIJING_OFFSET_MS;
return Math.max(PROFILE_TASK_MIN_RESET_DELAY_MS, nextResetAt - nowMs);
}
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
type BarcodeDetectorLike = {
@@ -947,6 +955,115 @@ function RecommendRuntimePreviewCard({
);
}
function RecommendRuntimeCover({
entry,
className = '',
}: {
entry: PlatformPublicGalleryCard;
className?: string;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
return (
<div
className={`platform-recommend-runtime-cover ${className}`}
aria-hidden="true"
>
{coverImage || fallbackCoverImage ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
fallbackSrc={fallbackCoverImage}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_22%_18%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.05),rgba(0,0,0,0.34))]" />
</div>
);
}
function RecommendRuntimeMountedProbe({
onMounted,
}: {
onMounted: () => void;
}) {
useEffect(() => {
const animationFrameId = window.requestAnimationFrame(onMounted);
return () => window.cancelAnimationFrame(animationFrameId);
}, [onMounted]);
return null;
}
function RecommendRuntimeVisual({
entry,
runtimeContent,
isStarting,
isRuntimeReady,
}: {
entry: PlatformPublicGalleryCard;
runtimeContent?: ReactNode;
isStarting: boolean;
isRuntimeReady: boolean;
}) {
const [isRuntimeMounted, setIsRuntimeMounted] = useState(false);
const activeEntryKey = buildPublicGalleryCardKey(entry);
const previousEntryKeyRef = useRef(activeEntryKey);
useEffect(() => {
if (previousEntryKeyRef.current === activeEntryKey) {
return;
}
previousEntryKeyRef.current = activeEntryKey;
setIsRuntimeMounted((currentValue) => {
// 中文注释:拼图推荐流“下一关”会在同一个 run 内切到相似作品;
// 此时只更新作品信息和分享基准,不应重显封面造成运行态闪跳。
if (currentValue && !isStarting && isRuntimeReady) {
return currentValue;
}
return false;
});
}, [activeEntryKey, isRuntimeReady, isStarting]);
const handleRuntimeMounted = useCallback(() => {
if (!isStarting && isRuntimeReady) {
setIsRuntimeMounted(true);
}
}, [isRuntimeReady, isStarting]);
const shouldShowCover =
!runtimeContent || isStarting || !isRuntimeReady || !isRuntimeMounted;
return (
<div className="platform-recommend-runtime-visual">
{runtimeContent ? (
<Suspense fallback={null}>
<div
className="platform-recommend-runtime-viewport"
aria-hidden={shouldShowCover}
>
{runtimeContent}
</div>
<RecommendRuntimeMountedProbe
key={activeEntryKey}
onMounted={handleRuntimeMounted}
/>
</Suspense>
) : null}
<RecommendRuntimeCover
entry={entry}
className={
shouldShowCover ? '' : 'platform-recommend-runtime-cover--hidden'
}
/>
</div>
);
}
function RecommendSwipeCard({
entry,
authorAvatarUrl,
@@ -1802,22 +1919,7 @@ function isExactPublicWorkCodeSearch(
}
function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: isMatch3DGalleryEntry(entry)
? 'match3d'
: isSquareHoleGalleryEntry(entry)
? 'square-hole'
: isVisualNovelGalleryEntry(entry)
? 'visual-novel'
: isBarkBattleGalleryEntry(entry)
? 'bark-battle'
: isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
return buildPlatformPublicGalleryCardKey(entry);
}
function PlatformWorkSearchResults({
@@ -2400,7 +2502,7 @@ function ProfileStatCard({
type="button"
onClick={onClick ? () => onClick(cardKey) : undefined}
aria-label={`${label} ${value}`}
className="platform-profile-stat-card flex min-h-[5.75rem] items-center justify-center gap-2 px-3 py-3 text-center transition"
className="platform-profile-stat-card flex min-h-[5.25rem] items-center justify-center gap-2 px-2.5 py-2.5 text-center transition"
>
<div className="platform-profile-stat-card__icon">
{imageSrc ? (
@@ -2410,10 +2512,10 @@ function ProfileStatCard({
)}
</div>
<div className="min-w-0 text-left">
<div className="platform-profile-stat-card__value whitespace-nowrap text-lg font-black leading-none text-[var(--platform-text-strong)]">
<div className="platform-profile-stat-card__value whitespace-nowrap text-[16px] font-black leading-none text-[var(--platform-text-strong)]">
{value}
</div>
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[12px] font-medium text-[var(--platform-text-soft)]">
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
{label}
</div>
</div>
@@ -2449,7 +2551,7 @@ function ProfileShortcutButton({
<button
type="button"
onClick={onClick ?? undefined}
className="platform-profile-shortcut-button flex min-h-[5.25rem] w-full flex-col items-center justify-center gap-2 px-2.5 py-3 text-center transition"
className="platform-profile-shortcut-button flex min-h-[4.75rem] w-full flex-col items-center justify-center gap-1.5 px-2 py-2.5 text-center transition"
>
<div className="platform-profile-shortcut-button__icon">
{imageSrc ? (
@@ -2458,11 +2560,11 @@ function ProfileShortcutButton({
<Icon className="h-[1.125rem] w-[1.125rem]" />
)}
</div>
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[13px] font-semibold text-[var(--platform-text-strong)]">
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[12px] font-semibold text-[var(--platform-text-strong)]">
{label}
</div>
{subLabel ? (
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[10px] font-medium text-[var(--platform-text-soft)]">
{subLabel}
</div>
) : null}
@@ -2485,13 +2587,13 @@ function ProfileSettingsRow({
<button
type="button"
onClick={onClick}
className="platform-profile-settings-row flex w-full items-center justify-between gap-3 px-4 py-4 text-left transition"
className="platform-profile-settings-row flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition"
>
<span className="flex min-w-0 items-center gap-3">
<span className="platform-profile-settings-row__icon">
<Icon className="h-5 w-5" />
<Icon className="h-4 w-4" />
</span>
<span className="truncate text-[15px] font-semibold text-[var(--platform-text-strong)]">
<span className="truncate text-[14px] font-semibold text-[var(--platform-text-strong)]">
{label}
</span>
</span>
@@ -4027,6 +4129,7 @@ export function RpgEntryHomeView({
recommendRuntimeContent,
activeRecommendEntryKey = null,
isStartingRecommendEntry = false,
isRecommendRuntimeReady = false,
recommendRuntimeError = null,
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
@@ -5022,6 +5125,40 @@ export function RpgEntryHomeView({
loadTaskCenter();
}, [activeTab, isAuthenticated, loadTaskCenter, profileTaskRefreshKey]);
useEffect(() => {
if (activeTab !== 'profile' || !isAuthenticated) {
return undefined;
}
let cancelled = false;
let timer: number | null = null;
const scheduleNextReset = () => {
if (cancelled) {
return;
}
timer = window.setTimeout(() => {
void refreshStoredAccessToken({ clearOnFailure: false })
.catch(() => undefined)
.finally(() => {
if (cancelled) {
return;
}
loadTaskCenter();
scheduleNextReset();
});
}, getDelayUntilNextProfileTaskReset());
};
scheduleNextReset();
return () => {
cancelled = true;
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [activeTab, isAuthenticated, loadTaskCenter]);
const openTaskCenterPanel = () => {
setIsTaskCenterOpen(true);
setTaskClaimSuccess(null);
@@ -5691,10 +5828,6 @@ export function RpgEntryHomeView({
{recommendRuntimeError}
</button>
</section>
) : isStartingRecommendEntry ? (
<section className="platform-recommend-runtime-panel">
<div className="platform-recommend-runtime-state">...</div>
</section>
) : activeRecommendEntry ? (
<div
ref={recommendCardStageRef}
@@ -5736,9 +5869,12 @@ export function RpgEntryHomeView({
)}
isActive
visual={
<div className="platform-recommend-runtime-viewport">
{recommendRuntimeContent}
</div>
<RecommendRuntimeVisual
entry={activeRecommendEntry}
runtimeContent={recommendRuntimeContent}
isStarting={isStartingRecommendEntry}
isRuntimeReady={isRecommendRuntimeReady}
/>
}
onDragPointerDown={beginRecommendDrag}
onDragPointerMove={moveRecommendDrag}
@@ -6324,7 +6460,7 @@ export function RpgEntryHomeView({
<div className="platform-profile-header__text min-w-0">
<div className="flex items-center gap-2">
<div className="platform-profile-header__name truncate text-[20px] font-black leading-tight text-[var(--platform-text-strong)]">
<div className="platform-profile-header__name truncate text-[18px] font-black leading-tight text-[var(--platform-text-strong)]">
{authUi.user.displayName}
</div>
<button
@@ -6333,10 +6469,10 @@ export function RpgEntryHomeView({
className="platform-profile-edit-button"
aria-label="修改昵称"
>
<Pencil className="h-5 w-5" />
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
<div className="platform-profile-header__code mt-3 flex flex-wrap items-center gap-2 text-[13px] text-[var(--platform-text-base)]">
<div className="platform-profile-header__code mt-2 flex flex-wrap items-center gap-2 text-[12px] text-[var(--platform-text-base)]">
<span> {publicUserCode}</span>
<button
type="button"
@@ -6365,10 +6501,10 @@ export function RpgEntryHomeView({
<Crown className="platform-profile-membership-card__crown" />
</span>
<span className="min-w-0 flex-1">
<span className="platform-profile-membership-card__title block text-[18px] font-black leading-tight text-white">
<span className="platform-profile-membership-card__title block text-[16px] font-black leading-tight text-white">
</span>
<span className="platform-profile-membership-card__subtitle mt-2 block text-[13px] font-medium text-white/92">
<span className="platform-profile-membership-card__subtitle mt-1.5 block text-[12px] font-medium text-white/92">
</span>
</span>
@@ -6397,7 +6533,7 @@ export function RpgEntryHomeView({
/>
<ProfileStatCard
cardKey="playTime"
label="累计游戏时长"
label="累计游"
value="暂不可用"
icon={Clock3}
imageSrc={profileClockImage}
@@ -6405,7 +6541,7 @@ export function RpgEntryHomeView({
/>
<ProfileStatCard
cardKey="playedWorks"
label="已玩游戏数量"
label="已玩游戏"
value="暂不可用"
icon={BookOpen}
imageSrc={profileGamepadImage}
@@ -6424,7 +6560,7 @@ export function RpgEntryHomeView({
/>
<ProfileStatCard
cardKey="playTime"
label="累计游戏时长"
label="累计游"
value={totalPlayTime}
icon={Clock3}
imageSrc={profileClockImage}
@@ -6432,7 +6568,7 @@ export function RpgEntryHomeView({
/>
<ProfileStatCard
cardKey="playedWorks"
label="已玩游戏数量"
label="已玩游戏"
value={`${formatDashboardCount(playedWorkCount)}`}
icon={BookOpen}
imageSrc={profileGamepadImage}
@@ -6452,15 +6588,15 @@ export function RpgEntryHomeView({
<span className="platform-profile-daily-task-card__title block text-[15px] font-black text-[var(--platform-text-strong)]">
</span>
<span className="platform-profile-daily-task-card__desc mt-4 block text-[13px] font-medium text-[var(--platform-text-base)]">
<span className="platform-profile-daily-task-card__desc mt-2 block text-[12px] font-medium text-[var(--platform-text-base)]">
{' '}
<span className="text-[#c45b2a]">
{profileTaskCardSummary.rewardPoints}
</span>{' '}
</span>
<span className="platform-profile-daily-task-card__progress mt-4 flex items-center gap-3">
<span className="platform-profile-daily-task-card__progress-value text-[14px] font-semibold text-[#dc3f0e]">
<span className="platform-profile-daily-task-card__progress mt-3 flex items-center gap-3">
<span className="platform-profile-daily-task-card__progress-value text-[13px] font-semibold text-[#dc3f0e]">
{profileTaskCardSummary.progressCount} /{' '}
{profileTaskCardSummary.threshold}
</span>
@@ -6479,9 +6615,6 @@ export function RpgEntryHomeView({
alt=""
className="platform-profile-daily-task-card__mascot"
/>
<span className="platform-profile-daily-task-card__action">
{profileTaskCardSummary.actionLabel}
</span>
</button>
<section
@@ -6505,14 +6638,14 @@ export function RpgEntryHomeView({
/>
<ProfileShortcutButton
label="玩家社区"
subLabel="交流心得 领取福利"
subLabel="交流心得"
icon={MessageCircle}
imageSrc={profileCommunityImage}
onClick={() => openProfilePopupPanel('community')}
/>
<ProfileShortcutButton
label="反馈与建议"
subLabel="帮我们做得更好"
subLabel="帮我们优化产品"
icon={MessageCircle}
imageSrc={profileFeedbackImage}
onClick={onOpenFeedback}
@@ -6521,16 +6654,6 @@ export function RpgEntryHomeView({
</section>
<section className="platform-profile-settings-panel" aria-label="设置入口">
<ProfileSettingsRow
label="主题设置"
icon={Palette}
onClick={() => authUi.openSettingsModal('appearance')}
/>
<ProfileSettingsRow
label="账号与安全"
icon={ShieldCheck}
onClick={() => authUi.openSettingsModal('account')}
/>
<ProfileSettingsRow
label="通用设置"
icon={Settings}

View File

@@ -268,7 +268,7 @@ test('resolves public work author from display name and public user code before
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('敲木鱼玩家');
});
test('public work author display hides phone masks and public user codes on cards', () => {
test('public work author display keeps phone masks and hides bare public user codes on cards', () => {
const card = mapWoodenFishWorkToPlatformGalleryCard({
publicWorkCode: 'WF-AUTHOR2',
workId: 'wooden-fish-work-author-mask',
@@ -294,8 +294,18 @@ test('public work author display hides phone masks and public user codes on card
displayName: '158****3533',
avatarUrl: null,
}),
).toBe('玩家');
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('玩家');
).toBe('158****3533');
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe(
'158****3533 · SY-00000003',
);
const publicCodeOnlyCard = {
...card,
authorDisplayName: 'SY-00000003',
};
expect(resolvePlatformWorkAuthorDisplayName(publicCodeOnlyCard, null)).toBe(
'玩家',
);
});
test('keeps baby object match public card code and template label intact', () => {

View File

@@ -397,6 +397,31 @@ export function isBarkBattleGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'bark-battle';
}
export function buildPlatformPublicGalleryCardKey(
entry: PlatformPublicGalleryCard,
) {
const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: isJumpHopGalleryEntry(entry)
? 'jump-hop'
: isWoodenFishGalleryEntry(entry)
? 'wooden-fish'
: isMatch3DGalleryEntry(entry)
? 'match3d'
: isSquareHoleGalleryEntry(entry)
? 'square-hole'
: isVisualNovelGalleryEntry(entry)
? 'visual-novel'
: isBarkBattleGalleryEntry(entry)
? 'bark-battle'
: isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard {
@@ -987,9 +1012,6 @@ function normalizePlatformPublicAuthorName(value: string | null | undefined) {
}
const compact = normalized.replace(/\s+/gu, '');
if (/^\d+\*+\d+(?:[·.-]?SY-\d+)?$/iu.test(compact)) {
return '';
}
if (/^SY-\d+$/iu.test(compact)) {
return '';
}