import { describe, expect, it } from 'vitest'; import { buildScoresheet, layoutImage, formatFinishedDate, MIN_SIDE } from './gameimage'; import type { GameView, MoveRecord, Seat } from './model'; function seat(n: number, name: string, score: number, winner = false): Seat { return { seat: n, accountId: `a${n}`, displayName: name, score, hintsUsed: 0, isWinner: winner }; } function game(seats: Seat[], over = true): GameView { return { id: 'g1', variant: 'scrabble_ru', dictVersion: 'v1', vsAi: false, unreadChat: false, unreadMessages: false, status: over ? 'finished' : 'active', players: seats.length, toMove: 0, turnTimeoutSecs: 300, multipleWordsPerTurn: false, moveCount: 0, endReason: over ? 'standard' : '', lastActivityUnix: 1_782_997_629, // 2026-07-02 (UTC) seats, }; } function play(player: number, words: string[], score: number, total: number, dir: 'H' | 'V' = 'H'): MoveRecord { return { player, action: 'play', dir, mainRow: 7, mainCol: 7, tiles: [], words, count: words.length, score, total }; } // A non-play move carries the seat's current running total on the wire (engine game.go // sets Total: g.scores[player] on pass/exchange/resign) — the fixture mirrors that. function action(player: number, act: 'pass' | 'exchange' | 'resign', total = 0): MoveRecord { return { player, action: act, dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total }; } const label = (a: string) => `<${a}>`; describe('buildScoresheet', () => { it('builds per-seat header, coordinated play rows and localized actions', () => { const g = game([seat(0, 'Аня', 118, true), seat(1, 'Боб', 92)]); const moves = [ play(0, ['ГРОТ'], 24, 24), action(1, 'exchange'), play(0, ['МЫС'], 18, 42, 'V'), play(1, ['СОН'], 12, 12), ]; const s = buildScoresheet(g, moves, label, { hostname: 'erudit-game.ru', dateLocale: 'ru' }); expect(s.header).toEqual([ { name: 'Аня', score: 118, winner: true }, { name: 'Боб', score: 92, winner: false }, ]); expect(s.rows).toHaveLength(2); expect(s.rows[0][0]).toMatchObject({ kind: 'play', coord: '8H', words: ['ГРОТ'], score: 24 }); expect(s.rows[0][1]).toMatchObject({ kind: 'action', label: '' }); expect(s.rows[1][0]).toMatchObject({ kind: 'play', coord: 'H8' }); // vertical → letter first expect(s.rows[1][1]).toMatchObject({ kind: 'play', coord: '8H', words: ['СОН'] }); expect(s.footer.startsWith('erudit-game.ru · ')).toBe(true); expect(s.footer).toContain('2026'); }); it('derives the endgame rack settlement from final scores vs last running totals', () => { // Seat 0 went out (+6 from the opponent rack), seat 1 lost its rack value (−6). const g = game([seat(0, 'A', 48, true), seat(1, 'B', 24)]); const moves = [play(0, ['X'], 42, 42), play(1, ['Y'], 30, 30)]; const s = buildScoresheet(g, moves, label, { hostname: 'h' }); expect(s.adjustments).toEqual([6, -6]); expect(s.hasAdjustments).toBe(true); }); it('reports no settlement when the final scores match the journal (resignation)', () => { const g = game([seat(0, 'A', 42, true), seat(1, 'B', 30)]); const moves = [play(0, ['X'], 42, 42), play(1, ['Y'], 30, 30), action(1, 'resign', 30)]; const s = buildScoresheet(g, moves, label, { hostname: 'h' }); expect(s.adjustments).toEqual([0, 0]); expect(s.hasAdjustments).toBe(false); }); it('keeps one column per seat for a four-player game', () => { const g = game([seat(0, 'A', 10), seat(1, 'B', 20, true), seat(2, 'C', 5), seat(3, 'D', 0)]); const moves = [play(0, ['X'], 10, 10), play(1, ['Y'], 20, 20), play(2, ['Z'], 5, 5)]; const s = buildScoresheet(g, moves, label, { hostname: 'h' }); expect(s.header).toHaveLength(4); expect(s.rows[0]).toHaveLength(4); expect(s.rows[0][3]).toEqual({ kind: 'empty' }); }); }); describe('layoutImage', () => { const short = (seats = 2) => buildScoresheet( game(Array.from({ length: seats }, (_, i) => seat(i, `P${i}`, 0))), [play(0, ['ГРОТ'], 24, 24)], label, { hostname: 'h' }, ); it('clamps the board to MIN_SIDE for a short game', () => { const l = layoutImage(short()); expect(l.board.side).toBe(MIN_SIDE); expect(l.board.cell).toBeCloseTo(MIN_SIDE / 15); }); it('stretches the board (and the image) to a long scoresheet, keeping the typography fixed', () => { const moves: MoveRecord[] = []; for (let i = 0; i < 80; i++) moves.push(play(i % 2, ['СЛОВО'], 10, 10 * i)); const s = buildScoresheet(game([seat(0, 'A', 300, true), seat(1, 'B', 290)]), moves, label, { hostname: 'h' }); const l = layoutImage(s); expect(l.board.side).toBeGreaterThan(MIN_SIDE); // The column content exactly fills the board height (that is the stretch contract). const content = l.headNameH + l.headScoreH + l.headRuleH + l.rowHeights.reduce((a, b) => a + b, 0) + l.adjRowH; expect(l.board.side).toBe(Math.ceil(content)); expect(l.h).toBe(l.margin + l.axis + l.board.side + l.footerH + l.margin); }); it('gives a row the extra sub-line only when a play formed several words', () => { const s = buildScoresheet( game([seat(0, 'A', 0), seat(1, 'B', 0)]), [play(0, ['ОСА', 'ОМ'], 12, 12), play(1, ['ДОМ'], 8, 8)], label, { hostname: 'h' }, ); const l = layoutImage(s); expect(l.rowHeights[0]).toBe(l.rowH + l.subRowH); expect(l.rowHeights).toHaveLength(1); }); it('widens the sheet per seat', () => { const two = layoutImage(short(2)); const four = layoutImage(short(4)); expect(four.hist.w).toBeGreaterThan(two.hist.w); expect(four.w - four.hist.w).toBe(two.w - two.hist.w); // board block unchanged }); }); describe('formatFinishedDate', () => { it('formats with the given locale (regional convention, long month)', () => { const s = formatFinishedDate(1_782_997_629, 'ru'); expect(s).toContain('2026'); expect(s).toMatch(/июн|июл/); // TZ-dependent day, but always mid-summer }); });