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