合并 master 并修复架构分支回归

合入 master 最新的认证、玩法契约与推荐页改动。

修复拼图草稿生成、推荐页下一关和公开详情访客试玩回归。

修复抓大鹅草稿试玩鉴权与首屏推荐详情测试入口。

补齐相关测试夹具、文档与团队记忆更新。
This commit is contained in:
2026-06-07 21:35:47 +08:00
80 changed files with 2627 additions and 511 deletions

View File

@@ -13,16 +13,16 @@ import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
JumpHopRuntimeRunSnapshotResponse,
JumpHopWorkDetailResponse,
JumpHopWorkProfileResponse,
JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
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';
@@ -42,7 +42,10 @@ import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import type {
WoodenFishGalleryCardResponse,
WoodenFishWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
@@ -71,7 +74,6 @@ 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,
@@ -91,6 +93,7 @@ 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 {
@@ -155,6 +158,7 @@ import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetail as getRpgEntryWorldGalleryDetailFromClient,
getRpgEntryWorldGalleryDetailByCode,
likeRpgEntryWorldGallery,
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
@@ -334,10 +338,6 @@ const ISOLATED_RUNTIME_AUTH_OPTIONS = {
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
};
const RECOMMEND_RUNTIME_AUTH_OPTIONS = {
...ISOLATED_RUNTIME_AUTH_OPTIONS,
runtimeGuestToken: 'runtime-guest-token',
};
const LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS = ISOLATED_RUNTIME_AUTH_OPTIONS;
function getPlatformTabPanel(tab: string) {
@@ -542,6 +542,7 @@ const rpgEntryLibraryServiceMocks = vi.hoisted(() => ({
getRpgEntryWorldGalleryDetail: vi.fn(),
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
getRpgEntryWorldLibraryDetail: vi.fn(),
likeRpgEntryWorldGallery: vi.fn(),
listRpgEntryWorldGallery: vi.fn(),
listRpgEntryWorldLibrary: vi.fn(),
publishRpgEntryWorldProfile: vi.fn(),
@@ -2005,6 +2006,18 @@ function buildReadyPuzzleDraft(
'/generated-puzzle-assets/puzzle-session-recovered/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/puzzle-session-recovered/ui/background.png',
levelSceneImageSrc:
'/generated-puzzle-assets/puzzle-session-recovered/level-scene.png',
levelSceneImageObjectKey:
'generated-puzzle-assets/puzzle-session-recovered/level-scene.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/puzzle-session-recovered/ui-spritesheet.png',
uiSpritesheetImageObjectKey:
'generated-puzzle-assets/puzzle-session-recovered/ui-spritesheet.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/puzzle-session-recovered/level-background.png',
levelBackgroundImageObjectKey:
'generated-puzzle-assets/puzzle-session-recovered/level-background.png',
generationStatus: 'ready',
},
],
@@ -5216,6 +5229,101 @@ test('running puzzle draft opens generation progress from draft tab', async () =
});
});
test('puzzle text-only form stays generating when compile starts background image without cover', async () => {
const user = userEvent.setup();
const initialSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-text-only',
stage: 'collecting_anchors',
progressPercent: 0,
draft: null,
});
const generatingDraft = buildReadyPuzzleDraft({
workTitle: '文字直创拼图',
workDescription: '只输入文字后后台继续生成图片。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating',
levels: [
{
...buildReadyPuzzleDraft().levels![0]!,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
levelSceneImageSrc: null,
levelSceneImageObjectKey: null,
uiSpritesheetImageSrc: null,
uiSpritesheetImageObjectKey: null,
levelBackgroundImageSrc: null,
levelBackgroundImageObjectKey: null,
generationStatus: 'generating',
},
],
});
const generatingSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-text-only',
stage: 'image_refining',
progressPercent: 88,
draft: generatingDraft,
lastAssistantReply: '已编译首关草稿,并启动首关画面和 UI 资产后台生成。',
resultPreview: {
draft: generatingDraft,
blockers: [
{
id: 'missing-cover-image-puzzle-level-1',
code: 'MISSING_COVER_IMAGE',
message: '正式拼图图片尚未确定',
},
],
qualityFindings: [],
publishReady: false,
},
});
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
session: initialSession,
});
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'compile-puzzle-text-only',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '首关拼图草稿',
phaseDetail: '已编译首关草稿,并启动首关画面和 UI 资产后台生成。',
progress: 0.88,
},
session: generatingSession,
});
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: generatingSession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(
await screen.findByRole('progressbar', {
name: '拼图图片生成进度',
}),
).toBeTruthy();
await waitFor(() => {
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
'puzzle-session-text-only',
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
});
expect(screen.queryByRole('dialog', { name: '生成完成' })).toBeNull();
expect(screen.queryByText('请先选择一张正式拼图图片。')).toBeNull();
expect(screen.queryByText('拼图结果页')).toBeNull();
expect(updatePuzzleWork).not.toHaveBeenCalled();
expect(startLocalPuzzleRun).not.toHaveBeenCalled();
});
test('puzzle form checks mud points before creating a draft', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
@@ -6523,6 +6631,7 @@ test('clicking a public work while logged out opens public detail without starti
/>,
);
await openDiscoverHub(user);
const workCards = await screen.findAllByRole('button', {
name: /潮雾列岛/u,
});
@@ -6576,20 +6685,29 @@ test('logged out public detail gates puzzle start and remix before real actions'
/>,
);
await waitFor(() => {
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
});
await openDiscoverHub(user);
const workCards = screen.getAllByRole('button', { name: /星桥机关/u });
await user.click(workCards[0]!);
expect(await screen.findByText('详情')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: 'puzzle-profile-public-1',
levelId: null,
},
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
}),
);
});
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(startPuzzleRun).not.toHaveBeenCalled();
requireAuth.mockClear();
await user.click(screen.getByRole('button', { name: '作品改造' }));
expect(requireAuth).toHaveBeenCalledTimes(2);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
});
@@ -7369,6 +7487,75 @@ test('home recommendation share opens publish share modal', async () => {
.toBeTruthy();
});
test('home recommendation wooden fish like does not call RPG gallery like', async () => {
const user = userEvent.setup();
const publishedWoodenFishWork: WoodenFishGalleryCardResponse = {
publicWorkCode: 'WF-3A9EC89B',
workId: 'wooden-fish-work-like-1',
profileId: 'wooden-fish-profile-like-1',
ownerUserId: 'wooden-fish-user-1',
authorDisplayName: '木鱼作者',
workTitle: '莲台木鱼',
workDescription: '推荐页里的敲木鱼作品。',
coverImageSrc: null,
themeTags: ['敲木鱼'],
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
generationStatus: 'ready',
};
vi.mocked(woodenFishClient.listGallery).mockResolvedValue({
items: [publishedWoodenFishWork],
hasMore: false,
nextCursor: null,
});
vi.mocked(woodenFishClient.startRun).mockResolvedValue({
run: {
runId: 'wooden-fish-run-like-1',
profileId: publishedWoodenFishWork.profileId,
ownerUserId: publishedWoodenFishWork.ownerUserId,
status: 'playing',
totalTapCount: 0,
wordCounters: [],
startedAtMs: 1,
updatedAtMs: 1,
finishedAtMs: null,
},
});
vi.mocked(likeRpgEntryWorldGallery).mockResolvedValue(
buildMockRpgGalleryDetail({
ownerUserId: 'custom-world-user-1',
profileId: 'custom-world-profile-1',
publicWorkCode: 'CW-00000001',
authorPublicUserCode: 'SY-00000001',
visibility: 'published',
publishedAt: '2026-04-25T09:00:00.000Z',
updatedAt: '2026-04-25T09:00:00.000Z',
authorDisplayName: 'RPG 作者',
worldName: '不应被点赞的 RPG',
subtitle: '错误分流',
summaryText: 'WF 点赞不应进入这里。',
coverImageSrc: null,
themeMode: 'mythic',
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 1,
}),
);
render(<TestWrapper withAuth />);
const meta = await screen.findByLabelText('莲台木鱼 作品信息');
await user.click(within(meta).getByRole('button', { name: '点赞 0' }));
expect(likeRpgEntryWorldGallery).not.toHaveBeenCalled();
expect(
await screen.findByText('作品类型 wooden-fish 暂不支持点赞。'),
).toBeTruthy();
});
test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => {
const publishedPuzzleWork = {
workId: 'puzzle-work-public-2',
@@ -7471,12 +7658,6 @@ test('logged out home recommendation next starts the next puzzle work', async ()
/>,
);
const recommendNavButton = document.querySelector<HTMLButtonElement>(
'.platform-bottom-nav [aria-label="推荐"]',
);
expect(recommendNavButton).toBeTruthy();
await user.click(recommendNavButton!);
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
@@ -7505,7 +7686,114 @@ test('logged out home recommendation next starts the next puzzle work', async ()
});
});
test('home recommendation puzzle next level switches to similar work detail', async () => {
test('home recommendation keeps cover while switching during a pending puzzle start', async () => {
const user = userEvent.setup();
const firstWork = {
workId: 'puzzle-work-pending-next-1',
profileId: 'puzzle-profile-pending-next-1',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-pending-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-pending-next-2',
profileId: 'puzzle-profile-pending-next-2',
ownerUserId: 'user-3',
sourceSessionId: 'puzzle-session-pending-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;
let resolveFirstRun!: (value: { run: PuzzleRunSnapshot }) => void;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [firstWork, secondWork],
});
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
item: profileId === secondWork.profileId ? secondWork : firstWork,
}));
vi.mocked(startPuzzleRun).mockImplementationOnce(
(async () =>
new Promise((resolve) => {
resolveFirstRun = resolve;
})) as typeof startPuzzleRun,
);
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: (action) => action(),
})}
/>,
);
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: firstWork.profileId,
levelId: null,
},
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
}),
);
});
await user.click(await screen.findByRole('button', { name: '下一个' }));
expect(
screen.queryByText('作品暂时无法进入,请稍后再试。'),
).toBeNull();
expect(
await screen.findByLabelText('贝壳潮汐 作品信息', undefined, {
timeout: 3000,
}),
).toBeTruthy();
expect(startPuzzleRun).toHaveBeenCalledTimes(1);
await act(async () => {
resolveFirstRun({
run: buildMockPuzzleRun(firstWork.profileId, '后端拼图关卡'),
});
});
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: secondWork.profileId,
levelId: null,
},
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
}),
);
});
expect(
screen.queryByText('作品暂时无法进入,请稍后再试。'),
).toBeNull();
});
test('home recommendation puzzle next level uses unified recommend switching', async () => {
const user = userEvent.setup();
const entryWork = {
workId: 'puzzle-work-public-guest-1',
@@ -7547,17 +7835,17 @@ test('home recommendation puzzle next level switches to similar work detail', as
},
],
} satisfies PuzzleWorkSummary;
const similarWork = {
const nextRecommendWork = {
...entryWork,
workId: 'puzzle-work-similar-guest-1',
profileId: 'puzzle-profile-similar-guest-1',
workId: 'puzzle-work-public-guest-2',
profileId: 'puzzle-profile-public-guest-2',
levelName: '风塔试炼',
summary: '另一套奇幻机关拼图。',
summary: '另一套推荐拼图。',
levels: [
{
levelId: 'similar-level-1',
levelId: 'next-recommend-level-1',
levelName: '风塔试炼',
pictureDescription: '相似作品首关。',
pictureDescription: '推荐队列下一张拼图。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
@@ -7586,47 +7874,35 @@ test('home recommendation puzzle next level switches to similar work detail', as
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(),
},
};
const nextRecommendRun = buildMockPuzzleRun(
nextRecommendWork.profileId,
nextRecommendWork.levelName,
);
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [entryWork],
items: [entryWork, nextRecommendWork],
});
vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({
item: profileId === similarWork.profileId ? similarWork : entryWork,
item: profileId === nextRecommendWork.profileId ? nextRecommendWork : entryWork,
}));
vi.mocked(startPuzzleRun).mockResolvedValue({
run: {
...startedRun,
currentLevel: {
...startedRun.currentLevel!,
startedAtMs: Date.now(),
vi.mocked(startPuzzleRun).mockImplementation(async (payload) => {
const run =
payload.profileId === nextRecommendWork.profileId
? nextRecommendRun
: startedRun;
return {
run: {
...run,
currentLevel: {
...run.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 />);
@@ -7655,24 +7931,23 @@ test('home recommendation puzzle next level switches to similar work detail', as
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
await waitFor(() => {
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(clearedRun.runId, {
preferSimilarWork: true,
});
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: nextRecommendWork.profileId,
levelId: null,
},
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
expect(advancePuzzleNextLevel).not.toHaveBeenCalled();
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);
expect(startPuzzleRun).toHaveBeenCalledTimes(2);
});
test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => {
@@ -8331,10 +8606,84 @@ test('direct jump hop result route restores work detail by profile id', async ()
expect(screen.queryByText('跳一跳草稿未恢复')).toBeNull();
expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith(
'jump-hop-profile-restore-1',
{ audience: 'creation' },
);
expect(jumpHopClient.getSession).not.toHaveBeenCalled();
});
test('completed unpublished jump hop draft opens result page without starting runtime', async () => {
const user = userEvent.setup();
const work = buildMockJumpHopWork({
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-draft-ready-1',
profileId: 'jump-hop-profile-draft-ready-1',
ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-draft-ready-1',
themeText: '未发布跳一跳草稿',
workTitle: '未发布跳一跳草稿',
workDescription: '已经生成完成,但还没有发布。',
themeTags: ['草稿'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T10:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
});
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({
items: [work.summary],
});
vi.mocked(jumpHopClient.getWorkDetail).mockResolvedValueOnce({
item: work,
} satisfies JumpHopWorkDetailResponse);
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: 55,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
],
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
const draftPanel = getPlatformTabPanel('saves');
await user.click(
await within(draftPanel).findByRole('button', {
name: /继续创作《未发布跳一跳草稿》/u,
}),
);
expect(await screen.findByText('未发布跳一跳草稿')).toBeTruthy();
expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith(
'jump-hop-profile-draft-ready-1',
{ audience: 'creation' },
);
expect(jumpHopClient.startRun).not.toHaveBeenCalled();
expect(window.location.pathname).toBe('/creation/jump-hop/result');
expect(window.location.search).toContain(
'profileId=jump-hop-profile-draft-ready-1',
);
});
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
const user = userEvent.setup();