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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user