import { describe, expect, test } from 'vitest'; import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import { advanceLocalPuzzleLevel, dragLocalPuzzlePiece, startLocalPuzzleRun, swapLocalPuzzlePieces, } from './puzzleLocalRuntime'; const baseWork: PuzzleWorkSummary = { workId: 'work-1', profileId: 'profile-1', ownerUserId: 'user-1', sourceSessionId: null, authorDisplayName: '测试作者', levelName: '测试拼图', summary: '服务层测试用拼图。', themeTags: ['测试', '拼图'], coverImageSrc: '/generated-puzzle-assets/test.png', coverAssetId: null, publicationStatus: 'published', updatedAt: '2026-04-25T00:00:00.000Z', publishedAt: '2026-04-25T00:00:00.000Z', playCount: 0, publishReady: true, }; function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) { return pieces.some((piece) => pieces.some((candidate) => { if (piece.pieceId === candidate.pieceId) { return false; } const currentRowDelta = candidate.currentRow - piece.currentRow; const currentColDelta = candidate.currentCol - piece.currentCol; const correctRowDelta = candidate.correctRow - piece.correctRow; const correctColDelta = candidate.correctCol - piece.correctCol; return ( Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 && currentRowDelta === correctRowDelta && currentColDelta === correctColDelta ); }), ); } function solveCurrentLevel(run: ReturnType) { let nextRun = run; for (let index = 0; index < 12; index += 1) { const currentLevel = nextRun.currentLevel; if (!currentLevel || currentLevel.status === 'cleared') { return nextRun; } const misplacedPiece = currentLevel.board.pieces.find( (piece) => piece.currentRow !== piece.correctRow || piece.currentCol !== piece.correctCol, ); if (!misplacedPiece) { return nextRun; } nextRun = dragLocalPuzzlePiece(nextRun, { pieceId: misplacedPiece.pieceId, targetRow: misplacedPiece.correctRow, targetCol: misplacedPiece.correctCol, }); } return nextRun; } describe('puzzleLocalRuntime', () => { test('每次启动都会生成不同的初始打乱样式', async () => { const firstRun = startLocalPuzzleRun(baseWork); await new Promise((resolve) => setTimeout(resolve, 2)); const secondRun = startLocalPuzzleRun(baseWork); const firstPositions = firstRun.currentLevel?.board.pieces.map((piece) => [ piece.currentRow, piece.currentCol, ]); const secondPositions = secondRun.currentLevel?.board.pieces.map((piece) => [ piece.currentRow, piece.currentCol, ]); expect(firstPositions).not.toEqual(secondPositions); }); test('初始棋盘没有任何自动合并块', () => { for (let index = 0; index < 12; index += 1) { const run = startLocalPuzzleRun(baseWork); const board = run.currentLevel?.board; expect(board?.mergedGroups).toEqual([]); expect(hasAnyCorrectNeighborPair(board?.pieces ?? [])).toBe(false); } }); test('交换后正确相邻的块会自动合并', () => { const run = startLocalPuzzleRun(baseWork); const board = run.currentLevel?.board; expect(board).toBeTruthy(); if (!run.currentLevel || !board) { return; } const nextRun = { ...run, currentLevel: { ...run.currentLevel, board: { ...board, pieces: board.pieces.map((piece) => { const layout: Record = { 'piece-0': [1, 1], 'piece-1': [0, 1], 'piece-2': [2, 2], 'piece-3': [0, 2], 'piece-4': [1, 0], 'piece-5': [2, 0], 'piece-6': [0, 0], 'piece-7': [1, 2], 'piece-8': [2, 1], }; const current = layout[piece.pieceId] ?? [piece.currentRow, piece.currentCol]; return { ...piece, currentRow: current[0], currentCol: current[1], mergedGroupId: null, }; }), mergedGroups: [], allTilesResolved: false, }, }, }; const swapped = swapLocalPuzzlePieces(nextRun, { firstPieceId: 'piece-0', secondPieceId: 'piece-6', }); expect( swapped.currentLevel?.board.mergedGroups.some( (group) => group.pieceIds.includes('piece-0') && group.pieceIds.includes('piece-1'), ), ).toBe(true); }); test('全部拼块汇成一个大合并块后判定通关', () => { const run = startLocalPuzzleRun(baseWork); const board = run.currentLevel?.board; expect(board).toBeTruthy(); if (!run.currentLevel || !board) { return; } const solvedByOneGroup = { ...run, currentLevel: { ...run.currentLevel, board: { ...board, pieces: board.pieces.map((piece, index) => ({ ...piece, currentRow: Math.floor(index / board.cols), currentCol: (index + 1) % board.cols, mergedGroupId: 'group-full', })), mergedGroups: [ { groupId: 'group-full', pieceIds: board.pieces.map((piece) => piece.pieceId), occupiedCells: board.pieces.map((_, index) => ({ row: Math.floor(index / board.cols), col: (index + 1) % board.cols, })), }, ], allTilesResolved: true, }, }, }; expect(solvedByOneGroup.currentLevel.board.allTilesResolved).toBe(true); }); test('大合并块覆盖多个小块时会与被覆盖块逐一交换,不会出现小块消失', () => { const run = startLocalPuzzleRun(baseWork); const board = run.currentLevel?.board; expect(board).toBeTruthy(); if (!run.currentLevel || !board) { return; } const preparedRun = { ...run, currentLevel: { ...run.currentLevel, board: { ...board, pieces: board.pieces.map((piece) => { const layout: Record = { 'piece-0': [0, 0, 'group-1'], 'piece-1': [0, 1, 'group-1'], 'piece-2': [0, 2, null], 'piece-3': [1, 0, 'group-1'], 'piece-4': [1, 1, 'group-1'], 'piece-5': [1, 2, null], 'piece-6': [2, 0, null], 'piece-7': [2, 1, null], 'piece-8': [2, 2, null], }; const current = layout[piece.pieceId] ?? [ piece.currentRow, piece.currentCol, piece.mergedGroupId, ]; return { ...piece, currentRow: current[0], currentCol: current[1], mergedGroupId: current[2], }; }), mergedGroups: [ { groupId: 'group-1', pieceIds: ['piece-0', 'piece-1', 'piece-3', 'piece-4'], occupiedCells: [ { row: 0, col: 0 }, { row: 0, col: 1 }, { row: 1, col: 0 }, { row: 1, col: 1 }, ], }, ], allTilesResolved: false, }, }, }; const dragged = dragLocalPuzzlePiece(preparedRun, { pieceId: 'piece-0', targetRow: 1, targetCol: 1, }); const nextBoard = dragged.currentLevel?.board; expect(nextBoard).toBeTruthy(); if (!nextBoard) { return; } const occupiedCells = nextBoard.pieces.map((piece) => `${piece.currentRow}:${piece.currentCol}`); expect(new Set(occupiedCells).size).toBe(nextBoard.pieces.length); expect( nextBoard.pieces.find((piece) => piece.pieceId === 'piece-5'), ).toMatchObject({ currentRow: 0, currentCol: 0 }); expect( nextBoard.pieces.find((piece) => piece.pieceId === 'piece-7'), ).toMatchObject({ currentRow: 0, currentCol: 1 }); expect( nextBoard.pieces.find((piece) => piece.pieceId === 'piece-8'), ).toMatchObject({ currentRow: 1, currentCol: 0 }); expect( nextBoard.pieces.find((piece) => piece.pieceId === 'piece-0'), ).toMatchObject({ currentRow: 1, currentCol: 1 }); expect( nextBoard.pieces.find((piece) => piece.pieceId === 'piece-4'), ).toMatchObject({ currentRow: 2, currentCol: 2 }); }); test('通关后提供下一关入口并能推进到新棋盘', () => { const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork)); expect(clearedRun.currentLevel?.status).toBe('cleared'); expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2'); expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0); expect(clearedRun.currentLevel?.leaderboardEntries.length).toBeGreaterThan(0); expect( clearedRun.currentLevel?.leaderboardEntries.some( (entry) => entry.isCurrentPlayer && entry.nickname === '测试作者', ), ).toBe(true); const nextRun = advanceLocalPuzzleLevel(clearedRun); expect(nextRun.currentLevelIndex).toBe(2); expect(nextRun.currentLevel?.status).toBe('playing'); expect(nextRun.currentLevel?.levelName).toBe('测试拼图 · 第 2 关'); expect(nextRun.currentLevel?.board.allTilesResolved).toBe(false); expect(nextRun.currentLevel?.elapsedMs).toBeNull(); expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]); expect(nextRun.recommendedNextProfileId).toBeNull(); }); });