From 50c146964d9c2589979549fd9fb4fac40ce6f7c8 Mon Sep 17 00:00:00 2001 From: hypercross Date: Fri, 3 Apr 2026 17:56:35 +0800 Subject: [PATCH] chore: update tests and imports --- src/core/part-factory.ts | 29 +++- src/core/part.ts | 20 ++- src/index.ts | 2 +- src/samples/boop/index.ts | 10 +- src/utils/mutable-signal.ts | 2 +- tests/core/part-factory.test.ts | 171 ++++++++++++++++++++++ tests/core/part.test.ts | 249 ++++++++++++++++++++++++++++++++ tests/samples/boop.test.ts | 2 +- 8 files changed, 474 insertions(+), 11 deletions(-) create mode 100644 tests/core/part-factory.test.ts create mode 100644 tests/core/part.test.ts diff --git a/src/core/part-factory.ts b/src/core/part-factory.ts index 37cb47c..8fa0c2c 100644 --- a/src/core/part-factory.ts +++ b/src/core/part-factory.ts @@ -14,12 +14,13 @@ export function createPart( template: PartTemplate, id: string ): Part { - return { - regionId: '', - position: [], - ...template, + const part: Part = { id, - } as Part; + regionId: template.regionId ?? '', + position: template.position ?? [], + ...template, + }; + return part; } export function createParts( @@ -67,7 +68,7 @@ export function mergePartPools( ...pools: PartPool[] ): PartPool { if (pools.length === 0) { - return createPartPool({} as PartTemplate, 0, 'merged'); + return createEmptyPartPool(); } const allPartsArray = pools.flatMap(p => Object.values(p.parts)); @@ -94,3 +95,19 @@ export function mergePartPools( }, }; } + +function createEmptyPartPool(): PartPool { + return { + parts: {}, + template: {} as PartTemplate, + draw() { + return undefined; + }, + return(_part: Part) { + // no-op for empty pool + }, + remaining() { + return 0; + }, + }; +} diff --git a/src/core/part.ts b/src/core/part.ts index 8db9d11..415b51c 100644 --- a/src/core/part.ts +++ b/src/core/part.ts @@ -1,4 +1,5 @@ -import {RNG} from "@/utils/rng"; +import {RNG} from '@/utils/rng'; +import {Region} from '@/core/region'; export type Part = { id: string; @@ -40,3 +41,20 @@ export function getPartAtPosition(parts: Record>, reg const posKey = position.join(','); return Object.values(parts).find(p => p.regionId === regionId && p.position.join(',') === posKey); } + +/** + * O(1) cell occupancy check using Region.partMap + */ +export function isCellOccupiedByRegion(region: Region, position: number[]): boolean { + return position.join(',') in region.partMap; +} + +/** + * O(1) part lookup using Region.partMap and parts Record + */ +export function getPartAtPositionInRegion(region: Region, parts: Record>, position: number[]): Part | undefined { + const posKey = position.join(','); + const partId = region.partMap[posKey]; + if (!partId) return undefined; + return parts[partId]; +} diff --git a/src/index.ts b/src/index.ts index 9e43f2f..f1e5f73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ export type { IGameContext } from './core/game'; export { createGameContext, createGameCommandRegistry } from './core/game'; export type { Part } from './core/part'; -export { flip, flipTo, roll, findPartById, isCellOccupied, getPartAtPosition } from './core/part'; +export { flip, flipTo, roll, findPartById, isCellOccupied, getPartAtPosition, isCellOccupiedByRegion, getPartAtPositionInRegion } from './core/part'; export type { PartTemplate, PartPool } from './core/part-factory'; export { createPart, createParts, createPartPool, mergePartPools } from './core/part-factory'; diff --git a/src/samples/boop/index.ts b/src/samples/boop/index.ts index 4ed20ff..94d7dc9 100644 --- a/src/samples/boop/index.ts +++ b/src/samples/boop/index.ts @@ -1,4 +1,12 @@ -import {createGameCommandRegistry, Part, MutableSignal, createRegion, createPart, isCellOccupied as isCellOccupiedUtil, getPartAtPosition} from '@/index'; +import { + createGameCommandRegistry, + Part, + MutableSignal, + createRegion, + createPart, + isCellOccupied as isCellOccupiedUtil, + getPartAtPosition, +} from '@/index'; const BOARD_SIZE = 6; const MAX_PIECES_PER_PLAYER = 8; diff --git a/src/utils/mutable-signal.ts b/src/utils/mutable-signal.ts index 8bcceca..36c33d3 100644 --- a/src/utils/mutable-signal.ts +++ b/src/utils/mutable-signal.ts @@ -1,4 +1,4 @@ -import {Signal, signal, SignalOptions} from "@preact/signals-core"; +import {Signal, signal, SignalOptions} from '@preact/signals-core'; import {create} from 'mutative'; export class MutableSignal extends Signal { diff --git a/tests/core/part-factory.test.ts b/tests/core/part-factory.test.ts new file mode 100644 index 0000000..be7e940 --- /dev/null +++ b/tests/core/part-factory.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; +import { createPart, createParts, createPartPool, mergePartPools, PartPool } from '@/core/part-factory'; + +describe('createPart', () => { + it('should create a part with given template and id', () => { + const part = createPart<{ player: string }>( + { regionId: 'board', position: [1, 2], player: 'X' }, + 'piece-1' + ); + expect(part.id).toBe('piece-1'); + expect(part.regionId).toBe('board'); + expect(part.position).toEqual([1, 2]); + expect(part.player).toBe('X'); + }); + + it('should apply default values for regionId and position when not provided', () => { + const part = createPart({}, 'piece-1'); + expect(part.regionId).toBe(''); + expect(part.position).toEqual([]); + }); + + it('should allow overriding default values', () => { + const part = createPart({ regionId: 'custom', position: [0] }, 'piece-1'); + expect(part.regionId).toBe('custom'); + expect(part.position).toEqual([0]); + }); + + it('should preserve metadata fields', () => { + type TestMeta = { type: string; count: number }; + const part = createPart( + { regionId: 'board', position: [0], type: 'kitten', count: 5 }, + 'piece-1' + ); + expect(part.type).toBe('kitten'); + expect(part.count).toBe(5); + }); +}); + +describe('createParts', () => { + it('should create multiple parts with auto-generated IDs', () => { + const parts = createParts( + { regionId: 'deck', position: [] }, + 3, + 'card' + ); + expect(parts.length).toBe(3); + expect(parts[0].id).toBe('card-1'); + expect(parts[1].id).toBe('card-2'); + expect(parts[2].id).toBe('card-3'); + }); + + it('should create parts with identical properties', () => { + const parts = createParts( + { regionId: 'deck', position: [], type: 'token' }, + 2, + 'token' + ); + expect(parts[0].regionId).toBe('deck'); + expect(parts[1].regionId).toBe('deck'); + expect(parts[0].type).toBe('token'); + expect(parts[1].type).toBe('token'); + }); + + it('should create zero parts when count is 0', () => { + const parts = createParts({}, 0, 'empty'); + expect(parts.length).toBe(0); + }); +}); + +describe('createPartPool', () => { + it('should create a pool with specified count', () => { + const pool = createPartPool( + { regionId: 'supply', position: [] }, + 5, + 'token' + ); + expect(pool.remaining()).toBe(5); + expect(Object.keys(pool.parts).length).toBe(5); + }); + + it('should generate parts with correct IDs', () => { + const pool = createPartPool({}, 3, 'piece'); + expect(pool.parts['piece-1']).toBeDefined(); + expect(pool.parts['piece-2']).toBeDefined(); + expect(pool.parts['piece-3']).toBeDefined(); + }); + + it('should draw parts from the pool', () => { + const pool = createPartPool({}, 2, 'card'); + const drawn = pool.draw(); + expect(drawn).toBeDefined(); + expect(drawn!.id).toBe('card-2'); + expect(pool.remaining()).toBe(1); + }); + + it('should return undefined when pool is empty', () => { + const pool = createPartPool({}, 1, 'card'); + pool.draw(); + const drawn = pool.draw(); + expect(drawn).toBeUndefined(); + }); + + it('should return part to pool', () => { + const pool = createPartPool({ regionId: 'board', position: [0, 0] }, 1, 'card'); + const drawn = pool.draw(); + expect(pool.remaining()).toBe(0); + + pool.return(drawn!); + expect(pool.remaining()).toBe(1); + expect(drawn!.regionId).toBe(''); + expect(drawn!.position).toEqual([]); + }); + + it('should store parts as Record keyed by ID', () => { + const pool = createPartPool({}, 2, 'piece'); + expect(typeof pool.parts).toBe('object'); + expect(pool.parts['piece-1']).toBeDefined(); + expect(pool.parts['piece-2']).toBeDefined(); + }); +}); + +describe('mergePartPools', () => { + it('should merge multiple pools', () => { + const pool1 = createPartPool({ regionId: 'deck1', position: [] }, 2, 'card1'); + const pool2 = createPartPool({ regionId: 'deck2', position: [] }, 3, 'card2'); + const merged = mergePartPools(pool1, pool2); + + expect(Object.keys(merged.parts).length).toBe(5); + // Parts with regionId: '' are available; pool parts have regionId from template + // After merge, available = parts with regionId === '' + expect(merged.remaining()).toBe(0); // parts have regionId: 'deck1' or 'deck2' + expect(merged.parts['card1-1']).toBeDefined(); + expect(merged.parts['card2-3']).toBeDefined(); + }); + + it('should use first pool template', () => { + const pool1 = createPartPool({ regionId: 'deck1', position: [] }, 1, 'card1'); + const pool2 = createPartPool({ regionId: 'deck2', position: [] }, 1, 'card2'); + const merged = mergePartPools(pool1, pool2); + + expect(merged.template.regionId).toBe('deck1'); + }); + + it('should return empty pool when no pools provided', () => { + const merged = mergePartPools(); + expect(Object.keys(merged.parts).length).toBe(0); + expect(merged.remaining()).toBe(0); + }); + + it('should only count available parts for remaining()', () => { + const pool1 = createPartPool({}, 2, 'card'); + const pool2 = createPartPool({}, 2, 'token'); + + const drawn = pool1.draw(); + drawn!.regionId = 'board'; + drawn!.position = [0, 0]; + + const merged = mergePartPools(pool1, pool2); + expect(merged.remaining()).toBe(3); + }); + + it('should handle drawing from merged pool', () => { + const pool1 = createPartPool({}, 2, 'card'); + const pool2 = createPartPool({}, 2, 'token'); + const merged = mergePartPools(pool1, pool2); + + const drawn = merged.draw(); + expect(drawn).toBeDefined(); + expect(merged.remaining()).toBe(3); + }); +}); diff --git a/tests/core/part.test.ts b/tests/core/part.test.ts new file mode 100644 index 0000000..a5edaf4 --- /dev/null +++ b/tests/core/part.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect } from 'vitest'; +import { flip, flipTo, roll, findPartById, isCellOccupied, getPartAtPosition, isCellOccupiedByRegion, getPartAtPositionInRegion, Part } from '@/core/part'; +import { createRegion } from '@/core/region'; +import { createRNG } from '@/utils/rng'; + +function createTestPart(overrides: Partial> & TMeta): Part { + return { + id: 'test-part', + regionId: 'test-region', + position: [0, 0], + ...overrides, + } as Part; +} + +describe('flip', () => { + it('should cycle to next side when sides is defined', () => { + const part = createTestPart({ sides: 2, side: 0 }); + flip(part); + expect(part.side).toBe(1); + flip(part); + expect(part.side).toBe(0); + }); + + it('should wrap around when side reaches sides count', () => { + const part = createTestPart({ sides: 6, side: 5 }); + flip(part); + expect(part.side).toBe(0); + }); + + it('should do nothing when sides is undefined', () => { + const part = createTestPart({ side: 3 }); + flip(part); + expect(part.side).toBe(3); + }); + + it('should initialize side to 0 when side is undefined', () => { + const part = createTestPart({ sides: 4 }); + flip(part); + expect(part.side).toBe(1); + }); +}); + +describe('flipTo', () => { + it('should set side to specified value', () => { + const part = createTestPart({ sides: 6, side: 0 }); + flipTo(part, 3); + expect(part.side).toBe(3); + }); + + it('should do nothing when sides is undefined', () => { + const part = createTestPart({ side: 2 }); + flipTo(part, 5); + expect(part.side).toBe(2); + }); + + it('should do nothing when side exceeds sides count', () => { + const part = createTestPart({ sides: 6, side: 0 }); + flipTo(part, 6); + expect(part.side).toBe(0); + }); + + it('should allow side equal to sides - 1', () => { + const part = createTestPart({ sides: 6, side: 0 }); + flipTo(part, 5); + expect(part.side).toBe(5); + }); +}); + +describe('roll', () => { + it('should randomize side when sides is defined', () => { + const part = createTestPart({ sides: 6 }); + const rng = createRNG(12345); + roll(part, rng); + expect(part.side).toBeGreaterThanOrEqual(0); + expect(part.side).toBeLessThan(6); + }); + + it('should do nothing when sides is undefined', () => { + const part = createTestPart({ side: 3 }); + const rng = createRNG(12345); + roll(part, rng); + expect(part.side).toBe(3); + }); + + it('should produce deterministic results with same seed', () => { + const part1 = createTestPart({ sides: 6 }); + const part2 = createTestPart({ sides: 6 }); + const rng1 = createRNG(42); + const rng2 = createRNG(42); + roll(part1, rng1); + roll(part2, rng2); + expect(part1.side).toBe(part2.side); + }); +}); + +describe('findPartById', () => { + it('should find part by id', () => { + const parts: Record = { + 'part-1': createTestPart({ id: 'part-1' }), + 'part-2': createTestPart({ id: 'part-2' }), + }; + const result = findPartById(parts, 'part-1'); + expect(result).toBeDefined(); + expect(result!.id).toBe('part-1'); + }); + + it('should return undefined for non-existent id', () => { + const parts: Record = { + 'part-1': createTestPart({ id: 'part-1' }), + }; + const result = findPartById(parts, 'part-99'); + expect(result).toBeUndefined(); + }); + + it('should work with empty record', () => { + const parts: Record = {}; + const result = findPartById(parts, 'any'); + expect(result).toBeUndefined(); + }); +}); + +describe('isCellOccupied', () => { + it('should return true for occupied cell', () => { + const parts: Record = { + 'p1': createTestPart({ id: 'p1', regionId: 'board', position: [1, 2] }), + }; + expect(isCellOccupied(parts, 'board', [1, 2])).toBe(true); + }); + + it('should return false for empty cell', () => { + const parts: Record = { + 'p1': createTestPart({ id: 'p1', regionId: 'board', position: [1, 2] }), + }; + expect(isCellOccupied(parts, 'board', [3, 4])).toBe(false); + }); + + it('should return false for different region', () => { + const parts: Record = { + 'p1': createTestPart({ id: 'p1', regionId: 'board', position: [1, 2] }), + }; + expect(isCellOccupied(parts, 'hand', [1, 2])).toBe(false); + }); + + it('should work with empty record', () => { + const parts: Record = {}; + expect(isCellOccupied(parts, 'board', [0, 0])).toBe(false); + }); + + it('should handle multi-dimensional positions', () => { + const parts: Record = { + 'p1': createTestPart({ id: 'p1', regionId: 'board', position: [1, 2, 3] }), + }; + expect(isCellOccupied(parts, 'board', [1, 2, 3])).toBe(true); + expect(isCellOccupied(parts, 'board', [1, 2])).toBe(false); + }); +}); + +describe('getPartAtPosition', () => { + it('should return part at specified position', () => { + const part1 = createTestPart({ id: 'p1', regionId: 'board', position: [1, 2] }); + const parts: Record = { 'p1': part1 }; + const result = getPartAtPosition(parts, 'board', [1, 2]); + expect(result).toBe(part1); + }); + + it('should return undefined for empty cell', () => { + const parts: Record = { + 'p1': createTestPart({ id: 'p1', regionId: 'board', position: [1, 2] }), + }; + const result = getPartAtPosition(parts, 'board', [3, 4]); + expect(result).toBeUndefined(); + }); + + it('should return undefined for different region', () => { + const parts: Record = { + 'p1': createTestPart({ id: 'p1', regionId: 'board', position: [1, 2] }), + }; + const result = getPartAtPosition(parts, 'hand', [1, 2]); + expect(result).toBeUndefined(); + }); + + it('should work with empty record', () => { + const parts: Record = {}; + const result = getPartAtPosition(parts, 'board', [0, 0]); + expect(result).toBeUndefined(); + }); +}); + +describe('isCellOccupiedByRegion', () => { + it('should return true for occupied cell', () => { + const region = createRegion('board', [ + { name: 'x', min: 0, max: 2 }, + { name: 'y', min: 0, max: 2 }, + ]); + const part = createTestPart({ id: 'p1', regionId: 'board', position: [1, 2] }); + region.childIds.push(part.id); + region.partMap['1,2'] = part.id; + + expect(isCellOccupiedByRegion(region, [1, 2])).toBe(true); + }); + + it('should return false for empty cell', () => { + const region = createRegion('board', [ + { name: 'x', min: 0, max: 2 }, + { name: 'y', min: 0, max: 2 }, + ]); + const part = createTestPart({ id: 'p1', regionId: 'board', position: [1, 2] }); + region.childIds.push(part.id); + region.partMap['1,2'] = part.id; + + expect(isCellOccupiedByRegion(region, [0, 0])).toBe(false); + }); + + it('should work with empty region', () => { + const region = createRegion('board', []); + expect(isCellOccupiedByRegion(region, [0, 0])).toBe(false); + }); +}); + +describe('getPartAtPositionInRegion', () => { + it('should return part at specified position', () => { + const region = createRegion('board', []); + const part1 = createTestPart({ id: 'p1', regionId: 'board', position: [1, 2] }); + const parts: Record = { 'p1': part1 }; + region.childIds.push(part1.id); + region.partMap['1,2'] = part1.id; + + const result = getPartAtPositionInRegion(region, parts, [1, 2]); + expect(result).toBe(part1); + }); + + it('should return undefined for empty cell', () => { + const region = createRegion('board', []); + const part1 = createTestPart({ id: 'p1', regionId: 'board', position: [1, 2] }); + const parts: Record = { 'p1': part1 }; + region.childIds.push(part1.id); + region.partMap['1,2'] = part1.id; + + const result = getPartAtPositionInRegion(region, parts, [0, 0]); + expect(result).toBeUndefined(); + }); + + it('should work with empty region', () => { + const region = createRegion('board', []); + const parts: Record = {}; + const result = getPartAtPositionInRegion(region, parts, [0, 0]); + expect(result).toBeUndefined(); + }); +}); diff --git a/tests/samples/boop.test.ts b/tests/samples/boop.test.ts index f62cd39..89ba9ac 100644 --- a/tests/samples/boop.test.ts +++ b/tests/samples/boop.test.ts @@ -16,7 +16,7 @@ import { PlayerType, getBoardRegion, } from '@/samples/boop'; -import {MutableSignal} from "@/utils/entity"; +import {MutableSignal} from "@/utils/mutable-signal"; import {createGameContext} from "@/"; import type { PromptEvent } from '@/utils/command';