import { describe, it, expect, beforeEach, vi } from 'vitest'; import fc from 'fast-check'; import { Game } from '../src/Game.js'; import { Ball } from '../src/entities/Ball.js'; import { GameState, GameMode, BALL_SPEED, BALL_RADIUS, LAUNCH_INTERVAL, GRID_COLS, GRID_GAP, TOP_PADDING_ROWS, FIXED_TIMESTEP } from '../src/constants.js'; /** * Create a minimal mock canvas for testing */ function createMockCanvas() { const canvas = { width: 375, height: 667, style: { width: '', height: '' }, parentElement: { clientWidth: 375, clientHeight: 667 }, getContext: () => ({ save: vi.fn(), restore: vi.fn(), scale: vi.fn(), setTransform: vi.fn(), clearRect: vi.fn(), fillRect: vi.fn(), fillText: vi.fn(), beginPath: vi.fn(), arc: vi.fn(), fill: vi.fn(), stroke: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), setLineDash: vi.fn(), translate: vi.fn(), fillStyle: '', strokeStyle: '', lineWidth: 1, font: '', textAlign: '', textBaseline: '', globalAlpha: 1, }), addEventListener: vi.fn(), removeEventListener: vi.fn(), getBoundingClientRect: () => ({ left: 0, top: 0, width: 375, height: 667 }), }; return canvas; } describe('Game', () => { let canvas; let game; beforeEach(() => { // Mock window for devicePixelRatio if (typeof globalThis.window === 'undefined') { globalThis.window = { devicePixelRatio: 1 }; } else { globalThis.window.devicePixelRatio = 1; } // Mock performance.now for animations if (typeof globalThis.performance === 'undefined') { globalThis.performance = { now: () => Date.now() }; } canvas = createMockCanvas(); game = new Game(canvas, GameMode.CLASSIC); }); describe('constructor', () => { it('initializes with correct default state', () => { expect(game.state).toBe(GameState.AIMING); expect(game.round).toBe(1); expect(game.ballCount).toBe(1); expect(game.pendingBalls).toBe(0); expect(game.balls).toEqual([]); expect(game.mode).toBe(GameMode.CLASSIC); }); it('calculates blockSize from canvas width', () => { const expectedBlockSize = (375 - GRID_GAP * (GRID_COLS + 1)) / GRID_COLS; expect(game.blockSize).toBeCloseTo(expectedBlockSize, 2); }); it('sets board dimensions', () => { expect(game.boardWidth).toBe(375); expect(game.boardHeight).toBe(667); }); it('creates subsystems', () => { expect(game.renderer).toBeDefined(); expect(game.inputHandler).toBeDefined(); expect(game.boardManager).toBeDefined(); }); }); describe('start()', () => { it('generates first row of blocks', () => { game.start(); const blocks = game.boardManager.getBlocks(); expect(blocks.length).toBeGreaterThan(0); }); it('enables input after start', () => { game.inputHandler.disable(); game.start(); // InputHandler._enabled should be true after start expect(game.inputHandler._enabled).toBe(true); }); }); describe('update() - state machine', () => { it('does nothing in AIMING state', () => { game.state = GameState.AIMING; game.update(16); expect(game.state).toBe(GameState.AIMING); }); it('does nothing in GAME_OVER state', () => { game.state = GameState.GAME_OVER; game.update(16); expect(game.state).toBe(GameState.GAME_OVER); }); it('calls nextRound in ROUND_END state', () => { game.start(); game.state = GameState.ROUND_END; const initialRound = game.round; game.update(16); // After nextRound, round should increment and state should change expect(game.round).toBe(initialRound + 1); // State should be AIMING, SLIDING_DOWN, or GAME_OVER expect([GameState.AIMING, GameState.SLIDING_DOWN, GameState.GAME_OVER]).toContain(game.state); }); }); describe('launch logic', () => { it('transitions from LAUNCHING to RUNNING after all balls launched', () => { game.start(); game.launchAngle = 90; game.state = GameState.LAUNCHING; game.launchIndex = 0; game.launchTimer = 0; game.ballCount = 1; game.balls = []; // Need enough fixed-timestep ticks for launchTimer to exceed LAUNCH_INTERVAL const ticksNeeded = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1; game.update(ticksNeeded * FIXED_TIMESTEP); // After launching 1 ball, should transition to RUNNING expect(game.state).toBe(GameState.RUNNING); expect(game.balls.length).toBe(1); }); it('launches balls at correct intervals', () => { game.start(); game.launchAngle = 90; game.state = GameState.LAUNCHING; game.launchIndex = 0; game.launchTimer = 0; game.ballCount = 3; game.balls = []; // Enough ticks for first ball launch const ticksForOneLaunch = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1; game.update(ticksForOneLaunch * FIXED_TIMESTEP); expect(game.balls.length).toBe(1); // Another interval for second ball game.update(ticksForOneLaunch * FIXED_TIMESTEP); expect(game.balls.length).toBe(2); }); it('sets correct velocity on launched balls', () => { game.start(); game.launchAngle = 90; // straight up game.state = GameState.LAUNCHING; game.launchIndex = 0; game.launchTimer = 0; game.ballCount = 1; game.balls = []; const ticksNeeded = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1; game.update(ticksNeeded * FIXED_TIMESTEP); const ball = game.balls[0]; const rad = 90 * Math.PI / 180; expect(ball.vx).toBeCloseTo(BALL_SPEED * Math.cos(rad), 5); expect(ball.vy).toBeCloseTo(-BALL_SPEED * Math.sin(rad), 5); }); }); describe('running state - round end detection', () => { it('transitions to ROUND_END when all balls are inactive', () => { game.start(); game.state = GameState.RUNNING; // Create a ball that is already inactive const { Ball } = require('../src/entities/Ball.js'); const ball = new Ball(100, 600, BALL_RADIUS); ball.active = false; game.balls = [ball]; // Need at least one fixed-timestep tick game.update(FIXED_TIMESTEP); expect(game.state).toBe(GameState.ROUND_END); }); it('stays in RUNNING when some balls are still active', () => { game.start(); game.state = GameState.RUNNING; const { Ball } = require('../src/entities/Ball.js'); const ball1 = new Ball(100, 300, BALL_RADIUS); ball1.vx = 0; ball1.vy = 5; ball1.active = true; const ball2 = new Ball(100, 600, BALL_RADIUS); ball2.active = false; game.balls = [ball1, ball2]; game.update(16); expect(game.state).toBe(GameState.RUNNING); }); }); describe('nextRound()', () => { it('adds pending balls to ball count', () => { game.start(); game.pendingBalls = 3; game.ballCount = 2; game.nextRound(); expect(game.ballCount).toBe(5); expect(game.pendingBalls).toBe(0); }); it('increments round number', () => { game.start(); const initialRound = game.round; game.nextRound(); expect(game.round).toBe(initialRound + 1); }); it('transitions to AIMING state', () => { game.start(); game.state = GameState.ROUND_END; game.nextRound(); // nextRound now transitions to SLIDING_DOWN first if (game.state === GameState.SLIDING_DOWN) { // Force slide animation to complete game._slideStartTime = performance.now() - game._slideDuration - 1; game._updateSlideDown(); } // Should be AIMING unless game over if (game.state !== GameState.GAME_OVER) { expect(game.state).toBe(GameState.AIMING); } }); it('enables input after round transition', () => { game.start(); game.inputHandler.disable(); game.nextRound(); // Force slide animation to complete if (game.state === GameState.SLIDING_DOWN) { game._slideStartTime = performance.now() - game._slideDuration - 1; game._updateSlideDown(); } if (game.state !== GameState.GAME_OVER) { expect(game.inputHandler._enabled).toBe(true); } }); }); describe('addPendingBall()', () => { it('increments pending balls', () => { expect(game.pendingBalls).toBe(0); game.addPendingBall(); expect(game.pendingBalls).toBe(1); game.addPendingBall(); expect(game.pendingBalls).toBe(2); }); }); describe('clearRow() and clearColumn()', () => { it('delegates clearRow to boardManager', () => { game.start(); const spy = vi.spyOn(game.boardManager, 'clearRow').mockReturnValue([]); game.clearRow(2); expect(spy).toHaveBeenCalledWith(2); }); it('delegates clearColumn to boardManager', () => { game.start(); const spy = vi.spyOn(game.boardManager, 'clearColumn').mockReturnValue([]); game.clearColumn(3); expect(spy).toHaveBeenCalledWith(3); }); }); describe('restart()', () => { it('resets game state to initial values', () => { game.start(); game.round = 5; game.ballCount = 10; game.pendingBalls = 3; game.state = GameState.GAME_OVER; game.restart(); expect(game.state).toBe(GameState.AIMING); expect(game.round).toBe(1); expect(game.ballCount).toBe(1); expect(game.pendingBalls).toBe(0); expect(game.balls).toEqual([]); }); it('generates new blocks after restart', () => { game.start(); game.restart(); const blocks = game.boardManager.getBlocks(); expect(blocks.length).toBeGreaterThan(0); }); }); describe('gameOver()', () => { it('sets state to GAME_OVER', () => { game.gameOver(); expect(game.state).toBe(GameState.GAME_OVER); }); }); describe('render()', () => { it('does not throw when rendering in AIMING state', () => { game.start(); game.state = GameState.AIMING; expect(() => game.render()).not.toThrow(); }); it('does not throw when rendering in GAME_OVER state', () => { game.start(); game.state = GameState.GAME_OVER; expect(() => game.render()).not.toThrow(); }); it('does not throw when rendering with active balls', () => { game.start(); game.state = GameState.RUNNING; const { Ball } = require('../src/entities/Ball.js'); const ball = new Ball(100, 300, BALL_RADIUS); ball.active = true; game.balls = [ball]; expect(() => game.render()).not.toThrow(); }); }); describe('input disabled during RUNNING', () => { it('disables input when launching starts', () => { game.start(); game.inputHandler.enable(); // Simulate _startLaunch game._startLaunch(); expect(game.inputHandler._enabled).toBe(false); }); it('re-enables input after round ends', () => { game.start(); game.inputHandler.disable(); game.nextRound(); // Force slide animation to complete if (game.state === GameState.SLIDING_DOWN) { game._slideStartTime = performance.now() - game._slideDuration - 1; game._updateSlideDown(); } if (game.state !== GameState.GAME_OVER) { expect(game.inputHandler._enabled).toBe(true); } }); }); describe('collision handling', () => { it('removes destroyed blocks during physics step', () => { game.start(); // Add a block with count=1 that will be destroyed on hit const { Block } = require('../src/entities/Block.js'); const block = new Block(3, 3, 1, game.blockSize); game.boardManager.blocks = [block]; // Create a ball above the block, moving downward toward it const { Ball } = require('../src/entities/Ball.js'); const blockRect = block.getRect(); const ball = new Ball( blockRect.x + blockRect.width / 2, blockRect.y - BALL_RADIUS - 2, BALL_RADIUS ); ball.vx = 0; ball.vy = BALL_SPEED; ball.active = true; game.balls = [ball]; game.state = GameState.RUNNING; game._stepPhysics(); // Block should be destroyed and removed expect(game.boardManager.getBlocks().length).toBe(0); }); }); /** * Feature: ball-block-breaker, Property 4: 球到底部停止 * **Validates: Requirements 3.3** * * 对于任意小球,当其 y 坐标到达或超过 Game_Board 底部时, * 该球应被标记为非活跃状态(active = false)。 */ describe('Property 4: 球到底部停止', () => { it('balls at or beyond the bottom become inactive after update', () => { fc.assert( fc.property( // Generate a y position at or beyond the bottom of the board fc.float({ min: 0, max: 200, noNaN: true, noDefaultInfinity: true }), // Generate an x position within the board fc.float({ min: BALL_RADIUS + 1, max: 375 - BALL_RADIUS - 1, noNaN: true, noDefaultInfinity: true }), // Generate a downward vy (positive = moving down) fc.float({ min: 1, max: BALL_SPEED, noNaN: true, noDefaultInfinity: true }), (yOffset, x, vy) => { const boardHeight = game.boardHeight; // Place ball at or past the bottom boundary const y = boardHeight - BALL_RADIUS + yOffset; const ball = new Ball(x, y, BALL_RADIUS); ball.vx = 0; ball.vy = vy; ball.active = true; game.state = GameState.RUNNING; game.balls = [ball]; // Need at least one fixed-timestep tick game.update(FIXED_TIMESTEP); // Ball that was at or beyond the bottom should now be inactive expect(ball.active).toBe(false); } ), { numRuns: 100 } ); }); }); }); /** * Feature: ball-block-breaker, Property 5: 所有球到底部则回合结束 * **Validates: Requirements 3.4** * * 对于任意球列表,当且仅当所有球的 active 状态均为 false 时, * 回合应结束(状态转为 ROUND_END)。 */ describe('Property 5: 所有球到底部则回合结束', () => { let canvas; let game; beforeEach(() => { if (typeof globalThis.window === 'undefined') { globalThis.window = { devicePixelRatio: 1 }; } else { globalThis.window.devicePixelRatio = 1; } if (typeof globalThis.performance === 'undefined') { globalThis.performance = { now: () => Date.now() }; } canvas = createMockCanvas(); game = new Game(canvas, GameMode.CLASSIC); game.start(); }); it('transitions to ROUND_END when all balls are inactive', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 20 }), (numBalls) => { game.state = GameState.RUNNING; // Create numBalls balls, all inactive game.balls = Array.from({ length: numBalls }, (_, i) => { const ball = new Ball(50 + i * 10, game.boardHeight - BALL_RADIUS, BALL_RADIUS); ball.vx = 0; ball.vy = 0; ball.active = false; return ball; }); game._updateRunning(16); expect(game.state).toBe(GameState.ROUND_END); } ), { numRuns: 100 } ); }); it('stays in RUNNING when at least one ball is still active', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 20 }), fc.integer({ min: 0, max: 19 }), (numBalls, activeIndexRaw) => { const activeIndex = activeIndexRaw % numBalls; game.state = GameState.RUNNING; // Create numBalls balls, all inactive except one at safe position game.balls = Array.from({ length: numBalls }, (_, i) => { const ball = new Ball(50 + i * 10, 300, BALL_RADIUS); ball.vx = 0; ball.vy = 0; ball.active = (i === activeIndex); return ball; }); game._updateRunning(16); expect(game.state).toBe(GameState.RUNNING); } ), { numRuns: 100 } ); }); }); /** * Feature: ball-block-breaker, Property 13: BallItem碰撞效果 * **Validates: Requirements 6.1, 6.3** * * 对于任意游戏状态,当小球触碰 BallItem 后,下一轮的球数应比当前多1, * 且该 BallItem 应被标记为已收集(从面板移除)。 */ describe('Property 13: BallItem碰撞效果', () => { let canvas; let game; beforeEach(() => { if (typeof globalThis.window === 'undefined') { globalThis.window = { devicePixelRatio: 1 }; } else { globalThis.window.devicePixelRatio = 1; } if (typeof globalThis.performance === 'undefined') { globalThis.performance = { now: () => Date.now() }; } canvas = createMockCanvas(); game = new Game(canvas, GameMode.CLASSIC); game.start(); }); it('collecting a BallItem increases pendingBalls by 1 and removes the item', () => { const { BallItem } = require('../src/entities/Item.js'); fc.assert( fc.property( // Random grid position for the BallItem fc.integer({ min: 0, max: GRID_COLS - 1 }), fc.integer({ min: 1, max: 5 }), // Random initial pendingBalls count fc.integer({ min: 0, max: 10 }), (gridX, gridY, initialPending) => { // Set up game state game.state = GameState.RUNNING; game.pendingBalls = initialPending; // Create a BallItem at the given grid position const ballItem = new BallItem(gridX, gridY, game.blockSize); // Clear existing items and add only our BallItem game.boardManager.blocks = []; game.boardManager.items = [ballItem]; // Position a ball overlapping the BallItem (guaranteed collision) const itemRect = ballItem.getRect(); const ball = new Ball( itemRect.x + itemRect.width / 2, itemRect.y + itemRect.height / 2, BALL_RADIUS ); ball.vx = 0; ball.vy = -0.001; ball.active = true; game.balls = [ball]; // Run physics step (handles item collisions) game._stepPhysics(); // pendingBalls should have increased by exactly 1 expect(game.pendingBalls).toBe(initialPending + 1); // BallItem should be removed from items array expect(game.boardManager.getItems().length).toBe(0); // BallItem should be marked as collected expect(ballItem.collected).toBe(true); } ), { numRuns: 100 } ); }); }); /** * Feature: ball-block-breaker, Property 15: LineClearItem碰撞效果 * **Validates: Requirements 6.4, 6.5** * * 对于任意方块布局和 LineClearItem 位置,触发后该行或列的所有方块应被移除, * 且 LineClearItem 本身不被移除。 */ describe('Property 15: LineClearItem碰撞效果', () => { let canvas; let game; beforeEach(() => { if (typeof globalThis.window === 'undefined') { globalThis.window = { devicePixelRatio: 1 }; } else { globalThis.window.devicePixelRatio = 1; } if (typeof globalThis.performance === 'undefined') { globalThis.performance = { now: () => Date.now() }; } canvas = createMockCanvas(); game = new Game(canvas, GameMode.ENHANCED); game.start(); }); it('collecting a LineClearItem decrements count of blocks in its row or column by 1', () => { const { LineClearItem } = require('../src/entities/Item.js'); const { Block } = require('../src/entities/Block.js'); fc.assert( fc.property( // LineClearItem grid position fc.integer({ min: 0, max: GRID_COLS - 1 }), fc.integer({ min: 1, max: 5 }), // Direction fc.constantFrom('horizontal', 'vertical'), // Generate blocks that won't overlap with the ball position fc.array( fc.record({ gridX: fc.integer({ min: 0, max: GRID_COLS - 1 }), gridY: fc.integer({ min: 0, max: 7 }), count: fc.integer({ min: 2, max: 20 }) }), { minLength: 1, maxLength: 20 } ), (itemGridX, itemGridY, direction, blockDefs) => { game.state = GameState.RUNNING; // Create LineClearItem with explicit direction const lineClearItem = new LineClearItem(itemGridX, itemGridY, game.blockSize, direction); // Filter out blocks that would overlap with the item's grid cell // or are adjacent (ball physics might hit nearby blocks) const safeBlockDefs = blockDefs.filter(def => !(def.gridX === itemGridX && def.gridY === itemGridY) && Math.abs(def.gridY - itemGridY) > 1 && Math.abs(def.gridX - itemGridX) > 1 ); if (safeBlockDefs.length === 0) return; // skip if no safe blocks // Create blocks from definitions const blocks = safeBlockDefs.map(def => new Block(def.gridX, def.gridY, def.count, game.blockSize) ); const originalCounts = blocks.map(b => b.count); // Set up board game.boardManager.blocks = [...blocks]; game.boardManager.items = [lineClearItem]; // Position a ball overlapping the LineClearItem for guaranteed collision const itemRect = lineClearItem.getRect(); const ball = new Ball( itemRect.x + itemRect.width / 2, itemRect.y + itemRect.height / 2, BALL_RADIUS ); ball.vx = 0; ball.vy = -0.001; ball.active = true; game.balls = [ball]; // Run physics step (handles item collisions) game._stepPhysics(); // LineClearItem should be marked as collected expect(lineClearItem.collected).toBe(true); // LineClearItem should NOT be removed (stays on board) expect(game.boardManager.getItems()).toContain(lineClearItem); // Check that affected blocks had count decremented by 1 // (blocks not overlapping with ball, so only clearRow/clearColumn hit them) for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; const wasAffected = direction === 'horizontal' ? block.gridY === itemGridY : block.gridX === itemGridX; if (wasAffected && !block.isDestroyed()) { expect(block.count).toBe(originalCounts[i] - 1); } else if (!wasAffected) { expect(block.count).toBe(originalCounts[i]); } } } ), { numRuns: 100 } ); }); }); /** * Feature: ball-block-breaker, Property 18: 运行中忽略输入 * **Validates: Requirements 9.3** * * 对于任意游戏状态为 RUNNING 或 LAUNCHING 时,输入事件不应改变游戏的瞄准角度或触发新的发射。 */ describe('Property 18: 运行中忽略输入', () => { let canvas; let game; beforeEach(() => { if (typeof globalThis.window === 'undefined') { globalThis.window = { devicePixelRatio: 1 }; } else { globalThis.window.devicePixelRatio = 1; } if (typeof globalThis.performance === 'undefined') { globalThis.performance = { now: () => Date.now() }; } canvas = createMockCanvas(); game = new Game(canvas, GameMode.CLASSIC); game.start(); }); it('input callbacks do not change aimAngle or trigger launch in RUNNING/LAUNCHING state', () => { fc.assert( fc.property( // Random state: RUNNING or LAUNCHING fc.constantFrom(GameState.RUNNING, GameState.LAUNCHING), // Random initial aimAngle fc.float({ min: 15, max: 165, noNaN: true, noDefaultInfinity: true }), // Random angle from input event fc.float({ min: -360, max: 360, noNaN: true, noDefaultInfinity: true }), (state, initialAimAngle, inputAngle) => { // Set game to the target state game.state = state; game.aimAngle = initialAimAngle; // Record state before input const aimAngleBefore = game.aimAngle; const stateBefore = game.state; const ballsBefore = game.balls.length; const eventData = { x: 100, y: 100, angle: inputAngle }; // Trigger all three input callbacks directly game.inputHandler._onAimStart(eventData); game.inputHandler._onAimMove(eventData); game.inputHandler._onAimEnd(eventData); // aimAngle must not change expect(game.aimAngle).toBe(aimAngleBefore); // State must not change (no launch triggered) expect(game.state).toBe(stateBefore); // No new balls should be created expect(game.balls.length).toBe(ballsBefore); } ), { numRuns: 100 } ); }); }); /** * Game Over UI Tests * **Validates: Requirements 7.2, 7.3** * * Tests for the game over overlay: showing the overlay with round number, * restart button functionality, and hiding the overlay on restart. */ describe('Game Over UI (Requirements 7.2, 7.3)', () => { let canvas; let game; let overlay; let scoreEl; let restartBtn; function createMockCanvasLocal() { return { width: 375, height: 667, style: { width: '', height: '' }, parentElement: { clientWidth: 375, clientHeight: 667 }, getContext: () => ({ save: vi.fn(), restore: vi.fn(), scale: vi.fn(), setTransform: vi.fn(), clearRect: vi.fn(), fillRect: vi.fn(), fillText: vi.fn(), beginPath: vi.fn(), arc: vi.fn(), fill: vi.fn(), stroke: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), setLineDash: vi.fn(), translate: vi.fn(), fillStyle: '', strokeStyle: '', lineWidth: 1, font: '', textAlign: '', textBaseline: '', globalAlpha: 1, }), addEventListener: vi.fn(), removeEventListener: vi.fn(), getBoundingClientRect: () => ({ left: 0, top: 0, width: 375, height: 667 }), }; } beforeEach(() => { if (typeof globalThis.window === 'undefined') { globalThis.window = { devicePixelRatio: 1 }; } else { globalThis.window.devicePixelRatio = 1; } if (typeof globalThis.performance === 'undefined') { globalThis.performance = { now: () => Date.now() }; } canvas = createMockCanvasLocal(); game = new Game(canvas, GameMode.CLASSIC); // Create mock DOM elements for game over UI overlay = { style: { display: 'none' } }; scoreEl = { textContent: '' }; restartBtn = { _listeners: [], addEventListener(event, handler) { this._listeners.push({ event, handler }); }, }; game.setGameOverUI(overlay, scoreEl, restartBtn); game.start(); }); describe('setGameOverUI()', () => { it('stores references to DOM elements', () => { expect(game.gameOverOverlay).toBe(overlay); expect(game.gameOverScoreEl).toBe(scoreEl); expect(game.gameOverRestartBtn).toBe(restartBtn); }); it('binds click handler to restart button', () => { expect(restartBtn._listeners.length).toBe(1); expect(restartBtn._listeners[0].event).toBe('click'); }); }); describe('gameOver()', () => { it('sets state to GAME_OVER', () => { game.round = 5; game.gameOver(); expect(game.state).toBe(GameState.GAME_OVER); }); it('displays the current round number in the score element', () => { game.round = 7; game.gameOver(); expect(scoreEl.textContent).toBe('第 7 轮'); }); it('shows the game over overlay', () => { game.gameOver(); expect(overlay.style.display).toBe('flex'); }); it('works without UI elements set (no errors)', () => { const game2 = new Game(createMockCanvasLocal(), GameMode.CLASSIC); expect(() => game2.gameOver()).not.toThrow(); expect(game2.state).toBe(GameState.GAME_OVER); }); }); describe('restart via button', () => { it('clicking restart button resets game state', () => { game.round = 10; game.gameOver(); // Simulate clicking the restart button const clickHandler = restartBtn._listeners.find(l => l.event === 'click').handler; clickHandler(); expect(game.state).toBe(GameState.AIMING); expect(game.round).toBe(1); expect(game.ballCount).toBe(1); }); it('clicking restart button hides the overlay', () => { game.gameOver(); expect(overlay.style.display).toBe('flex'); const clickHandler = restartBtn._listeners.find(l => l.event === 'click').handler; clickHandler(); expect(overlay.style.display).toBe('none'); }); }); describe('nextRound triggers gameOver with UI', () => { it('shows overlay when blocks reach bottom during nextRound', () => { // Force checkGameOver to return true vi.spyOn(game.boardManager, 'checkGameOver').mockReturnValue(true); game.round = 12; game.nextRound(); // Force slide animation to complete if (game.state === GameState.SLIDING_DOWN) { game._slideStartTime = performance.now() - game._slideDuration - 1; game._updateSlideDown(); } // round increments before gameOver is called expect(game.state).toBe(GameState.GAME_OVER); expect(overlay.style.display).toBe('flex'); expect(scoreEl.textContent).toBe('第 13 轮'); }); }); }); /** * 集成单元测试 - 任务 11.2 * 测试模式选择后游戏正确初始化,以及完整回合流程 * **Validates: Requirements 1.1-1.4, 3.4, 5.1, 5.2** */ describe('Integration: Mode selection and game initialization', () => { beforeEach(() => { if (typeof globalThis.window === 'undefined') { globalThis.window = { devicePixelRatio: 1 }; } else { globalThis.window.devicePixelRatio = 1; } if (typeof globalThis.performance === 'undefined') { globalThis.performance = { now: () => Date.now() }; } }); it('CLASSIC mode initializes correctly (state=AIMING, round=1, ballCount=1)', () => { const canvas = createMockCanvas(); const game = new Game(canvas, GameMode.CLASSIC); expect(game.mode).toBe(GameMode.CLASSIC); expect(game.state).toBe(GameState.AIMING); expect(game.round).toBe(1); expect(game.ballCount).toBe(1); }); it('ENHANCED mode initializes correctly (state=AIMING, round=1, ballCount=1)', () => { const canvas = createMockCanvas(); const game = new Game(canvas, GameMode.ENHANCED); expect(game.mode).toBe(GameMode.ENHANCED); expect(game.state).toBe(GameState.AIMING); expect(game.round).toBe(1); expect(game.ballCount).toBe(1); }); it('start() generates blocks on the board', () => { const canvas = createMockCanvas(); const game = new Game(canvas, GameMode.CLASSIC); game.start(); const blocks = game.boardManager.getBlocks(); expect(blocks.length).toBeGreaterThan(0); // All initial blocks should be at TOP_PADDING_ROWS for (const block of blocks) { expect(block.gridY).toBe(TOP_PADDING_ROWS); } }); it('start() in ENHANCED mode generates blocks on the board', () => { const canvas = createMockCanvas(); const game = new Game(canvas, GameMode.ENHANCED); game.start(); const blocks = game.boardManager.getBlocks(); expect(blocks.length).toBeGreaterThan(0); }); }); describe('Integration: Full round flow (launch → collision → round end → new row)', () => { let canvas; let game; beforeEach(() => { if (typeof globalThis.window === 'undefined') { globalThis.window = { devicePixelRatio: 1 }; } else { globalThis.window.devicePixelRatio = 1; } if (typeof globalThis.performance === 'undefined') { globalThis.performance = { now: () => Date.now() }; } canvas = createMockCanvas(); game = new Game(canvas, GameMode.CLASSIC); game.start(); }); it('transitions AIMING → LAUNCHING → RUNNING when launching', () => { expect(game.state).toBe(GameState.AIMING); // Simulate launch game.launchAngle = 90; game.state = GameState.LAUNCHING; game.launchIndex = 0; game.launchTimer = 0; game.balls = []; // Provide enough fixed-timestep ticks to launch all balls (ballCount=1) const ticksNeeded = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1; game.update(ticksNeeded * FIXED_TIMESTEP); expect(game.state).toBe(GameState.RUNNING); expect(game.balls.length).toBe(1); expect(game.balls[0].active).toBe(true); }); it('transitions RUNNING → ROUND_END when all balls become inactive', () => { game.state = GameState.RUNNING; // Create a ball that is already inactive (simulating it reached bottom) const ball = new Ball(game.boardWidth / 2, game.boardHeight - BALL_RADIUS); ball.active = false; game.balls = [ball]; game.update(FIXED_TIMESTEP); expect(game.state).toBe(GameState.ROUND_END); }); it('ROUND_END triggers nextRound: round increments, new blocks generated, blocks moved down', () => { // Record initial blocks const initialBlocks = game.boardManager.getBlocks(); const initialBlockCount = initialBlocks.length; const initialGridYs = initialBlocks.map(b => b.gridY); expect(game.round).toBe(1); // Trigger round end game.state = GameState.ROUND_END; game.update(16); // Round should have incremented (nextRound increments after generating) if (game.state !== GameState.GAME_OVER) { expect(game.round).toBe(2); // Complete slide animation if in SLIDING_DOWN if (game.state === GameState.SLIDING_DOWN) { game._slideStartTime = performance.now() - game._slideDuration - 1; game._updateSlideDown(); } expect(game.state).toBe(GameState.AIMING); // Blocks should have been moved down and new ones generated const newBlocks = game.boardManager.getBlocks(); expect(newBlocks.length).toBeGreaterThan(0); // There should be blocks at TOP_PADDING_ROWS (newly generated) const topRowBlocks = newBlocks.filter(b => b.gridY === TOP_PADDING_ROWS); expect(topRowBlocks.length).toBeGreaterThan(0); // Original blocks should have moved down by 1 row const movedBlocks = newBlocks.filter(b => b.gridY > 0); expect(movedBlocks.length).toBeGreaterThanOrEqual(initialBlockCount); } }); it('complete flow: launch → all balls inactive → round end → new round', () => { const round1Blocks = [...game.boardManager.getBlocks()]; expect(game.round).toBe(1); // Step 1: Start launching game.launchAngle = 90; game.state = GameState.LAUNCHING; game.launchIndex = 0; game.launchTimer = 0; game.balls = []; // Step 2: Launch all balls const ticksNeeded = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1; game.update(ticksNeeded * FIXED_TIMESTEP); expect(game.state).toBe(GameState.RUNNING); // Step 3: Set all balls to inactive (simulate reaching bottom) for (const ball of game.balls) { ball.active = false; } // Step 4: Update triggers ROUND_END detection game.update(FIXED_TIMESTEP); expect(game.state).toBe(GameState.ROUND_END); // Step 5: Update triggers nextRound game.update(16); if (game.state !== GameState.GAME_OVER) { expect(game.round).toBe(2); // Complete slide animation if in SLIDING_DOWN if (game.state === GameState.SLIDING_DOWN) { game._slideStartTime = performance.now() - game._slideDuration - 1; game._updateSlideDown(); } expect(game.state).toBe(GameState.AIMING); // New blocks should exist at TOP_PADDING_ROWS const blocks = game.boardManager.getBlocks(); const topRowBlocks = blocks.filter(b => b.gridY === TOP_PADDING_ROWS); expect(topRowBlocks.length).toBeGreaterThan(0); // Original round 1 blocks should now be at TOP_PADDING_ROWS + 1 (moved down) const row1Blocks = blocks.filter(b => b.gridY === TOP_PADDING_ROWS + 1); expect(row1Blocks.length).toBeGreaterThanOrEqual(round1Blocks.length); } }); });