import { describe, it, expect, beforeEach } from 'vitest'; import fc from 'fast-check'; import { BoardManager } from '../src/systems/BoardManager.js'; import { Block } from '../src/entities/Block.js'; import { BallItem, LineClearItem } from '../src/entities/Item.js'; import { GameMode, TOP_PADDING_ROWS } from '../src/constants.js'; describe('BoardManager', () => { const COLS = 7; const ROWS = 10; const BLOCK_SIZE = 40; let bm; beforeEach(() => { bm = new BoardManager(COLS, ROWS, BLOCK_SIZE); }); describe('constructor', () => { it('initializes with empty blocks and items', () => { expect(bm.getBlocks()).toEqual([]); expect(bm.getItems()).toEqual([]); expect(bm.cols).toBe(COLS); expect(bm.rows).toBe(ROWS); expect(bm.blockSize).toBe(BLOCK_SIZE); }); }); describe('generateRow', () => { it('generates at least one block per row', () => { for (let i = 0; i < 50; i++) { const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE); bm2.generateRow(1, GameMode.CLASSIC); expect(bm2.getBlocks().length).toBeGreaterThanOrEqual(1); } }); it('all new blocks have gridY = TOP_PADDING_ROWS', () => { bm.generateRow(5, GameMode.CLASSIC); for (const block of bm.getBlocks()) { expect(block.gridY).toBe(TOP_PADDING_ROWS); } }); it('all new items have gridY = TOP_PADDING_ROWS', () => { // Run multiple times to increase chance of item generation for (let i = 0; i < 30; i++) { const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE); bm2.generateRow(2, GameMode.CLASSIC); // round > 1 to generate BallItem for (const item of bm2.getItems()) { expect(item.gridY).toBe(TOP_PADDING_ROWS); } } }); it('block count equals round or round*2', () => { const round = 7; for (let i = 0; i < 30; i++) { const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE); bm2.generateRow(round, GameMode.CLASSIC); for (const block of bm2.getBlocks()) { expect([round, round * 2]).toContain(block.count); } } }); it('classic mode only generates BallItem, no LineClearItem', () => { for (let i = 0; i < 50; i++) { const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE); bm2.generateRow(1, GameMode.CLASSIC); for (const item of bm2.getItems()) { expect(item).toBeInstanceOf(BallItem); } } }); it('block gridX is within valid range', () => { bm.generateRow(3, GameMode.CLASSIC); for (const block of bm.getBlocks()) { expect(block.gridX).toBeGreaterThanOrEqual(0); expect(block.gridX).toBeLessThan(COLS); } }); it('items are not placed in columns with blocks', () => { for (let i = 0; i < 50; i++) { const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE); bm2.generateRow(1, GameMode.ENHANCED); const blockCols = new Set(bm2.getBlocks().map(b => b.gridX)); for (const item of bm2.getItems()) { expect(blockCols.has(item.gridX)).toBe(false); } } }); }); describe('moveAllDown', () => { it('moves all blocks down by one row', () => { bm.blocks = [ new Block(0, 0, 1, BLOCK_SIZE), new Block(3, 2, 5, BLOCK_SIZE), ]; bm.moveAllDown(); expect(bm.blocks[0].gridY).toBe(1); expect(bm.blocks[1].gridY).toBe(3); }); it('moves all items down by one row', () => { bm.items = [ new BallItem(1, 0, BLOCK_SIZE), new LineClearItem(4, 3, BLOCK_SIZE), ]; bm.moveAllDown(); expect(bm.items[0].gridY).toBe(1); expect(bm.items[1].gridY).toBe(4); }); }); describe('checkGameOver', () => { it('returns false when no blocks touch bottom', () => { bm.blocks = [ new Block(0, 0, 1, BLOCK_SIZE), new Block(3, ROWS - 1, 5, BLOCK_SIZE), ]; expect(bm.checkGameOver()).toBe(false); }); it('returns true when a block reaches rows', () => { bm.blocks = [ new Block(0, 0, 1, BLOCK_SIZE), new Block(3, ROWS, 5, BLOCK_SIZE), ]; expect(bm.checkGameOver()).toBe(true); }); it('returns true when a block exceeds rows', () => { bm.blocks = [new Block(0, ROWS + 2, 1, BLOCK_SIZE)]; expect(bm.checkGameOver()).toBe(true); }); it('returns false with empty board', () => { expect(bm.checkGameOver()).toBe(false); }); }); describe('removeBlock', () => { it('removes the specified block', () => { const b1 = new Block(0, 0, 1, BLOCK_SIZE); const b2 = new Block(1, 0, 2, BLOCK_SIZE); bm.blocks = [b1, b2]; bm.removeBlock(b1); expect(bm.getBlocks()).toEqual([b2]); }); it('does nothing if block not found', () => { const b1 = new Block(0, 0, 1, BLOCK_SIZE); bm.blocks = [b1]; bm.removeBlock(new Block(2, 2, 3, BLOCK_SIZE)); expect(bm.getBlocks()).toEqual([b1]); }); }); describe('clearRow', () => { it('decrements count of all blocks in the specified row, removes destroyed ones, returns destroyed', () => { bm.blocks = [ new Block(0, 2, 1, BLOCK_SIZE), // count=1, will be destroyed new Block(3, 2, 5, BLOCK_SIZE), // count=5, becomes 4 new Block(5, 4, 3, BLOCK_SIZE), // different row, untouched ]; const destroyed = bm.clearRow(2); expect(destroyed.length).toBe(1); expect(destroyed[0].gridX).toBe(0); expect(bm.getBlocks().length).toBe(2); const row2Block = bm.getBlocks().find(b => b.gridY === 2); expect(row2Block.count).toBe(4); const row4Block = bm.getBlocks().find(b => b.gridY === 4); expect(row4Block.count).toBe(3); }); }); describe('clearColumn', () => { it('decrements count of all blocks in the specified column, removes destroyed ones, returns destroyed', () => { bm.blocks = [ new Block(3, 0, 1, BLOCK_SIZE), // count=1, will be destroyed new Block(3, 2, 5, BLOCK_SIZE), // count=5, becomes 4 new Block(5, 4, 3, BLOCK_SIZE), // different column, untouched ]; const destroyed = bm.clearColumn(3); expect(destroyed.length).toBe(1); expect(destroyed[0].gridY).toBe(0); expect(bm.getBlocks().length).toBe(2); const col3Block = bm.getBlocks().find(b => b.gridX === 3); expect(col3Block.count).toBe(4); const col5Block = bm.getBlocks().find(b => b.gridX === 5); expect(col5Block.count).toBe(3); }); }); // Feature: ball-block-breaker, Property 10: 新行方块位置有效 // **Validates: Requirements 5.2** describe('Property 10: generated row blocks have valid positions', () => { it('all blocks have gridY === TOP_PADDING_ROWS, gridX in [0, cols-1], and at least 1 block', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 1000 }), fc.constantFrom(GameMode.CLASSIC, GameMode.ENHANCED), (round, mode) => { const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE); bm2.generateRow(round, mode); const blocks = bm2.getBlocks(); // At least one block generated expect(blocks.length).toBeGreaterThanOrEqual(1); for (const block of blocks) { // All blocks at TOP_PADDING_ROWS expect(block.gridY).toBe(TOP_PADDING_ROWS); // gridX within valid range [0, cols-1] expect(block.gridX).toBeGreaterThanOrEqual(0); expect(block.gridX).toBeLessThanOrEqual(COLS - 1); } } ), { numRuns: 100 } ); }); }); // Feature: ball-block-breaker, Property 11: 新方块数字有效 // **Validates: Requirements 5.3, 5.4** describe('Property 11: new block count is round or round*2', () => { it('every generated block count is either round or round*2', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 1000 }), fc.constantFrom(GameMode.CLASSIC, GameMode.ENHANCED), (round, mode) => { const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE); bm2.generateRow(round, mode); const blocks = bm2.getBlocks(); for (const block of blocks) { expect([round, round * 2]).toContain(block.count); } } ), { numRuns: 100 } ); }); }); // Feature: ball-block-breaker, Property 9: 方块下移一行 // **Validates: Requirements 5.1** describe('Property 9: moveAllDown increments gridY by 1', () => { it('every block gridY increases by exactly 1 after moveAllDown', () => { fc.assert( fc.property( fc.array( fc.record({ gridX: fc.integer({ min: 0, max: COLS - 1 }), gridY: fc.integer({ min: 0, max: ROWS - 1 }), count: fc.integer({ min: 1, max: 100 }), }), { minLength: 1, maxLength: 20 } ), (blockDefs) => { const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE); bm2.blocks = blockDefs.map( (b) => new Block(b.gridX, b.gridY, b.count, BLOCK_SIZE) ); const originalYs = bm2.blocks.map((b) => b.gridY); bm2.moveAllDown(); for (let i = 0; i < bm2.blocks.length; i++) { expect(bm2.blocks[i].gridY).toBe(originalYs[i] + 1); } } ), { numRuns: 100 } ); }); }); // Feature: ball-block-breaker, Property 12: 道具生成符合模式规则 // **Validates: Requirements 5.5, 5.6** describe('Property 12: item generation follows mode rules', () => { it('CLASSIC mode only generates BallItem, never LineClearItem', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 1000 }), (round) => { // Run multiple generateRow calls to increase item generation probability for (let i = 0; i < 20; i++) { const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE); bm2.generateRow(round, GameMode.CLASSIC); for (const item of bm2.getItems()) { expect(item).toBeInstanceOf(BallItem); expect(item).not.toBeInstanceOf(LineClearItem); } } } ), { numRuns: 100 } ); }); it('ENHANCED mode only generates BallItem or LineClearItem', () => { fc.assert( fc.property( fc.integer({ min: 1, max: 1000 }), (round) => { // Run multiple generateRow calls to increase item generation probability for (let i = 0; i < 20; i++) { const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE); bm2.generateRow(round, GameMode.ENHANCED); for (const item of bm2.getItems()) { const isBallItem = item instanceof BallItem; const isLineClearItem = item instanceof LineClearItem; expect(isBallItem || isLineClearItem).toBe(true); } } } ), { numRuns: 100 } ); }); }); // Feature: ball-block-breaker, Property 16: 方块触底判定 // **Validates: Requirements 7.1** describe('Property 16: checkGameOver returns true iff any block gridY >= rows', () => { it('checkGameOver is true iff at least one block has gridY >= ROWS', () => { fc.assert( fc.property( fc.array( fc.record({ gridX: fc.integer({ min: 0, max: COLS - 1 }), gridY: fc.integer({ min: 0, max: ROWS + 5 }), count: fc.integer({ min: 1, max: 100 }), }), { minLength: 0, maxLength: 30 } ), (blockDefs) => { const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE); bm2.blocks = blockDefs.map( (b) => new Block(b.gridX, b.gridY, b.count, BLOCK_SIZE) ); const expected = blockDefs.some((b) => b.gridY >= ROWS); expect(bm2.checkGameOver()).toBe(expected); } ), { numRuns: 100 } ); }); }); // Feature: ball-block-breaker, Property 16: 方块触底判定 // **Validates: Requirements 7.1** describe('Property 16: checkGameOver returns true iff any block gridY >= rows', () => { it('checkGameOver is true iff at least one block has gridY >= ROWS', () => { fc.assert( fc.property( fc.array( fc.record({ gridX: fc.integer({ min: 0, max: COLS - 1 }), gridY: fc.integer({ min: 0, max: ROWS + 5 }), count: fc.integer({ min: 1, max: 100 }), }), { minLength: 0, maxLength: 30 } ), (blockDefs) => { const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE); bm2.blocks = blockDefs.map( (b) => new Block(b.gridX, b.gridY, b.count, BLOCK_SIZE) ); const expected = blockDefs.some((b) => b.gridY >= ROWS); expect(bm2.checkGameOver()).toBe(expected); } ), { numRuns: 100 } ); }); }); });