Merge branch 'master' into codex/frontend-error-dialogs

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	server-rs/crates/api-server/src/generated_asset_sheets.rs
This commit is contained in:
kdletters
2026-05-26 22:10:41 +08:00
175 changed files with 4760 additions and 465 deletions

View File

@@ -83,7 +83,10 @@ import {
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { match3dCreationClient } from '../../services/match3d-creation';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import {
createLocalMatch3DRuntimeAdapter,
createServerMatch3DRuntimeAdapter,
} from '../../services/match3d-runtime';
import {
deleteMatch3DWork,
getMatch3DWorkDetail,
@@ -257,6 +260,13 @@ function queryCreationTypeButton(name: string | RegExp) {
});
}
async function openPuzzleFormFromCreateHub(
user: ReturnType<typeof userEvent.setup>,
) {
await user.click(await findCreationTypeButton('拼图'));
await screen.findByText(//u);
}
async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '草稿');
const panel = getPlatformTabPanel('saves');
@@ -291,7 +301,9 @@ async function openProfilePlayedWorks(
user: ReturnType<typeof userEvent.setup>,
) {
await clickFirstButtonByName(user, '我的');
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(
await screen.findByRole('button', { name: //u }),
);
expect(await screen.findByText('可继续')).toBeTruthy();
}
@@ -324,6 +336,13 @@ function getPlatformTabPanel(tab: string) {
return panel;
}
async function findPlatformTabPanel(tab: string) {
await waitFor(() => {
expect(document.getElementById(`platform-tab-panel-${tab}`)).toBeTruthy();
});
return getPlatformTabPanel(tab);
}
const testCreationEntryConfig = {
startCard: {
title: '新建作品',
@@ -655,6 +674,7 @@ vi.mock('../../services/match3dGeneratedModelCache', () => ({
}));
const match3dRuntimeServiceMocks = vi.hoisted(() => ({
createLocalMatch3DRuntimeAdapter: vi.fn(),
createServerMatch3DRuntimeAdapter: vi.fn(),
}));
@@ -667,6 +687,15 @@ const match3dServerRuntimeAdapterMock = vi.hoisted(() => ({
stopRun: vi.fn(),
}));
const match3dLocalRuntimeAdapterMock = vi.hoisted(() => ({
clickItem: vi.fn(),
finishTimeUp: vi.fn(),
getRun: vi.fn(),
restartRun: vi.fn(),
startRun: vi.fn(),
stopRun: vi.fn(),
}));
vi.mock('../../services/match3d-runtime', async () => {
const actual = await vi.importActual<
typeof import('../../services/match3d-runtime')
@@ -2376,6 +2405,9 @@ beforeEach(() => {
vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue(
match3dServerRuntimeAdapterMock,
);
vi.mocked(createLocalMatch3DRuntimeAdapter).mockReturnValue(
match3dLocalRuntimeAdapterMock,
);
match3dServerRuntimeAdapterMock.startRun.mockRejectedValue(
new Error('未启动抓大鹅运行态'),
);
@@ -2391,6 +2423,21 @@ beforeEach(() => {
match3dServerRuntimeAdapterMock.stopRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-stopped'),
});
match3dLocalRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-demo-20260525'),
});
match3dLocalRuntimeAdapterMock.clickItem.mockRejectedValue(
new Error('未执行本地抓大鹅点击'),
);
match3dLocalRuntimeAdapterMock.restartRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-demo-20260525'),
});
match3dLocalRuntimeAdapterMock.finishTimeUp.mockResolvedValue({
run: buildMockMatch3DRun('match3d-demo-20260525'),
});
match3dLocalRuntimeAdapterMock.stopRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-demo-20260525'),
});
window.history.replaceState(null, '', '/');
window.sessionStorage.clear();
window.localStorage.clear();
@@ -3469,7 +3516,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(screen.getByRole('tablist', { name: '玩法模板分类' })).toBeTruthy();
expect(
screen.getByRole('tablist', { name: '玩法模板分类' }).className,
).toContain('scroll-px-3');
).toContain('scroll-px-2');
expect(
screen.getByRole('tab', { name: '最近创作' }).getAttribute('aria-selected'),
).toBe('true');
@@ -3511,7 +3558,7 @@ test('create tab opens puzzle entry form from the template card', async () => {
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await openPuzzleFormFromCreateHub(user);
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
@@ -3663,7 +3710,11 @@ test('bark battle draft is visible in draft shelf while image assets are generat
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('自动生成素材')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '汪汪声浪素材生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回编辑' }));
await openDraftHub(user);
@@ -3728,7 +3779,7 @@ test('published bark battle stays visible when refresh temporarily returns only
await openDraftHub(user);
const panel = getPlatformTabPanel('saves');
await user.click(within(panel).getByRole('button', { name: /已发布/u }));
await user.click(within(panel).getByRole('tab', { name: /已发布/u }));
expect(await within(panel).findByText('汪汪测试杯')).toBeTruthy();
expect(
@@ -3762,7 +3813,11 @@ test('running match3d form generation can return to draft tab and reopen progres
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
@@ -3772,7 +3827,11 @@ test('running match3d form generation can return to draft tab and reopen progres
await user.click(
screen.getByRole('button', { name: /继续创作《抓大鹅草稿》/u }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await act(async () => {
resolveCompile({ session: buildMockMatch3DAgentSession() });
@@ -3845,7 +3904,11 @@ test('running match3d persisted draft reopens progress instead of unfinished res
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
expect(await screen.findAllByText('素材生成仍在后台处理')).not.toHaveLength(
0,
);
@@ -3859,7 +3922,11 @@ test('running match3d persisted draft reopens progress instead of unfinished res
await screen.findByRole('button', { name: /继续创作《赛博水果摊》/u }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
expect(match3dCreationClient.getSession).toHaveBeenCalledWith(
'match3d-running-persisted-session',
@@ -4042,13 +4109,18 @@ test('running match3d form generation keeps other creation templates available',
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
await openCreateTemplateHub(user);
const puzzleCard = await findCreationTypeButton('拼图');
expect((puzzleCard as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleTab);
await user.click(puzzleCard);
const generatePuzzleButton = await screen.findByRole('button', {
name: '生成草稿',
});
@@ -4111,12 +4183,17 @@ test('running match3d form generation keeps same template generation available',
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const match3dTab = await screen.findByRole('tab', { name: '抓大鹅' });
expect((match3dTab as HTMLButtonElement).disabled).toBe(false);
await user.click(match3dTab);
await openCreateTemplateHub(user);
const match3dCard = await findCreationTypeButton('抓大鹅');
expect((match3dCard as HTMLButtonElement).disabled).toBe(false);
await user.click(match3dCard);
const secondGenerateButton = await screen.findByRole('button', {
name: '生成抓大鹅草稿',
@@ -4143,7 +4220,11 @@ test('running match3d form generation keeps same template generation available',
expect.objectContaining({ action: 'match3d_compile_draft' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
@@ -4213,15 +4294,23 @@ test('running puzzle form generation creates a new puzzle draft on same template
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
await user.click(await findCreationTypeButton('拼图'));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
expect(
await screen.findByRole('progressbar', {
name: '拼图草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleTab);
await openCreateTemplateHub(user);
const puzzleCard = await findCreationTypeButton('拼图');
expect((puzzleCard as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleCard);
expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy();
expect(await screen.findByText(/拼图工作区:/u)).toBeTruthy();
expect(screen.getByTestId('puzzle-workspace-busy-state')).toHaveProperty(
'textContent',
'idle',
@@ -4232,9 +4321,7 @@ test('running puzzle form generation creates a new puzzle draft on same template
expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false);
await user.click(secondGenerateButton);
await waitFor(() => {
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2);
});
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
1,
@@ -4243,11 +4330,15 @@ test('running puzzle form generation creates a new puzzle draft on same template
);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
2,
'puzzle-parallel-session-2',
'puzzle-session-1',
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '拼图草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
@@ -4319,8 +4410,15 @@ test('running puzzle draft opens generation progress from draft tab', async () =
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
await user.click(await findCreationTypeButton('拼图'));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
expect(
await screen.findByRole('progressbar', {
name: '拼图草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
@@ -4330,7 +4428,11 @@ test('running puzzle draft opens generation progress from draft tab', async () =
screen.getByRole('button', { name: /继续创作《拼图草稿》/u }),
);
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '拼图草稿生成进度',
}),
).toBeTruthy();
expect(screen.queryByText('拼图结果页')).toBeNull();
await act(async () => {
@@ -4363,7 +4465,9 @@ test('puzzle form checks mud points before creating a draft', async () => {
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
@@ -4591,7 +4695,7 @@ test('match3d result trial passes generated 2D image views into first runtime mo
});
});
test('match3d result back returns to platform creation page', async () => {
test('match3d result back returns to draft hub when opened from shelf', async () => {
const user = userEvent.setup();
const match3dDraftWork: Match3DWorkSummary = {
workId: 'match3d-work-back-1',
@@ -4645,9 +4749,15 @@ test('match3d result back returns to platform creation page', async () => {
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
const draftPanel = await findPlatformTabPanel('saves');
await waitFor(() => {
expect(draftPanel.getAttribute('aria-hidden')).toBe('false');
});
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
within(draftPanel).getByRole('tablist', { name: '作品筛选' }),
).toBeTruthy();
expect(within(draftPanel).getByText('自动试玩抓大鹅')).toBeTruthy();
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true');
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
});
@@ -4994,7 +5104,11 @@ test('completed match3d draft notice first opens trial then reopens result', asy
);
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
expect(screen.queryByText('抓大鹅草稿生成进度')).toBeNull();
expect(
screen.queryByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeNull();
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(
@@ -5036,14 +5150,7 @@ test('completed baby object match draft viewed immediately does not keep unread
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '宝贝识物' }));
await waitFor(() => {
expect(
screen
.getByRole('tab', { name: '宝贝识物' })
.getAttribute('aria-selected'),
).toBe('true');
});
await user.click(await findCreationTypeButton('宝贝识物'));
await user.type(await screen.findByLabelText('物品 A'), '苹果');
await user.type(await screen.findByLabelText('物品 B'), '香蕉');
await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' }));
@@ -5075,8 +5182,10 @@ test('completed baby object match draft viewed immediately does not keep unread
await waitFor(() => {
expect(screen.queryByText('宝贝识物结果页')).toBeNull();
});
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
const reopenedDraftPanel = await findPlatformTabPanel('saves');
await waitFor(() => {
expect(reopenedDraftPanel.getAttribute('aria-hidden')).toBe('false');
});
expect(screen.queryByLabelText('新生成完成')).toBeNull();
});
@@ -5094,12 +5203,16 @@ test('completed baby object match draft shows unread marker after leaving genera
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '宝贝识物' }));
await user.click(await findCreationTypeButton('宝贝识物'));
await user.type(await screen.findByLabelText('物品 A'), '苹果');
await user.type(await screen.findByLabelText('物品 B'), '香蕉');
await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' }));
expect(await screen.findByText('宝贝识物草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '宝贝识物草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
@@ -5123,8 +5236,10 @@ test('completed baby object match draft shows unread marker after leaving genera
await waitFor(() => {
expect(screen.queryByText('宝贝识物结果页')).toBeNull();
});
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
const draftPanel = await findPlatformTabPanel('saves');
await waitFor(() => {
expect(draftPanel.getAttribute('aria-hidden')).toBe('false');
});
expect(screen.queryByLabelText('新生成完成')).toBeNull();
});
@@ -5193,7 +5308,9 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
await waitFor(() => {
expect(updatePuzzleWork).toHaveBeenCalledWith(
@@ -5279,7 +5396,10 @@ test('embedded puzzle form recovers when compile request times out after backend
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('button', { name: '生成草稿' }));
await user.click(await findCreationTypeButton('拼图'));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
await waitFor(() => {
expect(getPuzzleAgentSession).toHaveBeenCalledWith(
@@ -5316,12 +5436,10 @@ test('embedded puzzle form routes through requireAuth while logged out', async (
);
await openCreateTemplateHub(user);
const generateButton = await screen.findByRole('button', {
name: /生成草稿/u,
});
await user.click(await findCreationTypeButton('拼图'));
await user.click(generateButton);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(screen.queryByText('拼图工作区missing-session')).toBeNull();
expect(createCreativeAgentSession).not.toHaveBeenCalled();
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
expect(createRpgCreationSession).not.toHaveBeenCalled();
@@ -6265,6 +6383,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',
@@ -6886,7 +7258,9 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
const generateButton = screen.getByRole('button', { name: /生成草稿/u });
await user.click(await findCreationTypeButton('拼图'));
await screen.findByText(/拼图工作区:/u);
const generateButton = screen.getByRole('button', { name: '生成草稿' });
expect((generateButton as HTMLButtonElement).disabled).toBe(false);
await user.click(generateButton);
@@ -6923,8 +7297,10 @@ test('embedded puzzle form timeout exits busy state and shows a readable error',
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await screen.findByText(/拼图工作区:/u);
const button = screen.getByRole('button', { name: /生成草稿/u });
const button = screen.getByRole('button', { name: '生成草稿' });
await user.click(button);
await waitFor(() => {
@@ -6954,11 +7330,11 @@ test('match3d creation tab stays usable even when public galleries fail', async
await openCreateTemplateHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
expect(screen.getByRole('tab', { name: '抓大鹅' })).toBeTruthy();
expect(await findCreationTypeButton('抓大鹅')).toBeTruthy();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('puzzle draft result back button returns to creation hub', async () => {
test('puzzle draft result back button returns to draft hub when opened from shelf', async () => {
const user = userEvent.setup();
vi.mocked(listPuzzleWorks).mockResolvedValue({
@@ -6999,10 +7375,16 @@ test('puzzle draft result back button returns to creation hub', async () => {
await user.click(screen.getByRole('button', { name: '返回' }));
const draftPanel = await findPlatformTabPanel('saves');
await waitFor(() => {
expect(draftPanel.getAttribute('aria-hidden')).toBe('false');
});
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
within(draftPanel).getByRole('tablist', { name: '作品筛选' }),
).toBeTruthy();
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(within(draftPanel).getByText('雨夜猫塔')).toBeTruthy();
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true');
expect(screen.queryByText('拼图工作区missing-session')).toBeNull();
expect(
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
).toBeNull();
@@ -7383,6 +7765,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(
@@ -7771,6 +8154,9 @@ test('recommend puzzle remix return restarts recommendation instead of stale loa
profileId: 'puzzle-profile-public-1',
levelId: null,
},
expect.objectContaining({
authImpact: 'local',
}),
);
});
expect(screen.queryByText('正在进入拼图关卡')).toBeNull();
@@ -7857,6 +8243,7 @@ test('missing puzzle public detail returns to platform home', async () => {
);
render(<TestWrapper />);
vi.mocked(startPuzzleRun).mockClear();
await openDiscoverHub(user);
const workCards = await screen.findAllByRole('button', { name: /失效拼图/u });
@@ -7868,7 +8255,6 @@ test('missing puzzle public detail returns to platform home', async () => {
expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('false');
expect(screen.queryByText('详情')).toBeNull();
expect(screen.queryByText('资源不存在')).toBeNull();
expect(startPuzzleRun).toHaveBeenCalledTimes(0);
});
test('direct missing public work detail alert returns to platform home', async () => {
@@ -8004,6 +8390,38 @@ test('public code search opens a published Match3D work by M3 code and starts ru
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('public code search opens the local Match3D demo and starts local runtime', async () => {
const user = userEvent.setup();
vi.mocked(listMatch3DGallery).mockResolvedValue({ items: [] });
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput =
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
await user.type(searchInput, 'M3-20260525');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(await screen.findByText('详情')).toBeTruthy();
expect(screen.getByText('海底糖果集市')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
await waitFor(() => {
expect(match3dLocalRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-demo-20260525',
{},
);
});
expect(match3dServerRuntimeAdapterMock.startRun).not.toHaveBeenCalled();
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-demo-20260525',
);
expect(
await screen.findByText('抓大鹅运行态match3d-run-match3d-demo-20260525'),
).toBeTruthy();
});
test('published Match3D runtime receives persisted generated models', async () => {
const user = userEvent.setup();
const match3dWork: Match3DWorkSummary = {
@@ -8127,9 +8545,12 @@ test('starting draft generation leaves the agent workspace and shows the generat
);
});
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '世界草稿生成进度',
}),
).toBeTruthy();
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
expect(screen.getByText('当前世界信息')).toBeTruthy();
expect(screen.queryByText('回到工作区')).toBeNull();
expect(screen.getByText('世界承诺')).toBeTruthy();
@@ -8162,7 +8583,11 @@ test('running custom world draft generation can return to creation center with s
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '开始生成草稿' }));
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '世界草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
expect(
@@ -8192,9 +8617,12 @@ test('refresh restores running draft generation progress instead of agent worksp
render(<TestWrapper withAuth />);
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '世界草稿生成进度',
}),
).toBeTruthy();
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
});
test('failed draft work continues on generation progress view instead of agent workspace', async () => {
@@ -8243,7 +8671,11 @@ test('failed draft work continues on generation progress view instead of agent w
expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '世界草稿生成进度',
}),
).toBeTruthy();
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
});
@@ -9058,7 +9490,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
});
test('agent draft result back button returns to creation hub without syncing result profile', async () => {
test('agent draft result back button returns to draft hub without syncing result profile', async () => {
const user = userEvent.setup();
const resultSession = {
@@ -9215,22 +9647,32 @@ test('agent draft result back button returns to creation hub without syncing res
},
{ timeout: 2500 },
);
const syncCallsBeforeBack = vi
.mocked(executeRpgCreationAction)
.mock.calls.filter(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
).length;
await user.click(screen.getByRole('button', { name: /返回创作/u }));
const draftPanel = await findPlatformTabPanel('saves');
await waitFor(() => {
expect(screen.getByRole('tablist', { name: '玩法模板分类' })).toBeTruthy();
expect(draftPanel.getAttribute('aria-hidden')).toBe('false');
});
expect(within(draftPanel).getByRole('tablist', { name: '作品筛选' })).toBeTruthy();
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true');
expect(
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(
.mock.calls.filter(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
).toBe(false);
).length,
).toBe(syncCallsBeforeBack);
expect(screen.queryByText('世界档案')).toBeNull();
});
@@ -9614,7 +10056,7 @@ test('save tab can resume a selected archive directly into the game', async () =
});
});
test('profile page exposes save archive picker as a direct entry', async () => {
test('profile page keeps save archives inside played stats panel', async () => {
const user = userEvent.setup();
const handleContinueGame = vi.fn();
@@ -9656,20 +10098,11 @@ test('profile page exposes save archive picker as a direct entry', async () => {
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
await clickFirstButtonByName(user, '我的');
const shortcutRegion = await screen.findByRole('region', {
name: '常用功能',
});
await user.click(
within(shortcutRegion).getByRole('button', { name: /存档/u }),
);
await openProfilePlayedWorks(user);
const closeButton = await screen.findByLabelText('关闭存档');
const modal = closeButton.closest('.fixed') as HTMLElement;
expect(modal).toBeTruthy();
expect(within(modal).getByText('SAVES')).toBeTruthy();
await user.click(within(modal).getByRole('button', { name: /潮雾列岛/u }));
expect(screen.queryByLabelText('关闭存档')).toBeNull();
expect(screen.queryByText('SAVES')).toBeNull();
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
await waitFor(() => {
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');

View File

@@ -76,6 +76,7 @@ import type {
WechatMiniProgramPayParams,
WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import type { AuthUser } from '../../services/authService';
@@ -192,8 +193,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: (
@@ -4068,6 +4069,7 @@ export function RpgEntryHomeView({
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
string | null
>(null);
const hasManualCategoryTagSelectionRef = useRef(false);
const pendingPublicAuthorKeysRef = useRef<Set<string>>(new Set());
const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState<
Record<string, PublicUserSummary | null>
@@ -4290,16 +4292,33 @@ export function RpgEntryHomeView({
useEffect(() => {
if (categoryGroups.length === 0) {
setSelectedCategoryTag(null);
hasManualCategoryTagSelectionRef.current = false;
return;
}
const firstCategoryGroup = categoryGroups[0];
const firstCategoryGroup =
categoryGroups.find((group) =>
group.entries.some((entry) => !isMatch3DDemoProfileId(entry.profileId)),
) ?? categoryGroups[0];
const selectedCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null;
if (
firstCategoryGroup &&
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
(!selectedCategoryGroup ||
(!hasManualCategoryTagSelectionRef.current &&
selectedCategoryGroup.entries.every((entry) =>
isMatch3DDemoProfileId(entry.profileId),
) &&
firstCategoryGroup.tag !== selectedCategoryGroup.tag))
) {
setSelectedCategoryTag(firstCategoryGroup.tag);
}
if (
selectedCategoryTag &&
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
) {
hasManualCategoryTagSelectionRef.current = false;
}
}, [categoryGroups, selectedCategoryTag]);
useEffect(() => {
@@ -5226,6 +5245,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<{
@@ -5248,15 +5270,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,
@@ -5356,9 +5379,10 @@ export function RpgEntryHomeView({
return;
}
onSelectNextRecommendEntry?.();
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
}, [
activeRecommendEntry,
activeRecommendEntryKeyForSelection,
commitRecommendDrag,
isAuthenticated,
onSelectNextRecommendEntry,
@@ -5643,7 +5667,10 @@ export function RpgEntryHomeView({
<button
key={group.tag}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
@@ -5841,7 +5868,10 @@ export function RpgEntryHomeView({
<button
key={`${group.tag}:desktop-discover-category`}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
@@ -6610,7 +6640,10 @@ export function RpgEntryHomeView({
<button
key={`${group.tag}:desktop-category`}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}

View File

@@ -10,6 +10,7 @@ import {
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isEdutainmentGalleryEntry,
isMatch3DGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard,
@@ -21,6 +22,7 @@ import {
resolvePlatformPublicWorkCode,
resolvePlatformWorldFallbackCoverImage,
} from './rpgEntryWorldPresentation';
import { buildMatch3DDemoGalleryCard } from '../../data/match3dDemoGalleryCard';
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
expect(formatPlatformWorldTime('1777110165.990127Z')).toBe('2026-04-25');
@@ -78,6 +80,24 @@ test('platform public cards use play type reference images as cover fallback', (
);
});
test('builds local Match3D demo gallery card with generated runtime assets intact', () => {
const card = buildMatch3DDemoGalleryCard();
expect(isMatch3DGalleryEntry(card)).toBe(true);
expect(card.publicWorkCode).toBe('M3-20260525');
expect(resolvePlatformPublicWorkCode(card)).toBe('M3-20260525');
expect(card.coverImageSrc).toBe(
'/match3d-demo/undersea-candy-market/level-scene.png',
);
expect(card.generatedBackgroundAsset?.uiSpritesheetImageSrc).toBe(
'/match3d-demo/undersea-candy-market/ui-spritesheet.png',
);
expect(card.generatedBackgroundAsset?.containerImageSrc).toBeNull();
expect(card.generatedItemAssets?.[0]?.imageViews?.[0]?.imageSrc).toBe(
'/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png',
);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
const slides = buildPuzzleWorkCoverSlides({
workId: 'work-1',