boardmanager.test.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. import { describe, it, expect, beforeEach } from 'vitest';
  2. import fc from 'fast-check';
  3. import { BoardManager } from '../src/systems/BoardManager.js';
  4. import { Block } from '../src/entities/Block.js';
  5. import { BallItem, LineClearItem } from '../src/entities/Item.js';
  6. import { GameMode, TOP_PADDING_ROWS } from '../src/constants.js';
  7. describe('BoardManager', () => {
  8. const COLS = 7;
  9. const ROWS = 10;
  10. const BLOCK_SIZE = 40;
  11. let bm;
  12. beforeEach(() => {
  13. bm = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  14. });
  15. describe('constructor', () => {
  16. it('initializes with empty blocks and items', () => {
  17. expect(bm.getBlocks()).toEqual([]);
  18. expect(bm.getItems()).toEqual([]);
  19. expect(bm.cols).toBe(COLS);
  20. expect(bm.rows).toBe(ROWS);
  21. expect(bm.blockSize).toBe(BLOCK_SIZE);
  22. });
  23. });
  24. describe('generateRow', () => {
  25. it('generates at least one block per row', () => {
  26. for (let i = 0; i < 50; i++) {
  27. const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  28. bm2.generateRow(1, GameMode.CLASSIC);
  29. expect(bm2.getBlocks().length).toBeGreaterThanOrEqual(1);
  30. }
  31. });
  32. it('all new blocks have gridY = TOP_PADDING_ROWS', () => {
  33. bm.generateRow(5, GameMode.CLASSIC);
  34. for (const block of bm.getBlocks()) {
  35. expect(block.gridY).toBe(TOP_PADDING_ROWS);
  36. }
  37. });
  38. it('all new items have gridY = TOP_PADDING_ROWS', () => {
  39. // Run multiple times to increase chance of item generation
  40. for (let i = 0; i < 30; i++) {
  41. const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  42. bm2.generateRow(2, GameMode.CLASSIC); // round > 1 to generate BallItem
  43. for (const item of bm2.getItems()) {
  44. expect(item.gridY).toBe(TOP_PADDING_ROWS);
  45. }
  46. }
  47. });
  48. it('block count equals round or round*2', () => {
  49. const round = 7;
  50. for (let i = 0; i < 30; i++) {
  51. const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  52. bm2.generateRow(round, GameMode.CLASSIC);
  53. for (const block of bm2.getBlocks()) {
  54. expect([round, round * 2]).toContain(block.count);
  55. }
  56. }
  57. });
  58. it('classic mode only generates BallItem, no LineClearItem', () => {
  59. for (let i = 0; i < 50; i++) {
  60. const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  61. bm2.generateRow(1, GameMode.CLASSIC);
  62. for (const item of bm2.getItems()) {
  63. expect(item).toBeInstanceOf(BallItem);
  64. }
  65. }
  66. });
  67. it('block gridX is within valid range', () => {
  68. bm.generateRow(3, GameMode.CLASSIC);
  69. for (const block of bm.getBlocks()) {
  70. expect(block.gridX).toBeGreaterThanOrEqual(0);
  71. expect(block.gridX).toBeLessThan(COLS);
  72. }
  73. });
  74. it('items are not placed in columns with blocks', () => {
  75. for (let i = 0; i < 50; i++) {
  76. const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  77. bm2.generateRow(1, GameMode.ENHANCED);
  78. const blockCols = new Set(bm2.getBlocks().map(b => b.gridX));
  79. for (const item of bm2.getItems()) {
  80. expect(blockCols.has(item.gridX)).toBe(false);
  81. }
  82. }
  83. });
  84. });
  85. describe('moveAllDown', () => {
  86. it('moves all blocks down by one row', () => {
  87. bm.blocks = [
  88. new Block(0, 0, 1, BLOCK_SIZE),
  89. new Block(3, 2, 5, BLOCK_SIZE),
  90. ];
  91. bm.moveAllDown();
  92. expect(bm.blocks[0].gridY).toBe(1);
  93. expect(bm.blocks[1].gridY).toBe(3);
  94. });
  95. it('moves all items down by one row', () => {
  96. bm.items = [
  97. new BallItem(1, 0, BLOCK_SIZE),
  98. new LineClearItem(4, 3, BLOCK_SIZE),
  99. ];
  100. bm.moveAllDown();
  101. expect(bm.items[0].gridY).toBe(1);
  102. expect(bm.items[1].gridY).toBe(4);
  103. });
  104. });
  105. describe('checkGameOver', () => {
  106. it('returns false when no blocks touch bottom', () => {
  107. bm.blocks = [
  108. new Block(0, 0, 1, BLOCK_SIZE),
  109. new Block(3, ROWS - 1, 5, BLOCK_SIZE),
  110. ];
  111. expect(bm.checkGameOver()).toBe(false);
  112. });
  113. it('returns true when a block reaches rows', () => {
  114. bm.blocks = [
  115. new Block(0, 0, 1, BLOCK_SIZE),
  116. new Block(3, ROWS, 5, BLOCK_SIZE),
  117. ];
  118. expect(bm.checkGameOver()).toBe(true);
  119. });
  120. it('returns true when a block exceeds rows', () => {
  121. bm.blocks = [new Block(0, ROWS + 2, 1, BLOCK_SIZE)];
  122. expect(bm.checkGameOver()).toBe(true);
  123. });
  124. it('returns false with empty board', () => {
  125. expect(bm.checkGameOver()).toBe(false);
  126. });
  127. });
  128. describe('removeBlock', () => {
  129. it('removes the specified block', () => {
  130. const b1 = new Block(0, 0, 1, BLOCK_SIZE);
  131. const b2 = new Block(1, 0, 2, BLOCK_SIZE);
  132. bm.blocks = [b1, b2];
  133. bm.removeBlock(b1);
  134. expect(bm.getBlocks()).toEqual([b2]);
  135. });
  136. it('does nothing if block not found', () => {
  137. const b1 = new Block(0, 0, 1, BLOCK_SIZE);
  138. bm.blocks = [b1];
  139. bm.removeBlock(new Block(2, 2, 3, BLOCK_SIZE));
  140. expect(bm.getBlocks()).toEqual([b1]);
  141. });
  142. });
  143. describe('clearRow', () => {
  144. it('decrements count of all blocks in the specified row, removes destroyed ones, returns destroyed', () => {
  145. bm.blocks = [
  146. new Block(0, 2, 1, BLOCK_SIZE), // count=1, will be destroyed
  147. new Block(3, 2, 5, BLOCK_SIZE), // count=5, becomes 4
  148. new Block(5, 4, 3, BLOCK_SIZE), // different row, untouched
  149. ];
  150. const destroyed = bm.clearRow(2);
  151. expect(destroyed.length).toBe(1);
  152. expect(destroyed[0].gridX).toBe(0);
  153. expect(bm.getBlocks().length).toBe(2);
  154. const row2Block = bm.getBlocks().find(b => b.gridY === 2);
  155. expect(row2Block.count).toBe(4);
  156. const row4Block = bm.getBlocks().find(b => b.gridY === 4);
  157. expect(row4Block.count).toBe(3);
  158. });
  159. });
  160. describe('clearColumn', () => {
  161. it('decrements count of all blocks in the specified column, removes destroyed ones, returns destroyed', () => {
  162. bm.blocks = [
  163. new Block(3, 0, 1, BLOCK_SIZE), // count=1, will be destroyed
  164. new Block(3, 2, 5, BLOCK_SIZE), // count=5, becomes 4
  165. new Block(5, 4, 3, BLOCK_SIZE), // different column, untouched
  166. ];
  167. const destroyed = bm.clearColumn(3);
  168. expect(destroyed.length).toBe(1);
  169. expect(destroyed[0].gridY).toBe(0);
  170. expect(bm.getBlocks().length).toBe(2);
  171. const col3Block = bm.getBlocks().find(b => b.gridX === 3);
  172. expect(col3Block.count).toBe(4);
  173. const col5Block = bm.getBlocks().find(b => b.gridX === 5);
  174. expect(col5Block.count).toBe(3);
  175. });
  176. });
  177. // Feature: ball-block-breaker, Property 10: 新行方块位置有效
  178. // **Validates: Requirements 5.2**
  179. describe('Property 10: generated row blocks have valid positions', () => {
  180. it('all blocks have gridY === TOP_PADDING_ROWS, gridX in [0, cols-1], and at least 1 block', () => {
  181. fc.assert(
  182. fc.property(
  183. fc.integer({ min: 1, max: 1000 }),
  184. fc.constantFrom(GameMode.CLASSIC, GameMode.ENHANCED),
  185. (round, mode) => {
  186. const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  187. bm2.generateRow(round, mode);
  188. const blocks = bm2.getBlocks();
  189. // At least one block generated
  190. expect(blocks.length).toBeGreaterThanOrEqual(1);
  191. for (const block of blocks) {
  192. // All blocks at TOP_PADDING_ROWS
  193. expect(block.gridY).toBe(TOP_PADDING_ROWS);
  194. // gridX within valid range [0, cols-1]
  195. expect(block.gridX).toBeGreaterThanOrEqual(0);
  196. expect(block.gridX).toBeLessThanOrEqual(COLS - 1);
  197. }
  198. }
  199. ),
  200. { numRuns: 100 }
  201. );
  202. });
  203. });
  204. // Feature: ball-block-breaker, Property 11: 新方块数字有效
  205. // **Validates: Requirements 5.3, 5.4**
  206. describe('Property 11: new block count is round or round*2', () => {
  207. it('every generated block count is either round or round*2', () => {
  208. fc.assert(
  209. fc.property(
  210. fc.integer({ min: 1, max: 1000 }),
  211. fc.constantFrom(GameMode.CLASSIC, GameMode.ENHANCED),
  212. (round, mode) => {
  213. const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  214. bm2.generateRow(round, mode);
  215. const blocks = bm2.getBlocks();
  216. for (const block of blocks) {
  217. expect([round, round * 2]).toContain(block.count);
  218. }
  219. }
  220. ),
  221. { numRuns: 100 }
  222. );
  223. });
  224. });
  225. // Feature: ball-block-breaker, Property 9: 方块下移一行
  226. // **Validates: Requirements 5.1**
  227. describe('Property 9: moveAllDown increments gridY by 1', () => {
  228. it('every block gridY increases by exactly 1 after moveAllDown', () => {
  229. fc.assert(
  230. fc.property(
  231. fc.array(
  232. fc.record({
  233. gridX: fc.integer({ min: 0, max: COLS - 1 }),
  234. gridY: fc.integer({ min: 0, max: ROWS - 1 }),
  235. count: fc.integer({ min: 1, max: 100 }),
  236. }),
  237. { minLength: 1, maxLength: 20 }
  238. ),
  239. (blockDefs) => {
  240. const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  241. bm2.blocks = blockDefs.map(
  242. (b) => new Block(b.gridX, b.gridY, b.count, BLOCK_SIZE)
  243. );
  244. const originalYs = bm2.blocks.map((b) => b.gridY);
  245. bm2.moveAllDown();
  246. for (let i = 0; i < bm2.blocks.length; i++) {
  247. expect(bm2.blocks[i].gridY).toBe(originalYs[i] + 1);
  248. }
  249. }
  250. ),
  251. { numRuns: 100 }
  252. );
  253. });
  254. });
  255. // Feature: ball-block-breaker, Property 12: 道具生成符合模式规则
  256. // **Validates: Requirements 5.5, 5.6**
  257. describe('Property 12: item generation follows mode rules', () => {
  258. it('CLASSIC mode only generates BallItem, never LineClearItem', () => {
  259. fc.assert(
  260. fc.property(
  261. fc.integer({ min: 1, max: 1000 }),
  262. (round) => {
  263. // Run multiple generateRow calls to increase item generation probability
  264. for (let i = 0; i < 20; i++) {
  265. const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  266. bm2.generateRow(round, GameMode.CLASSIC);
  267. for (const item of bm2.getItems()) {
  268. expect(item).toBeInstanceOf(BallItem);
  269. expect(item).not.toBeInstanceOf(LineClearItem);
  270. }
  271. }
  272. }
  273. ),
  274. { numRuns: 100 }
  275. );
  276. });
  277. it('ENHANCED mode only generates BallItem or LineClearItem', () => {
  278. fc.assert(
  279. fc.property(
  280. fc.integer({ min: 1, max: 1000 }),
  281. (round) => {
  282. // Run multiple generateRow calls to increase item generation probability
  283. for (let i = 0; i < 20; i++) {
  284. const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  285. bm2.generateRow(round, GameMode.ENHANCED);
  286. for (const item of bm2.getItems()) {
  287. const isBallItem = item instanceof BallItem;
  288. const isLineClearItem = item instanceof LineClearItem;
  289. expect(isBallItem || isLineClearItem).toBe(true);
  290. }
  291. }
  292. }
  293. ),
  294. { numRuns: 100 }
  295. );
  296. });
  297. });
  298. // Feature: ball-block-breaker, Property 16: 方块触底判定
  299. // **Validates: Requirements 7.1**
  300. describe('Property 16: checkGameOver returns true iff any block gridY >= rows', () => {
  301. it('checkGameOver is true iff at least one block has gridY >= ROWS', () => {
  302. fc.assert(
  303. fc.property(
  304. fc.array(
  305. fc.record({
  306. gridX: fc.integer({ min: 0, max: COLS - 1 }),
  307. gridY: fc.integer({ min: 0, max: ROWS + 5 }),
  308. count: fc.integer({ min: 1, max: 100 }),
  309. }),
  310. { minLength: 0, maxLength: 30 }
  311. ),
  312. (blockDefs) => {
  313. const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  314. bm2.blocks = blockDefs.map(
  315. (b) => new Block(b.gridX, b.gridY, b.count, BLOCK_SIZE)
  316. );
  317. const expected = blockDefs.some((b) => b.gridY >= ROWS);
  318. expect(bm2.checkGameOver()).toBe(expected);
  319. }
  320. ),
  321. { numRuns: 100 }
  322. );
  323. });
  324. });
  325. // Feature: ball-block-breaker, Property 16: 方块触底判定
  326. // **Validates: Requirements 7.1**
  327. describe('Property 16: checkGameOver returns true iff any block gridY >= rows', () => {
  328. it('checkGameOver is true iff at least one block has gridY >= ROWS', () => {
  329. fc.assert(
  330. fc.property(
  331. fc.array(
  332. fc.record({
  333. gridX: fc.integer({ min: 0, max: COLS - 1 }),
  334. gridY: fc.integer({ min: 0, max: ROWS + 5 }),
  335. count: fc.integer({ min: 1, max: 100 }),
  336. }),
  337. { minLength: 0, maxLength: 30 }
  338. ),
  339. (blockDefs) => {
  340. const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
  341. bm2.blocks = blockDefs.map(
  342. (b) => new Block(b.gridX, b.gridY, b.count, BLOCK_SIZE)
  343. );
  344. const expected = blockDefs.some((b) => b.gridY >= ROWS);
  345. expect(bm2.checkGameOver()).toBe(expected);
  346. }
  347. ),
  348. { numRuns: 100 }
  349. );
  350. });
  351. });
  352. });