Files
scrabble-game/ui/src/lib/gameimage.test.ts
T
Ilia Denisov 3306a016a0
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 28s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
feat(ui): per-kind active-game limit lock on the New Game screen
Carry the caller's per-tier active-game caps and each game's kind on the
wire (Profile.game_limits + GameView.kind, additive FBS + gateway transcode
+ client codec, committed regen). The New Game screen counts the player's
active games per kind from the lobby and locks a capped start: an outline
button with a lock that opens a funnel modal instead of a game -- a sign-in
prompt for a guest, a "finish a current game first" notice for a signed-in
account (native Telegram popup, in-app modal elsewhere). The lock lifts via
the existing profile refetch after a guest->durable upgrade.

Remove the lobby's old at_game_limit New-Game tab disable + notice: the flag
(now the random-kind cap) conflicted with the per-kind lock -- it hid the
screen where the lock lives and wrongly blocked an unfulfilled kind. The New
Game tab is always enabled; the per-kind start lock is the only gate. The
at_game_limit wire field stays (unused by the client) for a later cleanup.

Tests: client lock logic + codec kind/game_limits roundtrip + gateway
transcode encode + native popup builders (unit); a mock e2e for the lock
badge and the modal.
2026-07-10 10:09:45 +02:00

148 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
kind: 0,
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
});
});