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