import { describe, it, expect } from 'vitest'; import * as fc from 'fast-check'; import { clampAngle, calculate } from '../src/systems/GuideLine.js'; import { Block } from '../src/entities/Block.js'; import { BALL_RADIUS } from '../src/constants.js'; describe('GuideLine', () => { // ---- clampAngle ---- describe('clampAngle', () => { it('returns 15 for angles below 15', () => { expect(clampAngle(0)).toBe(15); expect(clampAngle(10)).toBe(15); expect(clampAngle(-50)).toBe(15); }); it('returns 165 for angles above 165', () => { expect(clampAngle(170)).toBe(165); expect(clampAngle(180)).toBe(165); }); it('returns 15 for angles in 270-360 range', () => { expect(clampAngle(300)).toBe(15); expect(clampAngle(360)).toBe(15); }); it('returns the angle itself when within range', () => { expect(clampAngle(15)).toBe(15); expect(clampAngle(90)).toBe(90); expect(clampAngle(165)).toBe(165); expect(clampAngle(45)).toBe(45); }); }); // Feature: ball-block-breaker, Property 2: 角度钳制 // **Validates: Requirements 2.5** describe('Property 2: 角度钳制', () => { it('clamped angle is always within [15, 165] for any input', () => { fc.assert( fc.property( fc.double({ min: -1000, max: 1000, noNaN: true }), (angle) => { const result = clampAngle(angle); expect(result).toBeGreaterThanOrEqual(15); expect(result).toBeLessThanOrEqual(165); } ), { numRuns: 100 } ); }); }); // ---- calculate ---- describe('calculate', () => { const boardWidth = 400; const boardHeight = 600; it('returns 3 points for straight up (90°) hitting top wall', () => { const points = calculate(200, 600, 90, [], boardWidth, boardHeight); // Start -> top wall -> reflected back down expect(points.length).toBe(3); expect(points[0]).toEqual({ x: 200, y: 600 }); // Should hit top wall at x=200, y=BALL_RADIUS (offset by ball radius) expect(points[1].x).toBeCloseTo(200, 1); expect(points[1].y).toBeCloseTo(BALL_RADIUS, 1); }); it('returns 3 points when hitting left wall at angle < 90°', () => { // Angle 45° = up-left direction (reference convention: <90° goes left) const points = calculate(200, 600, 45, [], boardWidth, boardHeight); expect(points.length).toBe(3); expect(points[0]).toEqual({ x: 200, y: 600 }); // Should hit left wall (x=BALL_RADIUS) expect(points[1].x).toBeCloseTo(BALL_RADIUS, 0); }); it('returns 3 points when hitting right wall at angle > 90°', () => { // Angle 135° = up-right direction (reference convention: >90° goes right) const points = calculate(200, 600, 135, [], boardWidth, boardHeight); expect(points.length).toBe(3); expect(points[0]).toEqual({ x: 200, y: 600 }); // Should hit right wall (x=boardWidth - BALL_RADIUS) expect(points[1].x).toBeCloseTo(boardWidth - BALL_RADIUS, 0); }); it('first point is always the start point', () => { const points = calculate(100, 500, 90, [], boardWidth, boardHeight); expect(points[0]).toEqual({ x: 100, y: 500 }); }); it('returns at most 3 points', () => { const points = calculate(200, 600, 60, [], boardWidth, boardHeight); expect(points.length).toBeLessThanOrEqual(3); expect(points.length).toBeGreaterThanOrEqual(2); }); it('clamps angle before calculating', () => { // Angle 5° should be clamped to 15° const pointsClamped = calculate(200, 600, 5, [], boardWidth, boardHeight); const pointsAt15 = calculate(200, 600, 15, [], boardWidth, boardHeight); expect(pointsClamped[1].x).toBeCloseTo(pointsAt15[1].x, 1); expect(pointsClamped[1].y).toBeCloseTo(pointsAt15[1].y, 1); }); it('handles block collision and reflects', () => { // Place a block directly above the start point const block = new Block(3, 5, 5, 50); const blockRect = block.getRect(); // Start below the block, shoot straight up const startX = blockRect.x + blockRect.width / 2; const startY = blockRect.y + blockRect.height + 100; const points = calculate(startX, startY, 90, [block], boardWidth, boardHeight); expect(points.length).toBe(3); // First hit should be near the block's bottom edge + ball radius expect(points[1].y).toBeCloseTo(blockRect.y + blockRect.height + BALL_RADIUS, 1); }); it('reflects off left wall and continues', () => { // Shoot at 45° (up-left in reference convention) from center const points = calculate(200, 600, 45, [], boardWidth, boardHeight); expect(points.length).toBe(3); // First hit: left wall (offset by ball radius) expect(points[1].x).toBeCloseTo(BALL_RADIUS, 0); // After reflection off left wall, dx flips, so second segment goes right expect(points[2].x).toBeGreaterThan(BALL_RADIUS); }); it('reflects off right wall and continues', () => { // Shoot at 135° (up-right in reference convention) from center const points = calculate(200, 600, 135, [], boardWidth, boardHeight); expect(points.length).toBe(3); // First hit: right wall (offset by ball radius) expect(points[1].x).toBeCloseTo(boardWidth - BALL_RADIUS, 0); // After reflection off right wall, dx flips, so second segment goes left expect(points[2].x).toBeLessThan(boardWidth - BALL_RADIUS); }); it('reflects off top wall and continues downward', () => { // Shoot straight up const points = calculate(200, 600, 90, [], boardWidth, boardHeight); expect(points.length).toBe(3); // First hit: top wall (offset by ball radius) expect(points[1].y).toBeCloseTo(BALL_RADIUS, 1); // After reflection off top, dy flips, so second segment goes down expect(points[2].y).toBeGreaterThan(BALL_RADIUS); }); }); }); // Feature: ball-block-breaker, Property 1: 参考线最多一次折射 // **Validates: Requirements 2.2, 2.3, 2.4** describe('Property 1: 参考线最多一次折射', () => { const boardWidth = 400; const boardHeight = 600; const blockSize = 50; const blockArb = fc.record({ gridX: fc.integer({ min: 0, max: 6 }), gridY: fc.integer({ min: 0, max: 8 }), count: fc.integer({ min: 1, max: 100 }), }); const blocksArb = fc.array(blockArb, { minLength: 0, maxLength: 5 }); it('path has at most 3 points and at least 2 points for any angle and block layout', () => { fc.assert( fc.property( fc.double({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }), fc.double({ min: 1, max: boardWidth - 1, noNaN: true, noDefaultInfinity: true }), fc.double({ min: 1, max: boardHeight - 1, noNaN: true, noDefaultInfinity: true }), blocksArb, (angle, startX, startY, blockDefs) => { const blocks = blockDefs.map(b => new Block(b.gridX, b.gridY, b.count, blockSize)); const points = calculate(startX, startY, angle, blocks, boardWidth, boardHeight); // Path must have 2-3 points (at most one refraction) expect(points.length).toBeGreaterThanOrEqual(2); expect(points.length).toBeLessThanOrEqual(3); // First point must equal start position expect(points[0].x).toBeCloseTo(startX, 5); expect(points[0].y).toBeCloseTo(startY, 5); } ), { numRuns: 100 } ); }); });