guideline.test.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. import { describe, it, expect } from 'vitest';
  2. import * as fc from 'fast-check';
  3. import { clampAngle, calculate } from '../src/systems/GuideLine.js';
  4. import { Block } from '../src/entities/Block.js';
  5. import { BALL_RADIUS } from '../src/constants.js';
  6. describe('GuideLine', () => {
  7. // ---- clampAngle ----
  8. describe('clampAngle', () => {
  9. it('returns 15 for angles below 15', () => {
  10. expect(clampAngle(0)).toBe(15);
  11. expect(clampAngle(10)).toBe(15);
  12. expect(clampAngle(-50)).toBe(15);
  13. });
  14. it('returns 165 for angles above 165', () => {
  15. expect(clampAngle(170)).toBe(165);
  16. expect(clampAngle(180)).toBe(165);
  17. });
  18. it('returns 15 for angles in 270-360 range', () => {
  19. expect(clampAngle(300)).toBe(15);
  20. expect(clampAngle(360)).toBe(15);
  21. });
  22. it('returns the angle itself when within range', () => {
  23. expect(clampAngle(15)).toBe(15);
  24. expect(clampAngle(90)).toBe(90);
  25. expect(clampAngle(165)).toBe(165);
  26. expect(clampAngle(45)).toBe(45);
  27. });
  28. });
  29. // Feature: ball-block-breaker, Property 2: 角度钳制
  30. // **Validates: Requirements 2.5**
  31. describe('Property 2: 角度钳制', () => {
  32. it('clamped angle is always within [15, 165] for any input', () => {
  33. fc.assert(
  34. fc.property(
  35. fc.double({ min: -1000, max: 1000, noNaN: true }),
  36. (angle) => {
  37. const result = clampAngle(angle);
  38. expect(result).toBeGreaterThanOrEqual(15);
  39. expect(result).toBeLessThanOrEqual(165);
  40. }
  41. ),
  42. { numRuns: 100 }
  43. );
  44. });
  45. });
  46. // ---- calculate ----
  47. describe('calculate', () => {
  48. const boardWidth = 400;
  49. const boardHeight = 600;
  50. it('returns 3 points for straight up (90°) hitting top wall', () => {
  51. const points = calculate(200, 600, 90, [], boardWidth, boardHeight);
  52. // Start -> top wall -> reflected back down
  53. expect(points.length).toBe(3);
  54. expect(points[0]).toEqual({ x: 200, y: 600 });
  55. // Should hit top wall at x=200, y=BALL_RADIUS (offset by ball radius)
  56. expect(points[1].x).toBeCloseTo(200, 1);
  57. expect(points[1].y).toBeCloseTo(BALL_RADIUS, 1);
  58. });
  59. it('returns 3 points when hitting left wall at angle < 90°', () => {
  60. // Angle 45° = up-left direction (reference convention: <90° goes left)
  61. const points = calculate(200, 600, 45, [], boardWidth, boardHeight);
  62. expect(points.length).toBe(3);
  63. expect(points[0]).toEqual({ x: 200, y: 600 });
  64. // Should hit left wall (x=BALL_RADIUS)
  65. expect(points[1].x).toBeCloseTo(BALL_RADIUS, 0);
  66. });
  67. it('returns 3 points when hitting right wall at angle > 90°', () => {
  68. // Angle 135° = up-right direction (reference convention: >90° goes right)
  69. const points = calculate(200, 600, 135, [], boardWidth, boardHeight);
  70. expect(points.length).toBe(3);
  71. expect(points[0]).toEqual({ x: 200, y: 600 });
  72. // Should hit right wall (x=boardWidth - BALL_RADIUS)
  73. expect(points[1].x).toBeCloseTo(boardWidth - BALL_RADIUS, 0);
  74. });
  75. it('first point is always the start point', () => {
  76. const points = calculate(100, 500, 90, [], boardWidth, boardHeight);
  77. expect(points[0]).toEqual({ x: 100, y: 500 });
  78. });
  79. it('returns at most 3 points', () => {
  80. const points = calculate(200, 600, 60, [], boardWidth, boardHeight);
  81. expect(points.length).toBeLessThanOrEqual(3);
  82. expect(points.length).toBeGreaterThanOrEqual(2);
  83. });
  84. it('clamps angle before calculating', () => {
  85. // Angle 5° should be clamped to 15°
  86. const pointsClamped = calculate(200, 600, 5, [], boardWidth, boardHeight);
  87. const pointsAt15 = calculate(200, 600, 15, [], boardWidth, boardHeight);
  88. expect(pointsClamped[1].x).toBeCloseTo(pointsAt15[1].x, 1);
  89. expect(pointsClamped[1].y).toBeCloseTo(pointsAt15[1].y, 1);
  90. });
  91. it('handles block collision and reflects', () => {
  92. // Place a block directly above the start point
  93. const block = new Block(3, 5, 5, 50);
  94. const blockRect = block.getRect();
  95. // Start below the block, shoot straight up
  96. const startX = blockRect.x + blockRect.width / 2;
  97. const startY = blockRect.y + blockRect.height + 100;
  98. const points = calculate(startX, startY, 90, [block], boardWidth, boardHeight);
  99. expect(points.length).toBe(3);
  100. // First hit should be near the block's bottom edge + ball radius
  101. expect(points[1].y).toBeCloseTo(blockRect.y + blockRect.height + BALL_RADIUS, 1);
  102. });
  103. it('reflects off left wall and continues', () => {
  104. // Shoot at 45° (up-left in reference convention) from center
  105. const points = calculate(200, 600, 45, [], boardWidth, boardHeight);
  106. expect(points.length).toBe(3);
  107. // First hit: left wall (offset by ball radius)
  108. expect(points[1].x).toBeCloseTo(BALL_RADIUS, 0);
  109. // After reflection off left wall, dx flips, so second segment goes right
  110. expect(points[2].x).toBeGreaterThan(BALL_RADIUS);
  111. });
  112. it('reflects off right wall and continues', () => {
  113. // Shoot at 135° (up-right in reference convention) from center
  114. const points = calculate(200, 600, 135, [], boardWidth, boardHeight);
  115. expect(points.length).toBe(3);
  116. // First hit: right wall (offset by ball radius)
  117. expect(points[1].x).toBeCloseTo(boardWidth - BALL_RADIUS, 0);
  118. // After reflection off right wall, dx flips, so second segment goes left
  119. expect(points[2].x).toBeLessThan(boardWidth - BALL_RADIUS);
  120. });
  121. it('reflects off top wall and continues downward', () => {
  122. // Shoot straight up
  123. const points = calculate(200, 600, 90, [], boardWidth, boardHeight);
  124. expect(points.length).toBe(3);
  125. // First hit: top wall (offset by ball radius)
  126. expect(points[1].y).toBeCloseTo(BALL_RADIUS, 1);
  127. // After reflection off top, dy flips, so second segment goes down
  128. expect(points[2].y).toBeGreaterThan(BALL_RADIUS);
  129. });
  130. });
  131. });
  132. // Feature: ball-block-breaker, Property 1: 参考线最多一次折射
  133. // **Validates: Requirements 2.2, 2.3, 2.4**
  134. describe('Property 1: 参考线最多一次折射', () => {
  135. const boardWidth = 400;
  136. const boardHeight = 600;
  137. const blockSize = 50;
  138. const blockArb = fc.record({
  139. gridX: fc.integer({ min: 0, max: 6 }),
  140. gridY: fc.integer({ min: 0, max: 8 }),
  141. count: fc.integer({ min: 1, max: 100 }),
  142. });
  143. const blocksArb = fc.array(blockArb, { minLength: 0, maxLength: 5 });
  144. it('path has at most 3 points and at least 2 points for any angle and block layout', () => {
  145. fc.assert(
  146. fc.property(
  147. fc.double({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
  148. fc.double({ min: 1, max: boardWidth - 1, noNaN: true, noDefaultInfinity: true }),
  149. fc.double({ min: 1, max: boardHeight - 1, noNaN: true, noDefaultInfinity: true }),
  150. blocksArb,
  151. (angle, startX, startY, blockDefs) => {
  152. const blocks = blockDefs.map(b => new Block(b.gridX, b.gridY, b.count, blockSize));
  153. const points = calculate(startX, startY, angle, blocks, boardWidth, boardHeight);
  154. // Path must have 2-3 points (at most one refraction)
  155. expect(points.length).toBeGreaterThanOrEqual(2);
  156. expect(points.length).toBeLessThanOrEqual(3);
  157. // First point must equal start position
  158. expect(points[0].x).toBeCloseTo(startX, 5);
  159. expect(points[0].y).toBeCloseTo(startY, 5);
  160. }
  161. ),
  162. { numRuns: 100 }
  163. );
  164. });
  165. });