import { describe, it, expect } from 'vitest'; import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { Dawg } from './dawg'; import { evaluateLocal } from './eval'; import { setAlphabet } from '../alphabet'; import type { Board as ClientBoard, BoardCell } from '../board'; import type { PlacedTile } from '../client'; import type { Variant } from '../model'; // End-to-end conformance for the local-eval ADAPTER: it drives evaluateLocal through // the real letter-space client path (the server-sent alphabet table, a letter board, // letter placements) and asserts it matches the Go engine's EvalResult for every // fixture. This is the cross-test the algorithm-level validate.parity suite does not // cover — it exercises the letter<->index mapping, the ruleset assembly and the word // decode, not just the index-space validator. Env-gated like the other parity suites. const dawgDir = process.env.DICT_DAWG_DIR; const validDir = process.env.DICT_VALID_DIR; const ready = !!dawgDir && !!validDir && existsSync(dawgDir) && existsSync(validDir); const dawgFile: Record = { scrabble_en: 'en_sowpods.dawg', scrabble_ru: 'ru_scrabble.dawg', erudit_ru: 'ru_erudit.dawg', }; interface FxCell { R: number; C: number; Letter: number; Blank: boolean; } interface FxWord { Letters: number[]; } interface Fx { board: number; ignoreCrossWords: boolean; tiles: FxCell[]; legal: boolean; score: number; main?: FxWord; cross?: FxWord[]; } interface Alpha { index: number; letter: string; value: number; } interface VF { rows: number; cols: number; alphabet: Alpha[]; boards: (FxCell[] | null)[]; fixtures: Fx[]; } describe.skipIf(!ready)('local-eval adapter parity vs Go engine', () => { for (const variant of Object.keys(dawgFile)) { it( variant, () => { const vf: VF = JSON.parse(readFileSync(join(validDir!, `${variant}.fixtures.json`), 'utf8')); const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, dawgFile[variant])))); const v = variant as Variant; // Seed the alphabet cache exactly as the server would (wire entries, lower-cased // letters); the adapter re-encodes through it, just like production. setAlphabet(v, vf.alphabet.map((a) => ({ index: a.index, letter: a.letter, value: a.value }))); const upper = vf.alphabet.map((a) => a.letter.toUpperCase()); const lower = vf.alphabet.map((a) => a.letter); let legalCount = 0; let mismatches = 0; const first: string[] = []; for (let fi = 0; fi < vf.fixtures.length; fi++) { const fx = vf.fixtures[fi]; // Build the client's letter-space board and placements from the fixture. const board: ClientBoard = Array.from({ length: vf.rows }, () => Array.from({ length: vf.cols }, () => null as BoardCell | null), ); for (const cl of vf.boards[fx.board] ?? []) board[cl.R][cl.C] = { letter: upper[cl.Letter], blank: cl.Blank }; const tiles: PlacedTile[] = fx.tiles.map((t) => ({ row: t.R, col: t.C, letter: upper[t.Letter], blank: t.Blank })); const res = evaluateLocal(dawg, v, board, tiles, !fx.ignoreCrossWords); if (fx.legal) legalCount++; let ok = res.legal === fx.legal; if (ok && fx.legal) { const wantWords = [fx.main!, ...(fx.cross ?? [])].map((w) => w.Letters.map((i) => lower[i]).join('')); ok = res.score === fx.score && res.words.length === wantWords.length && res.words.every((w, i) => w === wantWords[i]); } if (!ok) { mismatches++; if (first.length < 8) { first.push(`#${fi} exp legal=${fx.legal} score=${fx.score} got legal=${res.legal} score=${res.score} words=${JSON.stringify(res.words)}`); } } } expect(legalCount, 'fixtures should include legal plays').toBeGreaterThan(0); expect(mismatches, first.join(' | ')).toBe(0); }, 120_000, ); } });