| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143 |
- 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);
- }
- });
- });
|