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
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.
93 lines
3.6 KiB
TypeScript
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');
|
|
});
|
|
});
|