ball.test.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import * as fc from 'fast-check';
  2. import { describe, it, expect } from 'vitest';
  3. import { Ball } from '../src/entities/Ball.js';
  4. import { BALL_RADIUS, BALL_COLOR, BALL_SPEED } from '../src/constants.js';
  5. describe('Ball', () => {
  6. // ---- Unit Tests ----
  7. describe('constructor', () => {
  8. it('initializes with given position, radius, and color', () => {
  9. const ball = new Ball(100, 200, 10, '#ff0000');
  10. expect(ball.x).toBe(100);
  11. expect(ball.y).toBe(200);
  12. expect(ball.radius).toBe(10);
  13. expect(ball.color).toBe('#ff0000');
  14. expect(ball.vx).toBe(0);
  15. expect(ball.vy).toBe(0);
  16. expect(ball.active).toBe(true);
  17. });
  18. it('uses default radius and color when not provided', () => {
  19. const ball = new Ball(50, 60);
  20. expect(ball.radius).toBe(BALL_RADIUS);
  21. expect(ball.color).toBe(BALL_COLOR);
  22. });
  23. });
  24. describe('reflect', () => {
  25. it('negates vx when axis is x', () => {
  26. const ball = new Ball(100, 100);
  27. ball.vx = 5;
  28. ball.vy = -3;
  29. ball.reflect('x');
  30. expect(ball.vx).toBe(-5);
  31. expect(ball.vy).toBe(-3);
  32. });
  33. it('negates vy when axis is y', () => {
  34. const ball = new Ball(100, 100);
  35. ball.vx = 5;
  36. ball.vy = -3;
  37. ball.reflect('y');
  38. expect(ball.vx).toBe(5);
  39. expect(ball.vy).toBe(3);
  40. });
  41. it('does nothing for invalid axis', () => {
  42. const ball = new Ball(100, 100);
  43. ball.vx = 5;
  44. ball.vy = -3;
  45. ball.reflect('z');
  46. expect(ball.vx).toBe(5);
  47. expect(ball.vy).toBe(-3);
  48. });
  49. });
  50. describe('isAtBottom', () => {
  51. it('returns true when ball touches bottom', () => {
  52. const ball = new Ball(100, 492, 8);
  53. expect(ball.isAtBottom(500)).toBe(true);
  54. });
  55. it('returns true when ball is past bottom', () => {
  56. const ball = new Ball(100, 510, 8);
  57. expect(ball.isAtBottom(500)).toBe(true);
  58. });
  59. it('returns false when ball is above bottom', () => {
  60. const ball = new Ball(100, 400, 8);
  61. expect(ball.isAtBottom(500)).toBe(false);
  62. });
  63. });
  64. describe('getRect', () => {
  65. it('returns correct AABB rectangle', () => {
  66. const ball = new Ball(100, 200, 10);
  67. const rect = ball.getRect();
  68. expect(rect).toEqual({ x: 90, y: 190, width: 20, height: 20 });
  69. });
  70. });
  71. describe('update', () => {
  72. it('moves ball by vx and vy each frame', () => {
  73. const ball = new Ball(100, 100, 8);
  74. ball.vx = 3;
  75. ball.vy = 4;
  76. ball.update(0, 400, 600);
  77. expect(ball.x).toBe(103);
  78. expect(ball.y).toBe(104);
  79. });
  80. it('does not move inactive ball', () => {
  81. const ball = new Ball(100, 100, 8);
  82. ball.vx = 3;
  83. ball.vy = 4;
  84. ball.active = false;
  85. ball.update(0, 400, 600);
  86. expect(ball.x).toBe(100);
  87. expect(ball.y).toBe(100);
  88. });
  89. it('reflects off left wall', () => {
  90. const ball = new Ball(5, 100, 8);
  91. ball.vx = -10;
  92. ball.vy = 4;
  93. ball.update(0, 400, 600);
  94. expect(ball.x).toBe(8); // clamped to radius
  95. expect(ball.vx).toBe(10); // reflected
  96. });
  97. it('reflects off right wall', () => {
  98. const ball = new Ball(395, 100, 8);
  99. ball.vx = 10;
  100. ball.vy = 4;
  101. ball.update(0, 400, 600);
  102. expect(ball.x).toBe(392); // clamped to boardWidth - radius
  103. expect(ball.vx).toBe(-10); // reflected
  104. });
  105. it('reflects off top wall', () => {
  106. const ball = new Ball(100, 5, 8);
  107. ball.vx = 3;
  108. ball.vy = -10;
  109. ball.update(0, 400, 600);
  110. expect(ball.y).toBe(8); // clamped to radius
  111. expect(ball.vy).toBe(10); // reflected
  112. });
  113. it('stops at bottom and becomes inactive', () => {
  114. const ball = new Ball(100, 590, 8);
  115. ball.vx = 3;
  116. ball.vy = 5;
  117. ball.update(0, 400, 600);
  118. expect(ball.active).toBe(false);
  119. expect(ball.y).toBe(592); // boardHeight - radius
  120. });
  121. });
  122. // ---- Property-Based Tests ----
  123. describe('Property: reflect preserves speed magnitude', () => {
  124. /**
  125. * Feature: ball-block-breaker
  126. * **Validates: Requirements 4.3, 4.4, 4.5**
  127. *
  128. * reflect('x') negates vx only, reflect('y') negates vy only.
  129. * Speed magnitude is preserved.
  130. */
  131. it('reflect(x) negates vx and preserves vy and speed magnitude', () => {
  132. fc.assert(
  133. fc.property(
  134. fc.float({ min: -100, max: 100, noNaN: true, noDefaultInfinity: true }),
  135. fc.float({ min: -100, max: 100, noNaN: true, noDefaultInfinity: true }),
  136. (vx, vy) => {
  137. const ball = new Ball(200, 200);
  138. ball.vx = vx;
  139. ball.vy = vy;
  140. const speedBefore = Math.sqrt(vx * vx + vy * vy);
  141. ball.reflect('x');
  142. expect(ball.vx).toBe(-vx);
  143. expect(ball.vy).toBe(vy);
  144. const speedAfter = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
  145. expect(Math.abs(speedBefore - speedAfter)).toBeLessThan(0.001);
  146. }
  147. ),
  148. { numRuns: 100 }
  149. );
  150. });
  151. it('reflect(y) negates vy and preserves vx and speed magnitude', () => {
  152. fc.assert(
  153. fc.property(
  154. fc.float({ min: -100, max: 100, noNaN: true, noDefaultInfinity: true }),
  155. fc.float({ min: -100, max: 100, noNaN: true, noDefaultInfinity: true }),
  156. (vx, vy) => {
  157. const ball = new Ball(200, 200);
  158. ball.vx = vx;
  159. ball.vy = vy;
  160. const speedBefore = Math.sqrt(vx * vx + vy * vy);
  161. ball.reflect('y');
  162. expect(ball.vx).toBe(vx);
  163. expect(ball.vy).toBe(-vy);
  164. const speedAfter = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
  165. expect(Math.abs(speedBefore - speedAfter)).toBeLessThan(0.001);
  166. }
  167. ),
  168. { numRuns: 100 }
  169. );
  170. });
  171. });
  172. describe('Property: isAtBottom correctness', () => {
  173. /**
  174. * Feature: ball-block-breaker
  175. * **Validates: Requirements 3.3**
  176. *
  177. * isAtBottom returns true iff y + radius >= boardHeight
  178. */
  179. it('isAtBottom returns true iff y + radius >= boardHeight', () => {
  180. fc.assert(
  181. fc.property(
  182. fc.float({ min: 0, max: 1000, noNaN: true, noDefaultInfinity: true }),
  183. fc.float({ min: 1, max: 50, noNaN: true, noDefaultInfinity: true }),
  184. fc.float({ min: 100, max: 1000, noNaN: true, noDefaultInfinity: true }),
  185. (y, radius, boardHeight) => {
  186. const ball = new Ball(100, y, radius);
  187. const expected = y + radius >= boardHeight;
  188. expect(ball.isAtBottom(boardHeight)).toBe(expected);
  189. }
  190. ),
  191. { numRuns: 100 }
  192. );
  193. });
  194. });
  195. describe('Property: getRect consistency', () => {
  196. /**
  197. * Feature: ball-block-breaker
  198. *
  199. * getRect returns an AABB centered on (x, y) with side = 2 * radius
  200. */
  201. it('getRect returns AABB centered on ball position with correct dimensions', () => {
  202. fc.assert(
  203. fc.property(
  204. fc.float({ min: 0, max: 1000, noNaN: true, noDefaultInfinity: true }),
  205. fc.float({ min: 0, max: 1000, noNaN: true, noDefaultInfinity: true }),
  206. fc.float({ min: 1, max: 50, noNaN: true, noDefaultInfinity: true }),
  207. (x, y, radius) => {
  208. const ball = new Ball(x, y, radius);
  209. const rect = ball.getRect();
  210. expect(rect.x).toBeCloseTo(x - radius, 5);
  211. expect(rect.y).toBeCloseTo(y - radius, 5);
  212. expect(rect.width).toBeCloseTo(radius * 2, 5);
  213. expect(rect.height).toBeCloseTo(radius * 2, 5);
  214. }
  215. ),
  216. { numRuns: 100 }
  217. );
  218. });
  219. });
  220. describe('Property 6: 球速恒定', () => {
  221. /**
  222. * Feature: ball-block-breaker, Property 6: 球速恒定
  223. * **Validates: Requirements 3.5**
  224. *
  225. * 对于任意小球在任意帧更新后,其速度向量的大小(Math.sqrt(vx² + vy²))
  226. * 应与初始速度大小相等(允许浮点误差 ε < 0.001)。
  227. */
  228. it('ball speed magnitude remains constant after multiple updates', () => {
  229. fc.assert(
  230. fc.property(
  231. fc.float({ min: -50, max: 50, noNaN: true, noDefaultInfinity: true }),
  232. fc.float({ min: -50, max: 50, noNaN: true, noDefaultInfinity: true }),
  233. fc.integer({ min: 1, max: 200 }),
  234. (vx, vy, numFrames) => {
  235. const initialSpeed = Math.sqrt(vx * vx + vy * vy);
  236. // Skip zero-speed cases
  237. fc.pre(initialSpeed > 0.001);
  238. // Place ball in center of a very large board so it won't reach the bottom
  239. const boardWidth = 100000;
  240. const boardHeight = 100000;
  241. const ball = new Ball(boardWidth / 2, boardHeight / 2, BALL_RADIUS);
  242. ball.vx = vx;
  243. ball.vy = vy;
  244. for (let i = 0; i < numFrames; i++) {
  245. ball.update(0, boardWidth, boardHeight);
  246. const currentSpeed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
  247. expect(Math.abs(currentSpeed - initialSpeed)).toBeLessThan(0.001);
  248. }
  249. }
  250. ),
  251. { numRuns: 100 }
  252. );
  253. });
  254. });
  255. });