Files
scrabble-game/ui/src/lib/history.test.ts
T
Ilia Denisov a0eacf3011
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
feat(game): finished-game export as a PNG image behind a format chooser
The history header's export button now opens a chooser — Telegram's
native popup inside Telegram, the app's own modal elsewhere — offering
the GCG file and a new client-rendered PNG of the final position
(lib/gameimage, Canvas 2D, lazy dynamic import, zero dependencies):
light theme, classic A..O/1..15 axes, label-free premium fills, and a
fixed-typography per-seat scoresheet with GCG-style move coordinates,
multi-word sub-lines, endgame rack-settlement row, winner trophy and a
hostname + device-locale finish date footer; a long game stretches the
board, never the typography.

Delivery mirrors the GCG rules (Web Share with no blob fallback, else
download) except on Android Telegram/VK WebViews and the desktop VK
iframe, where a binary PNG has no clipboard-text fallback: those get a
preview modal with a long-press/right-click save hint and a copy-image
button where ClipboardItem exists.
2026-07-02 16:20:35 +02:00

93 lines
3.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { historyGrid, moveCoord, type HistoryCell } from './history';
import type { MoveRecord } from './model';
function play(player: number, words: string[], score: number): MoveRecord {
return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words, count: words.length, score, total: 0 };
}
function action(player: number, act: 'pass' | 'exchange' | 'resign' | 'timeout'): MoveRecord {
return { player, action: act, dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
}
function kinds(grid: HistoryCell[][]): string[][] {
return grid.map((row) => row.map((c) => c.kind));
}
describe('historyGrid', () => {
it('lays moves into per-seat columns and marks the awaited opponent as thinking', () => {
const moves = [play(0, ['ДОМ'], 12), action(1, 'pass'), play(0, ['ОСА', 'ОМ'], 12)];
const grid = historyGrid(moves, 2, 1); // seat 1 is to move
expect(kinds(grid)).toEqual([
['play', 'action'],
['play', 'thinking'],
]);
expect(grid[0][0]).toMatchObject({ kind: 'play', words: ['ДОМ'], score: 12 });
expect(grid[1][0]).toMatchObject({ kind: 'play', words: ['ОСА', 'ОМ'], score: 12 });
expect(grid[0][1]).toMatchObject({ kind: 'action', action: 'pass' });
expect(grid[1][1]).toMatchObject({ kind: 'thinking', player: 1 });
});
it('renders no thinking cell when thinkingSeat is null (game over / own turn)', () => {
const moves = [play(0, ['ДОМ'], 12), action(1, 'pass'), play(0, ['ОСА'], 6)];
const grid = historyGrid(moves, 2, null);
expect(kinds(grid)).toEqual([
['play', 'action'],
['play', 'empty'],
]);
});
it('fills ragged columns with empty cells (a seat that stopped moving)', () => {
const moves = [
play(0, ['ДОМ'], 12),
play(1, ['КОТ'], 10),
action(2, 'resign'),
play(0, ['ОСА'], 6),
play(1, ['ЛИС'], 8),
];
const grid = historyGrid(moves, 3, null);
expect(kinds(grid)).toEqual([
['play', 'play', 'action'],
['play', 'play', 'empty'],
]);
expect(grid[0][2]).toMatchObject({ kind: 'action', action: 'resign' });
});
it('shows a lone thinking cell for the first mover on an empty journal', () => {
expect(kinds(historyGrid([], 2, 0))).toEqual([['thinking', 'empty']]);
});
it('produces an empty grid for an empty journal with no awaited mover', () => {
expect(historyGrid([], 2, null)).toEqual([]);
});
it('ignores moves with an out-of-range seat', () => {
const grid = historyGrid([play(5, ['X'], 1), play(0, ['ДОМ'], 12)], 2, null);
expect(kinds(grid)).toEqual([['play', 'empty']]);
expect(grid[0][0]).toMatchObject({ words: ['ДОМ'] });
});
it('stamps a play cell with its classic coordinate', () => {
const grid = historyGrid([play(0, ['ДОМ'], 12)], 1, null);
expect(grid[0][0]).toMatchObject({ kind: 'play', coord: '8H' });
});
});
describe('moveCoord', () => {
// The classic notation the GCG export uses (backend gcgPos): an across play is
// row-then-column ("8G"), a down play column-then-row ("H8").
it('puts the row number first for an across play', () => {
expect(moveCoord({ dir: 'H', mainRow: 7, mainCol: 6 })).toBe('8G');
});
it('puts the column letter first for a down play', () => {
expect(moveCoord({ dir: 'V', mainRow: 7, mainCol: 7 })).toBe('H8');
});
it('covers the board corners', () => {
expect(moveCoord({ dir: 'H', mainRow: 0, mainCol: 0 })).toBe('1A');
expect(moveCoord({ dir: 'V', mainRow: 14, mainCol: 14 })).toBe('O15');
});
});