From 0284c9b83a2f8194b394b607407bd297a72f88d4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 3 Jun 2026 00:55:38 +0200 Subject: [PATCH] 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 --- .gitea/workflows/ui-test.yaml | 56 ++++++++++++ backend/internal/inttest/game_test.go | 57 ++++++++++++ gateway/internal/transcode/transcode_test.go | 93 ++++++++++++++++++++ ui/scripts/bundle-size.mjs | 21 +++++ ui/src/lib/board.test.ts | 57 ++++++++++++ ui/src/lib/codec.test.ts | 80 +++++++++++++++++ ui/src/lib/i18n.test.ts | 20 +++++ ui/src/lib/placement.test.ts | 64 ++++++++++++++ ui/src/lib/premiums.test.ts | 53 +++++++++++ ui/vitest.config.ts | 11 +++ 10 files changed, 512 insertions(+) create mode 100644 .gitea/workflows/ui-test.yaml create mode 100644 ui/scripts/bundle-size.mjs create mode 100644 ui/src/lib/board.test.ts create mode 100644 ui/src/lib/codec.test.ts create mode 100644 ui/src/lib/i18n.test.ts create mode 100644 ui/src/lib/placement.test.ts create mode 100644 ui/src/lib/premiums.test.ts create mode 100644 ui/vitest.config.ts diff --git a/.gitea/workflows/ui-test.yaml b/.gitea/workflows/ui-test.yaml new file mode 100644 index 0000000..7f009b7 --- /dev/null +++ b/.gitea/workflows/ui-test.yaml @@ -0,0 +1,56 @@ +name: Tests · UI + +# Hermetic UI checks: type-check, Vitest unit tests, production build with a +# bundle-size budget, and a Playwright smoke against the in-memory mock transport +# (no backend/gateway/Postgres). The committed src/gen/ codegen is built, not +# regenerated (the same model as the Go committed jet/fbs output). + +on: + push: + paths: + - 'ui/**' + - '.gitea/workflows/ui-test.yaml' + pull_request: + paths: + - 'ui/**' + - '.gitea/workflows/ui-test.yaml' + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: ui + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + run: npm install -g pnpm@11.0.9 + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Type-check + run: pnpm run check + + - name: Unit tests + run: pnpm run test:unit + + - name: Build + run: pnpm run build + + - name: Bundle-size budget + run: node scripts/bundle-size.mjs + + - name: Install Playwright browser + run: pnpm exec playwright install --with-deps chromium + + - name: E2E smoke (mock) + run: pnpm run test:e2e diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 0f9a22a..00ba2af 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -86,6 +86,63 @@ func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWor return wins, losses, draws, maxGame, maxWord, true } +// TestListForAccount checks the lobby "my games" query: it returns exactly the +// games the account is seated in (each with its seats), and nothing for an outsider. +func TestListForAccount(t *testing.T) { + ctx := context.Background() + svc := newGameService() + me, opp := provisionAccount(t), provisionAccount(t) + + g1, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{me, opp}, TurnTimeout: 24 * time.Hour, Seed: 1, + }) + if err != nil { + t.Fatalf("create g1: %v", err) + } + g2, err := svc.Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{opp, me}, TurnTimeout: 24 * time.Hour, Seed: 2, + }) + if err != nil { + t.Fatalf("create g2: %v", err) + } + + games, err := svc.ListForAccount(ctx, me) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(games) != 2 { + t.Fatalf("got %d games, want 2", len(games)) + } + seen := map[uuid.UUID]bool{} + for _, g := range games { + seen[g.ID] = true + if len(g.Seats) != 2 { + t.Errorf("game %s has %d seats, want 2", g.ID, len(g.Seats)) + } + seated := false + for _, s := range g.Seats { + if s.AccountID == me { + seated = true + } + } + if !seated { + t.Errorf("account not found among seats of returned game %s", g.ID) + } + } + if !seen[g1.ID] || !seen[g2.ID] { + t.Errorf("returned games %v missing g1=%s or g2=%s", seen, g1.ID, g2.ID) + } + + other := provisionAccount(t) + og, err := svc.ListForAccount(ctx, other) + if err != nil { + t.Fatalf("list other: %v", err) + } + if len(og) != 0 { + t.Errorf("outsider sees %d games, want 0", len(og)) + } +} + // TestGameLifecycleAndStats drives a greedy two-player game to its natural end // through the service and checks the finish state and statistics. func TestGameLifecycleAndStats(t *testing.T) { diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index cb644b7..2af5a4b 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -139,3 +139,96 @@ func TestDomainErrorSurfacesBackendCode(t *testing.T) { t.Fatalf("DomainCode = (%q, %v), want (not_your_turn, true)", code, ok) } } + +// gameActionPayload builds a GameActionRequest payload (pass / resign / hint / etc.). +func gameActionPayload(gameID string) []byte { + b := flatbuffers.NewBuilder(32) + gid := b.CreateString(gameID) + fb.GameActionRequestStart(b) + fb.GameActionRequestAddGameId(b, gid) + b.Finish(fb.GameActionRequestEnd(b)) + return b.FinishedBytes() +} + +func TestGamesListRoundTripDecodesSeatNames(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-User-ID"); got != "u-9" { + t.Errorf("X-User-ID = %q, want u-9", got) + } + if r.URL.Path != "/api/v1/user/games" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgGamesList) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-9"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + gl := fb.GetRootAsGameList(payload, 0) + if gl.GamesLength() != 1 { + t.Fatalf("games length = %d, want 1", gl.GamesLength()) + } + var g fb.GameView + gl.Games(&g, 0) + if string(g.Id()) != "g-1" { + t.Errorf("game id = %q, want g-1", g.Id()) + } + var seat fb.SeatView + g.Seats(&seat, 1) + if string(seat.DisplayName()) != "Ann" { + t.Errorf("seat display name = %q, want Ann", seat.DisplayName()) + } +} + +func TestPassRoundTrip(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/games/g-2/pass" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"move":{"player":0,"action":"pass"},"game":{"id":"g-2","status":"active","seats":[{"seat":0,"account_id":"u-1","display_name":"You"}]}}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgGamePass) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-2")}) + if err != nil { + t.Fatalf("handler: %v", err) + } + mr := fb.GetRootAsMoveResult(payload, 0) + var move fb.MoveRecord + mr.Move(&move) + if string(move.Action()) != "pass" { + t.Errorf("action = %q, want pass", move.Action()) + } +} + +func TestHintRoundTrip(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/games/g-3/hint" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"move":{"player":0,"action":"play","words":["CAT"],"score":9},"hints_remaining":2}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgGameHint) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-3")}) + if err != nil { + t.Fatalf("handler: %v", err) + } + hr := fb.GetRootAsHintResult(payload, 0) + if hr.HintsRemaining() != 2 { + t.Errorf("hints remaining = %d, want 2", hr.HintsRemaining()) + } + var move fb.MoveRecord + hr.Move(&move) + if move.Score() != 9 { + t.Errorf("hint move score = %d, want 9", move.Score()) + } +} diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs new file mode 100644 index 0000000..69bc781 --- /dev/null +++ b/ui/scripts/bundle-size.mjs @@ -0,0 +1,21 @@ +// Bundle-size budget gate. Sums the gzipped size of the built app JS and fails if it +// exceeds the budget — a guard against an accidental heavy dependency. The real +// transport build is ~69 KB gzip today; the budget leaves headroom. +import { readdirSync, readFileSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; + +const BUDGET = 100 * 1024; // gzip bytes for app JS +const dir = 'dist/assets'; + +let total = 0; +for (const f of readdirSync(dir)) { + if (!f.endsWith('.js')) continue; + const gz = gzipSync(readFileSync(`${dir}/${f}`)).length; + total += gz; + console.log(`${f}: ${(gz / 1024).toFixed(1)} KB gzip`); +} +console.log(`total app JS: ${(total / 1024).toFixed(1)} KB gzip (budget ${BUDGET / 1024} KB)`); +if (total > BUDGET) { + console.error('bundle exceeds size budget'); + process.exit(1); +} diff --git a/ui/src/lib/board.test.ts b/ui/src/lib/board.test.ts new file mode 100644 index 0000000..68bc777 --- /dev/null +++ b/ui/src/lib/board.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts new file mode 100644 index 0000000..ef28f5d --- /dev/null +++ b/ui/src/lib/codec.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/i18n.test.ts b/ui/src/lib/i18n.test.ts new file mode 100644 index 0000000..9383297 --- /dev/null +++ b/ui/src/lib/i18n.test.ts @@ -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'); + }); +}); diff --git a/ui/src/lib/placement.test.ts b/ui/src/lib/placement.test.ts new file mode 100644 index 0000000..428ce24 --- /dev/null +++ b/ui/src/lib/placement.test.ts @@ -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(); + }); +}); diff --git a/ui/src/lib/premiums.test.ts b/ui/src/lib/premiums.test.ts new file mode 100644 index 0000000..b7b8108 --- /dev/null +++ b/ui/src/lib/premiums.test.ts @@ -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); + }); +}); diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts new file mode 100644 index 0000000..2a045c1 --- /dev/null +++ b/ui/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// Unit tests cover the pure client logic (no Svelte components, no network): the +// board replay, the placement state machine, premium/value maps, the FlatBuffers +// codec and the i18n catalog. Component/interaction coverage is the Playwright smoke. +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +});