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