import { describe, it, expect, vi } from 'vitest'; import type { StateView } 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 } from './source'; import type { Seat } from './serialize'; import { newLock } from '../pin'; async function hotseat(id: string, seats: Seat[], hostDigits = '9999'): Promise { const src = new LocalSource(); await src.create({ id, variant: 'scrabble_en', dictVersion: 'sample', seed: 3n, multipleWords: true, seats, hotseat: true, hostPin: await newLock(hostDigits), }); return src; } describe('LocalSource hotseat', () => { it('creates a pass-and-play game: not vs_ai, board visible, first seat to move', async () => { const src = await hotseat('local:h1', [ { kind: 'human', name: 'Ann' }, { kind: 'human', name: 'Bob' }, ]); const st = await src.gameState('local:h1'); expect(st.game.vsAi).toBe(false); expect(st.game.hotseat).toBe(true); expect(st.game.toMove).toBe(0); expect(st.locked).toBeFalsy(); // seat 0 has no PIN expect(st.rack.length).toBe(7); expect(st.game.seats.map((s) => s.displayName)).toEqual(['Ann', 'Bob']); }); it('hides the rack of a PIN-locked seat until the right PIN unlocks it', async () => { const src = await hotseat('local:h2', [ { kind: 'human', name: 'Ann', pin: await newLock('1234') }, { kind: 'human', name: 'Bob' }, ]); const st = await src.gameState('local:h2'); expect(st.locked).toBe(true); expect(st.rack).toEqual([]); await expect(src.unlockSeat('local:h2', '0000')).rejects.toMatchObject({ code: 'wrong_pin' }); const opened = await src.unlockSeat('local:h2', '1234'); expect(opened.locked).toBe(false); expect(opened.rack.length).toBe(7); }); it('re-locks the seat when the turn advances and again when it comes back', async () => { const src = await hotseat('local:h3', [ { kind: 'human', name: 'A', pin: await newLock('1111') }, { kind: 'human', name: 'B' }, { kind: 'human', name: 'C' }, ]); await src.unlockSeat('local:h3', '1111'); await src.pass('local:h3'); // seat 0 -> 1 let st = await src.gameState('local:h3'); expect(st.game.toMove).toBe(1); expect(st.locked).toBeFalsy(); // seat 1 has no PIN await src.pass('local:h3'); // 1 -> 2 await src.pass('local:h3'); // 2 -> 0 st = await src.gameState('local:h3'); expect(st.game.toMove).toBe(0); expect(st.locked).toBe(true); // seat 0 needs re-unlocking }); it('lets the host skip the current turn, gated by the master PIN', async () => { const src = await hotseat('local:h4', [ { kind: 'human', name: 'A' }, { kind: 'human', name: 'B' }, ], '4321'); expect((await src.gameState('local:h4')).game.toMove).toBe(0); await expect(src.hostAction('local:h4', '0000', 'skip')).rejects.toMatchObject({ code: 'wrong_pin' }); const st = await src.hostAction('local:h4', '4321', 'skip'); expect(st).not.toBeNull(); expect((st as StateView).game.toMove).toBe(1); }); it('lets the host resign a seat (ending a two-player game)', async () => { const src = await hotseat('local:h5', [ { kind: 'human', name: 'A' }, { kind: 'human', name: 'B' }, ], '4321'); const st = await src.hostAction('local:h5', '4321', 'resign', 1); expect((st as StateView).game.status).toBe('finished'); }); it('lets the host terminate the game, deleting it', async () => { const src = await hotseat('local:h6', [ { kind: 'human', name: 'A' }, { kind: 'human', name: 'B' }, ], '4321'); const r = await src.hostAction('local:h6', '4321', 'terminate'); expect(r).toBeNull(); await expect(src.gameState('local:h6')).rejects.toMatchObject({ code: 'game_not_found' }); }); it('re-locks an unlocked seat on relock (returning to the lobby)', async () => { const src = await hotseat('local:h9', [ { kind: 'human', name: 'Ann', pin: await newLock('1234') }, { kind: 'human', name: 'Bob' }, ]); const opened = await src.unlockSeat('local:h9', '1234'); expect(opened.locked).toBe(false); src.relock('local:h9'); const st = await src.gameState('local:h9'); expect(st.locked).toBe(true); // returning to the game re-prompts the seat PIN expect(st.rack).toEqual([]); }); it('verifies the master PIN', async () => { const src = await hotseat('local:h7', [ { kind: 'human', name: 'A' }, { kind: 'human', name: 'B' }, ], '4321'); expect(await src.verifyHostPin('local:h7', '4321')).toBe(true); expect(await src.verifyHostPin('local:h7', '0000')).toBe(false); }); it('persists a naturally finished game (scoreless run) as finished, and unlocked', async () => { const src = await hotseat('local:h8', [ { kind: 'human', name: 'A', pin: await newLock('1111') }, { kind: 'human', name: 'B', pin: await newLock('2222') }, ]); for (let i = 0; i < 6; i++) await src.pass('local:h8'); const st = await src.gameState('local:h8'); expect(st.game.status).toBe('finished'); expect(st.locked).toBeFalsy(); // a finished game shows its final rack, never an unlock button }); });