import { describe, it, expect } from 'vitest'; import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { Dawg } from './dawg'; import { generateMoves, GenRack, type GenBoard, type Mode } from './generate'; import type { Ruleset } from './validate'; // Full-dictionary conformance for the ported move generator: for positions over the real // shipped dictionaries (deep graphs and full 26/33-letter alphabets the tiny committed // samples cannot reach) it must return exactly the ranked list the Go solver does. The // golden vectors come from `go run ./backend/cmd/movegen -dawg-dir -out ` and // the CI conformance job wires the two directories in; the suite skips when they are unset. const dawgDir = process.env.DICT_DAWG_DIR; const goldDir = process.env.DICT_MOVEGEN_DIR; const ready = !!dawgDir && !!goldDir && existsSync(dawgDir) && existsSync(goldDir); // variant -> the release dawg file name (matches dawg.parity.test.ts / the movegen tool). const variants = [ { variant: 'scrabble_en', dawg: 'en_sowpods' }, { variant: 'scrabble_ru', dawg: 'ru_scrabble' }, { variant: 'erudit_ru', dawg: 'ru_erudit' }, ]; interface Tile { row: number; col: number; letter: number; blank: boolean; } interface GenMove { dir: number; tiles: Tile[]; score: number; } interface Fixture { ruleset: { size: number; cols: number; center: number; rackSize: number; bingo: number; values: number[]; letterMult: number[][]; wordMult: number[][]; }; cases: { name: string; placed: Tile[] | null; rack: { letters: number[]; blanks: number }; mode: number; ignoreCrossWords: boolean; moves: GenMove[]; }[]; } function buildBoard(placed: Tile[], cols: number): GenBoard { const grid: ({ letter: number; blank: boolean } | null)[] = new Array(cols * cols).fill(null); for (const t of placed) grid[t.row * cols + t.col] = { letter: t.letter, blank: t.blank }; const inBounds = (r: number, c: number): boolean => r >= 0 && r < cols && c >= 0 && c < cols; return { rows: cols, cols, inBounds, filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null, cellAt: (r, c) => grid[r * cols + c]!, isEmpty: () => placed.length === 0, }; } function rulesetFor(fx: Fixture, ignoreCrossWords: boolean): Ruleset { const r = fx.ruleset; return { cols: r.cols, center: r.center, rackSize: r.rackSize, bingo: r.bingo, values: r.values, letterMult: (row, col) => r.letterMult[row][col], wordMult: (row, col) => r.wordMult[row][col], ignoreCrossWords, }; } function sig(m: GenMove): string { const ts = m.tiles.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col)); return `${m.dir}#${m.score}#` + ts.map((t) => `${t.row},${t.col},${t.letter}${t.blank ? '*' : ''}`).join(';'); } describe.skipIf(!ready)('move generator parity vs Go on the real dictionaries', () => { for (const v of variants) { describe(v.variant, () => { // Guard the collection-time reads: the block is skipped when the dirs are unset, but // the describe callback still runs to register tests, so touch the filesystem only // when ready (otherwise join(undefined, …) would throw during a normal unit run). if (!ready) return; const fx = JSON.parse(readFileSync(join(goldDir!, `${v.variant}.movegen.json`), 'utf8')) as Fixture; const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, `${v.dawg}.dawg`)))); for (const c of fx.cases) { it( c.name, () => { const board = buildBoard(c.placed ?? [], fx.ruleset.cols); const rack = GenRack.from(fx.ruleset.size, c.rack.letters, c.rack.blanks); const rs = rulesetFor(fx, c.ignoreCrossWords); const got = generateMoves(dawg, board, rack, rs, c.mode as Mode); expect(got.map(sig)).toEqual(c.moves.map(sig)); }, 60_000, ); } }); } });