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