import { describe, it, expect } from 'vitest'; import fc from 'fast-check'; import { checkBallWallCollision, checkBallBlockCollision, checkBallItemCollision, resolveBallBlockCollision, reflectWall } from '../src/systems/Physics.js'; import { Ball } from '../src/entities/Ball.js'; import { Block } from '../src/entities/Block.js'; import { BallItem, LineClearItem } from '../src/entities/Item.js'; describe('Physics', () => { // ---- checkBallWallCollision ---- describe('checkBallWallCollision', () => { it('returns "left" when ball touches left wall', () => { const ball = { x: 8, y: 100, radius: 8 }; expect(checkBallWallCollision(ball, 400, 600)).toBe('left'); }); it('returns "right" when ball touches right wall', () => { const ball = { x: 392, y: 100, radius: 8 }; expect(checkBallWallCollision(ball, 400, 600)).toBe('right'); }); it('returns "top" when ball touches top wall', () => { const ball = { x: 200, y: 8, radius: 8 }; expect(checkBallWallCollision(ball, 400, 600)).toBe('top'); }); it('returns null when ball is in the middle', () => { const ball = { x: 200, y: 300, radius: 8 }; expect(checkBallWallCollision(ball, 400, 600)).toBeNull(); }); it('returns "left" when ball is past left wall', () => { const ball = { x: 3, y: 100, radius: 8 }; expect(checkBallWallCollision(ball, 400, 600)).toBe('left'); }); it('prioritizes left over top when in corner', () => { const ball = { x: 5, y: 5, radius: 8 }; expect(checkBallWallCollision(ball, 400, 600)).toBe('left'); }); }); // ---- checkBallBlockCollision ---- describe('checkBallBlockCollision', () => { it('returns hit:false when no overlap', () => { const ball = new Ball(50, 50, 8); const block = new Block(3, 3, 5, 40); // far away const result = checkBallBlockCollision(ball, block); expect(result.hit).toBe(false); expect(result.side).toBeNull(); }); it('detects top collision', () => { // Place ball just above the block, overlapping slightly from top const block = new Block(0, 1, 5, 40); const blockRect = block.getRect(); // Ball center just above block top, overlapping by 2px const ball = new Ball(blockRect.x + 20, blockRect.y - 6, 8); const result = checkBallBlockCollision(ball, block); expect(result.hit).toBe(true); expect(result.side).toBe('top'); }); it('detects bottom collision', () => { const block = new Block(0, 1, 5, 40); const blockRect = block.getRect(); // Ball center just below block bottom, overlapping by 2px const ball = new Ball(blockRect.x + 20, blockRect.y + blockRect.height + 6, 8); const result = checkBallBlockCollision(ball, block); expect(result.hit).toBe(true); expect(result.side).toBe('bottom'); }); it('detects left collision', () => { const block = new Block(2, 2, 5, 40); const blockRect = block.getRect(); // Ball center just left of block, overlapping by 2px const ball = new Ball(blockRect.x - 6, blockRect.y + 20, 8); const result = checkBallBlockCollision(ball, block); expect(result.hit).toBe(true); expect(result.side).toBe('left'); }); it('detects right collision', () => { const block = new Block(2, 2, 5, 40); const blockRect = block.getRect(); // Ball center just right of block, overlapping by 2px const ball = new Ball(blockRect.x + blockRect.width + 6, blockRect.y + 20, 8); const result = checkBallBlockCollision(ball, block); expect(result.hit).toBe(true); expect(result.side).toBe('right'); }); }); // ---- checkBallItemCollision ---- describe('checkBallItemCollision', () => { it('returns true when ball overlaps item', () => { const ball = new Ball(10, 10, 8); const item = new BallItem(0, 0, 40); expect(checkBallItemCollision(ball, item)).toBe(true); }); it('returns false when ball does not overlap item', () => { const ball = new Ball(200, 200, 8); const item = new BallItem(0, 0, 40); expect(checkBallItemCollision(ball, item)).toBe(false); }); // Feature: ball-block-breaker, Property 14: 道具碰撞不改变球方向 // **Validates: Requirements 6.2** it('item collision does not change ball velocity', () => { fc.assert( fc.property( fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }), fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }), fc.constantFrom('ball', 'lineClear'), (vx, vy, itemType) => { // Place ball at item center so it overlaps const item = itemType === 'ball' ? new BallItem(0, 0, 40) : new LineClearItem(0, 0, 40); const itemRect = item.getRect(); const ball = new Ball( itemRect.x + itemRect.width / 2, itemRect.y + itemRect.height / 2, 8 ); ball.vx = vx; ball.vy = vy; // Perform collision detection const collided = checkBallItemCollision(ball, item); // Collision should be detected expect(collided).toBe(true); // Ball velocity must remain unchanged expect(ball.vx).toBe(vx); expect(ball.vy).toBe(vy); } ), { numRuns: 100 } ); }); }); // ---- resolveBallBlockCollision ---- describe('resolveBallBlockCollision', () => { it('reflects y for top collision', () => { const ball = new Ball(100, 100, 8); ball.vx = 5; ball.vy = 3; resolveBallBlockCollision(ball, 'top'); expect(ball.vx).toBe(5); expect(ball.vy).toBe(-3); }); it('reflects y for bottom collision', () => { const ball = new Ball(100, 100, 8); ball.vx = 5; ball.vy = -3; resolveBallBlockCollision(ball, 'bottom'); expect(ball.vx).toBe(5); expect(ball.vy).toBe(3); }); it('reflects x for left collision', () => { const ball = new Ball(100, 100, 8); ball.vx = 5; ball.vy = 3; resolveBallBlockCollision(ball, 'left'); expect(ball.vx).toBe(-5); expect(ball.vy).toBe(3); }); it('reflects x for right collision', () => { const ball = new Ball(100, 100, 8); ball.vx = -5; ball.vy = 3; resolveBallBlockCollision(ball, 'right'); expect(ball.vx).toBe(5); expect(ball.vy).toBe(3); }); // Feature: ball-block-breaker, Property 8: 方块碰撞反弹正确性 // **Validates: Requirements 4.3, 4.4, 4.5** it('block collision reflects correct axis and preserves speed magnitude', () => { fc.assert( fc.property( fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }).filter(v => v !== 0), fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }).filter(v => v !== 0), fc.constantFrom('top', 'bottom', 'left', 'right'), (vx, vy, side) => { const ball = new Ball(100, 100, 8); ball.vx = vx; ball.vy = vy; const originalSpeed = Math.sqrt(vx * vx + vy * vy); resolveBallBlockCollision(ball, side); if (side === 'top' || side === 'bottom') { // vy should be negated, vx unchanged expect(ball.vy).toBe(-vy); expect(ball.vx).toBe(vx); } else { // left or right: vx should be negated, vy unchanged expect(ball.vx).toBe(-vx); expect(ball.vy).toBe(vy); } // Speed magnitude preserved const newSpeed = Math.sqrt(ball.vx ** 2 + ball.vy ** 2); expect(Math.abs(originalSpeed - newSpeed)).toBeLessThan(0.001); } ), { numRuns: 100 } ); }); }); // ---- reflectWall ---- describe('reflectWall', () => { it('negates vx for left wall', () => { const result = reflectWall({ vx: 5, vy: 3 }, 'left'); expect(result).toEqual({ vx: -5, vy: 3 }); }); it('negates vx for right wall', () => { const result = reflectWall({ vx: -5, vy: 3 }, 'right'); expect(result).toEqual({ vx: 5, vy: 3 }); }); it('negates vy for top wall', () => { const result = reflectWall({ vx: 5, vy: -3 }, 'top'); expect(result).toEqual({ vx: 5, vy: 3 }); }); it('returns unchanged velocity for unknown wall', () => { const result = reflectWall({ vx: 5, vy: 3 }, 'bottom'); expect(result).toEqual({ vx: 5, vy: 3 }); }); it('is a pure function - does not mutate input', () => { const velocity = { vx: 5, vy: 3 }; reflectWall(velocity, 'left'); expect(velocity).toEqual({ vx: 5, vy: 3 }); }); // Feature: ball-block-breaker, Property 3: 墙壁反射正确性 // **Validates: Requirements 3.2** it('wall reflection preserves speed magnitude', () => { fc.assert( fc.property( fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }), fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }), fc.constantFrom('left', 'right', 'top'), (vx, vy, wall) => { const reflected = reflectWall({ vx, vy }, wall); const originalSpeed = Math.sqrt(vx * vx + vy * vy); const newSpeed = Math.sqrt(reflected.vx ** 2 + reflected.vy ** 2); expect(Math.abs(originalSpeed - newSpeed)).toBeLessThan(0.001); } ), { numRuns: 100 } ); }); it('wall reflection negates correct component and preserves the other', () => { fc.assert( fc.property( fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }), fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }), fc.constantFrom('left', 'right', 'top'), (vx, vy, wall) => { const reflected = reflectWall({ vx, vy }, wall); if (wall === 'left' || wall === 'right') { expect(reflected.vx).toBe(-vx); expect(reflected.vy).toBe(vy); } else { // wall === 'top' expect(reflected.vx).toBe(vx); expect(reflected.vy).toBe(-vy); } } ), { numRuns: 100 } ); }); }); });