import { describe, it, expect, vi } from 'vitest'; import type { PushEvent } from '../model'; // getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node, // so games live in the source's in-memory cache — created once, then driven without reload). vi.mock('../dict', () => ({ getDawg: async () => { const { Dawg } = await import('../dict/dawg'); const { readFileSync } = await import('node:fs'); return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url)))); }, })); import { LocalSource, isLocalGameId } from './source'; import type { Seat } from './serialize'; const seats: Seat[] = [ { kind: 'human', name: 'You' }, { kind: 'robot', name: 'Robot' }, ]; async function newSource(id: string, seed: bigint): Promise { const src = new LocalSource(); await src.create({ id, variant: 'scrabble_en', dictVersion: 'sample', seed, multipleWords: true, seats }); return src; } describe('LocalSource', () => { it('detects local game ids', () => { expect(isLocalGameId('local:abc')).toBe(true); expect(isLocalGameId('deadbeef')).toBe(false); }); it('creates a vs_ai game with the human to move and a full rack', async () => { const src = await newSource('local:g1', 42n); const st = await src.gameState('local:g1'); expect(st.game.vsAi).toBe(true); expect(st.game.status).toBe('active'); expect(st.seat).toBe(0); expect(st.rack.length).toBe(7); expect(st.game.seats.map((s) => s.displayName)).toEqual(['You', 'Robot']); expect(st.game.toMove).toBe(0); }); it('replies to a human pass with a synchronous robot move, delivered via the event', async () => { const src = await newSource('local:g2', 999n); const events: PushEvent[] = []; src.events('local:g2', (e) => events.push(e)); const res = await src.pass('local:g2'); expect(res.move.action).toBe('pass'); expect(res.move.player).toBe(0); // The robot has already played (synchronously) — the state reflects both moves... const mid = await src.gameState('local:g2'); expect(mid.game.moveCount).toBeGreaterThanOrEqual(2); // ...and its move is delivered to subscribers via the per-game event. await Promise.resolve(); expect(events.some((e) => e.kind === 'opponent_moved')).toBe(true); }); it('gates the hint until 30 minutes since the robot last moved', async () => { const src = await newSource('local:g3', 7n); await expect(src.hint('local:g3')).rejects.toMatchObject({ code: 'hint_not_ready' }); }); it('records decoded moves in the history', async () => { const src = await newSource('local:g4', 55n); await src.pass('local:g4'); const h = await src.gameHistory('local:g4'); expect(h.moves.length).toBeGreaterThanOrEqual(2); expect(h.moves[0].action).toBe('pass'); // the robot's reply, whatever it is, is decoded (a play carries glyph tiles + lower-case words) const play = h.moves.find((m) => m.action === 'play'); if (play) { expect(play.tiles.every((t) => typeof t.letter === 'string' && t.letter.length >= 1)).toBe(true); expect(play.words.every((w) => w === w.toLowerCase())).toBe(true); } }); it('drives a whole local game to completion', async () => { const src = await newSource('local:g5', 2024n); src.events('local:g5', () => {}); let turns = 0; while ((await src.gameState('local:g5')).game.status === 'active' && turns < 500) { await src.pass('local:g5'); await Promise.resolve(); turns++; } const st = await src.gameState('local:g5'); expect(st.game.status).toBe('finished'); expect(turns).toBeLessThan(500); const winners = st.game.seats.filter((s) => s.isWinner); expect(winners.length).toBeLessThanOrEqual(1); }); });