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.
147 lines
6.0 KiB
TypeScript
147 lines
6.0 KiB
TypeScript
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: '<exchange>' });
|
||
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
|
||
});
|
||
});
|