import * as fc from 'fast-check'; import { describe, it, expect } from 'vitest'; import { Ball } from '../src/entities/Ball.js'; import { BALL_RADIUS, BALL_COLOR, BALL_SPEED } from '../src/constants.js'; describe('Ball', () => { // ---- Unit Tests ---- describe('constructor', () => { it('initializes with given position, radius, and color', () => { const ball = new Ball(100, 200, 10, '#ff0000'); expect(ball.x).toBe(100); expect(ball.y).toBe(200); expect(ball.radius).toBe(10); expect(ball.color).toBe('#ff0000'); expect(ball.vx).toBe(0); expect(ball.vy).toBe(0); expect(ball.active).toBe(true); }); it('uses default radius and color when not provided', () => { const ball = new Ball(50, 60); expect(ball.radius).toBe(BALL_RADIUS); expect(ball.color).toBe(BALL_COLOR); }); }); describe('reflect', () => { it('negates vx when axis is x', () => { const ball = new Ball(100, 100); ball.vx = 5; ball.vy = -3; ball.reflect('x'); expect(ball.vx).toBe(-5); expect(ball.vy).toBe(-3); }); it('negates vy when axis is y', () => { const ball = new Ball(100, 100); ball.vx = 5; ball.vy = -3; ball.reflect('y'); expect(ball.vx).toBe(5); expect(ball.vy).toBe(3); }); it('does nothing for invalid axis', () => { const ball = new Ball(100, 100); ball.vx = 5; ball.vy = -3; ball.reflect('z'); expect(ball.vx).toBe(5); expect(ball.vy).toBe(-3); }); }); describe('isAtBottom', () => { it('returns true when ball touches bottom', () => { const ball = new Ball(100, 492, 8); expect(ball.isAtBottom(500)).toBe(true); }); it('returns true when ball is past bottom', () => { const ball = new Ball(100, 510, 8); expect(ball.isAtBottom(500)).toBe(true); }); it('returns false when ball is above bottom', () => { const ball = new Ball(100, 400, 8); expect(ball.isAtBottom(500)).toBe(false); }); }); describe('getRect', () => { it('returns correct AABB rectangle', () => { const ball = new Ball(100, 200, 10); const rect = ball.getRect(); expect(rect).toEqual({ x: 90, y: 190, width: 20, height: 20 }); }); }); describe('update', () => { it('moves ball by vx and vy each frame', () => { const ball = new Ball(100, 100, 8); ball.vx = 3; ball.vy = 4; ball.update(0, 400, 600); expect(ball.x).toBe(103); expect(ball.y).toBe(104); }); it('does not move inactive ball', () => { const ball = new Ball(100, 100, 8); ball.vx = 3; ball.vy = 4; ball.active = false; ball.update(0, 400, 600); expect(ball.x).toBe(100); expect(ball.y).toBe(100); }); it('reflects off left wall', () => { const ball = new Ball(5, 100, 8); ball.vx = -10; ball.vy = 4; ball.update(0, 400, 600); expect(ball.x).toBe(8); // clamped to radius expect(ball.vx).toBe(10); // reflected }); it('reflects off right wall', () => { const ball = new Ball(395, 100, 8); ball.vx = 10; ball.vy = 4; ball.update(0, 400, 600); expect(ball.x).toBe(392); // clamped to boardWidth - radius expect(ball.vx).toBe(-10); // reflected }); it('reflects off top wall', () => { const ball = new Ball(100, 5, 8); ball.vx = 3; ball.vy = -10; ball.update(0, 400, 600); expect(ball.y).toBe(8); // clamped to radius expect(ball.vy).toBe(10); // reflected }); it('stops at bottom and becomes inactive', () => { const ball = new Ball(100, 590, 8); ball.vx = 3; ball.vy = 5; ball.update(0, 400, 600); expect(ball.active).toBe(false); expect(ball.y).toBe(592); // boardHeight - radius }); }); // ---- Property-Based Tests ---- describe('Property: reflect preserves speed magnitude', () => { /** * Feature: ball-block-breaker * **Validates: Requirements 4.3, 4.4, 4.5** * * reflect('x') negates vx only, reflect('y') negates vy only. * Speed magnitude is preserved. */ it('reflect(x) negates vx and preserves vy and speed magnitude', () => { fc.assert( fc.property( fc.float({ min: -100, max: 100, noNaN: true, noDefaultInfinity: true }), fc.float({ min: -100, max: 100, noNaN: true, noDefaultInfinity: true }), (vx, vy) => { const ball = new Ball(200, 200); ball.vx = vx; ball.vy = vy; const speedBefore = Math.sqrt(vx * vx + vy * vy); ball.reflect('x'); expect(ball.vx).toBe(-vx); expect(ball.vy).toBe(vy); const speedAfter = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy); expect(Math.abs(speedBefore - speedAfter)).toBeLessThan(0.001); } ), { numRuns: 100 } ); }); it('reflect(y) negates vy and preserves vx and speed magnitude', () => { fc.assert( fc.property( fc.float({ min: -100, max: 100, noNaN: true, noDefaultInfinity: true }), fc.float({ min: -100, max: 100, noNaN: true, noDefaultInfinity: true }), (vx, vy) => { const ball = new Ball(200, 200); ball.vx = vx; ball.vy = vy; const speedBefore = Math.sqrt(vx * vx + vy * vy); ball.reflect('y'); expect(ball.vx).toBe(vx); expect(ball.vy).toBe(-vy); const speedAfter = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy); expect(Math.abs(speedBefore - speedAfter)).toBeLessThan(0.001); } ), { numRuns: 100 } ); }); }); describe('Property: isAtBottom correctness', () => { /** * Feature: ball-block-breaker * **Validates: Requirements 3.3** * * isAtBottom returns true iff y + radius >= boardHeight */ it('isAtBottom returns true iff y + radius >= boardHeight', () => { fc.assert( fc.property( fc.float({ min: 0, max: 1000, noNaN: true, noDefaultInfinity: true }), fc.float({ min: 1, max: 50, noNaN: true, noDefaultInfinity: true }), fc.float({ min: 100, max: 1000, noNaN: true, noDefaultInfinity: true }), (y, radius, boardHeight) => { const ball = new Ball(100, y, radius); const expected = y + radius >= boardHeight; expect(ball.isAtBottom(boardHeight)).toBe(expected); } ), { numRuns: 100 } ); }); }); describe('Property: getRect consistency', () => { /** * Feature: ball-block-breaker * * getRect returns an AABB centered on (x, y) with side = 2 * radius */ it('getRect returns AABB centered on ball position with correct dimensions', () => { fc.assert( fc.property( fc.float({ min: 0, max: 1000, noNaN: true, noDefaultInfinity: true }), fc.float({ min: 0, max: 1000, noNaN: true, noDefaultInfinity: true }), fc.float({ min: 1, max: 50, noNaN: true, noDefaultInfinity: true }), (x, y, radius) => { const ball = new Ball(x, y, radius); const rect = ball.getRect(); expect(rect.x).toBeCloseTo(x - radius, 5); expect(rect.y).toBeCloseTo(y - radius, 5); expect(rect.width).toBeCloseTo(radius * 2, 5); expect(rect.height).toBeCloseTo(radius * 2, 5); } ), { numRuns: 100 } ); }); }); describe('Property 6: 球速恒定', () => { /** * Feature: ball-block-breaker, Property 6: 球速恒定 * **Validates: Requirements 3.5** * * 对于任意小球在任意帧更新后,其速度向量的大小(Math.sqrt(vx² + vy²)) * 应与初始速度大小相等(允许浮点误差 ε < 0.001)。 */ it('ball speed magnitude remains constant after multiple updates', () => { fc.assert( fc.property( fc.float({ min: -50, max: 50, noNaN: true, noDefaultInfinity: true }), fc.float({ min: -50, max: 50, noNaN: true, noDefaultInfinity: true }), fc.integer({ min: 1, max: 200 }), (vx, vy, numFrames) => { const initialSpeed = Math.sqrt(vx * vx + vy * vy); // Skip zero-speed cases fc.pre(initialSpeed > 0.001); // Place ball in center of a very large board so it won't reach the bottom const boardWidth = 100000; const boardHeight = 100000; const ball = new Ball(boardWidth / 2, boardHeight / 2, BALL_RADIUS); ball.vx = vx; ball.vy = vy; for (let i = 0; i < numFrames; i++) { ball.update(0, boardWidth, boardHeight); const currentSpeed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy); expect(Math.abs(currentSpeed - initialSpeed)).toBeLessThan(0.001); } } ), { numRuns: 100 } ); }); }); });