Stage 7 (wip): tests + UI CI

- Vitest units: board replay, placement machine, premium parity, i18n key parity, FlatBuffers codec round-trips (19 tests)
- Playwright smoke (mock transport): guest -> lobby -> board -> place tile -> preview
- ui-test.yaml workflow: check/unit/build + bundle-size budget (67.5KB gzip < 100KB) + chromium e2e
- gateway transcode tests for games.list (seat display_name), pass, hint
- backend integration test for game.ListForAccount
This commit is contained in:
Ilia Denisov
2026-06-03 00:55:38 +02:00
parent 65689b903f
commit 0284c9b83a
10 changed files with 512 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { lastPlayTiles, replay } from './board';
import type { MoveRecord } from './model';
function play(tiles: { row: number; col: number; letter: string; blank: boolean }[]): MoveRecord {
return {
player: 0,
action: 'play',
dir: 'H',
mainRow: tiles[0].row,
mainCol: tiles[0].col,
tiles,
words: [],
count: 0,
score: 0,
total: 0,
};
}
const pass: MoveRecord = {
player: 1,
action: 'pass',
dir: '',
mainRow: 0,
mainCol: 0,
tiles: [],
words: [],
count: 0,
score: 0,
total: 0,
};
describe('board replay', () => {
it('places play tiles and ignores non-play moves', () => {
const moves = [
play([
{ row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: true },
]),
pass,
play([{ row: 8, col: 7, letter: 'C', blank: false }]),
];
const b = replay(moves);
expect(b[7][7]?.letter).toBe('A');
expect(b[7][8]?.blank).toBe(true);
expect(b[8][7]?.letter).toBe('C');
expect(b[0][0]).toBeNull();
expect(b.length).toBe(15);
expect(b[0].length).toBe(15);
});
it('lastPlayTiles returns the most recent play, skipping passes', () => {
const moves = [play([{ row: 7, col: 7, letter: 'A', blank: false }]), pass];
expect(lastPlayTiles(moves)).toHaveLength(1);
expect(lastPlayTiles([pass])).toHaveLength(0);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { Builder, ByteBuffer } from 'flatbuffers';
import { describe, expect, it } from 'vitest';
import * as fb from '../gen/fbs/scrabblefb';
import { decodeGameList, decodeSession, encodeSubmitPlay } from './codec';
describe('codec', () => {
it('encodes a SubmitPlayRequest the gateway can read', () => {
const buf = encodeSubmitPlay('g1', 'H', [
{ row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: true },
]);
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
expect(r.gameId()).toBe('g1');
expect(r.dir()).toBe('H');
expect(r.tilesLength()).toBe(2);
expect(r.tiles(0)?.letter()).toBe('A');
expect(r.tiles(1)?.blank()).toBe(true);
});
it('decodes a Session', () => {
const b = new Builder(64);
const token = b.createString('tok');
const uid = b.createString('u1');
const name = b.createString('Me');
fb.Session.startSession(b);
fb.Session.addToken(b, token);
fb.Session.addUserId(b, uid);
fb.Session.addIsGuest(b, true);
fb.Session.addDisplayName(b, name);
b.finish(fb.Session.endSession(b));
expect(decodeSession(b.asUint8Array())).toEqual({
token: 'tok',
userId: 'u1',
isGuest: true,
displayName: 'Me',
});
});
it('decodes a GameList including nested seat display names', () => {
const b = new Builder(256);
const aid = b.createString('a1');
const dn = b.createString('Ann');
fb.SeatView.startSeatView(b);
fb.SeatView.addSeat(b, 1);
fb.SeatView.addAccountId(b, aid);
fb.SeatView.addScore(b, 13);
fb.SeatView.addHintsUsed(b, 0);
fb.SeatView.addIsWinner(b, false);
fb.SeatView.addDisplayName(b, dn);
const seat = fb.SeatView.endSeatView(b);
const seats = fb.GameView.createSeatsVector(b, [seat]);
const id = b.createString('g1');
const variant = b.createString('english');
const dv = b.createString('v1');
const status = b.createString('active');
const er = b.createString('');
fb.GameView.startGameView(b);
fb.GameView.addId(b, id);
fb.GameView.addVariant(b, variant);
fb.GameView.addDictVersion(b, dv);
fb.GameView.addStatus(b, status);
fb.GameView.addPlayers(b, 2);
fb.GameView.addToMove(b, 0);
fb.GameView.addTurnTimeoutSecs(b, 86400);
fb.GameView.addMoveCount(b, 4);
fb.GameView.addEndReason(b, er);
fb.GameView.addSeats(b, seats);
const game = fb.GameView.endGameView(b);
const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b);
fb.GameList.addGames(b, games);
b.finish(fb.GameList.endGameList(b));
const gl = decodeGameList(b.asUint8Array());
expect(gl.games).toHaveLength(1);
expect(gl.games[0].id).toBe('g1');
expect(gl.games[0].seats[0].displayName).toBe('Ann');
expect(gl.games[0].seats[0].score).toBe(13);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { en } from './i18n/en';
import { ru } from './i18n/ru';
import { errorKey, translate } from './i18n/catalog';
describe('i18n catalog', () => {
it('has identical keys in en and ru', () => {
expect(Object.keys(ru).sort()).toEqual(Object.keys(en).sort());
});
it('interpolates parameters', () => {
expect(translate('en', 'game.bag', { n: 7 })).toBe('Bag 7');
expect(translate('ru', 'game.bag', { n: 7 })).toBe('Мешок 7');
});
it('maps error codes to keys with a generic fallback', () => {
expect(errorKey('not_your_turn')).toBe('error.not_your_turn');
expect(errorKey('totally_unknown')).toBe('error.generic');
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
BLANK,
direction,
newPlacement,
place,
rackView,
recallAt,
reset,
toSubmit,
} from './placement';
const rack = ['A', 'Q', BLANK, 'N', 'I', 'W', 'E'];
describe('placement state machine', () => {
it('places a tile and marks the rack slot used', () => {
const p = place(newPlacement(rack), 0, 7, 7);
expect(p.pending).toHaveLength(1);
expect(rackView(p)[0].used).toBe(true);
expect(rackView(p)[1].used).toBe(false);
});
it('rejects reusing a slot or an occupied cell', () => {
let p = place(newPlacement(rack), 0, 7, 7);
p = place(p, 0, 7, 8); // same slot -> no-op
expect(p.pending).toHaveLength(1);
p = place(p, 1, 7, 7); // occupied cell -> no-op
expect(p.pending).toHaveLength(1);
});
it('requires a letter for a blank slot', () => {
const noLetter = place(newPlacement(rack), 2, 7, 7);
expect(noLetter.pending).toHaveLength(0);
const withLetter = place(newPlacement(rack), 2, 7, 7, 'x');
expect(withLetter.pending[0]).toMatchObject({ letter: 'X', blank: true });
});
it('recalls a tile by cell', () => {
let p = place(newPlacement(rack), 0, 7, 7);
p = recallAt(p, 7, 7);
expect(p.pending).toHaveLength(0);
expect(reset(place(p, 0, 7, 7)).pending).toHaveLength(0);
});
it('infers direction H for a row, V for a column, null for a single tile', () => {
let h = place(newPlacement(rack), 0, 7, 7);
h = place(h, 1, 7, 8);
expect(direction(h)).toBe('H');
let v = place(newPlacement(rack), 0, 7, 7);
v = place(v, 1, 8, 7);
expect(direction(v)).toBe('V');
expect(direction(place(newPlacement(rack), 0, 7, 7))).toBeNull();
});
it('builds a sorted submit payload and honours a direction override', () => {
let p = place(newPlacement(rack), 1, 7, 9);
p = place(p, 0, 7, 7);
const sub = toSubmit(p);
expect(sub?.dir).toBe('H');
expect(sub?.tiles.map((t) => t.col)).toEqual([7, 9]);
expect(toSubmit(place(newPlacement(rack), 0, 7, 7), 'V')?.dir).toBe('V');
expect(toSubmit(newPlacement(rack))).toBeNull();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { alphabet, BOARD_SIZE, centre, premiumGrid, tileValue } from './premiums';
// Parity with scrabble-solver/rules/rules.go: english/russian share standardBoard
// (centre is a double word); erudit shares the geometry but a non-doubling centre.
describe('premium layout', () => {
it('is a 15x15 grid with TW corners', () => {
const g = premiumGrid('english');
expect(g.length).toBe(BOARD_SIZE);
expect(g[0].length).toBe(BOARD_SIZE);
for (const [r, c] of [
[0, 0],
[0, 14],
[14, 0],
[14, 14],
]) {
expect(g[r][c]).toBe('TW');
}
});
it('doubles the centre for standard variants but not for erudit', () => {
expect(centre('english')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('english')[7][7]).toBe('DW');
expect(premiumGrid('russian')[7][7]).toBe('DW');
expect(centre('erudit')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('erudit')[7][7]).toBe('');
});
it('keeps the standard premium counts', () => {
const flat = premiumGrid('english').flat();
const count = (p: string) => flat.filter((x) => x === p).length;
expect(count('TW')).toBe(8);
expect(count('TL')).toBe(12);
expect(count('DL')).toBe(24);
expect(count('DW')).toBe(17); // 16 double-word squares + the centre
});
});
describe('tile values', () => {
it('scores letters per variant and zero for a blank', () => {
expect(tileValue('english', 'A')).toBe(1);
expect(tileValue('english', 'Q')).toBe(10);
expect(tileValue('english', '?')).toBe(0);
expect(tileValue('russian', 'Ф')).toBe(10);
expect(tileValue('erudit', 'Ё')).toBe(0);
});
it('exposes the full alphabet for the blank chooser', () => {
expect(alphabet('english')).toHaveLength(26);
expect(alphabet('russian')).toHaveLength(33);
expect(alphabet('erudit')).toHaveLength(33);
});
});