1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-02 20:43:41 +08:00
parent 543ccf2509
commit 5831703156
36 changed files with 799 additions and 254 deletions

View File

@@ -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();
});