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