Files
scrabble-game/ui/src/lib/result.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

122 lines
5.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { resultBadge, seatMedal } from './result';
import type { GameView, Seat } from './model';
const seat = (s: number, accountId: string, score: number, isWinner = false, resigned = false): Seat => ({
seat: s,
accountId,
displayName: accountId,
score,
hintsUsed: 0,
isWinner,
resigned,
});
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
return {
id: 'g',
variant: 'scrabble_en',
dictVersion: 'v1',
vsAi: false,
unreadChat: false,
unreadMessages: false,
kind: 0,
status,
players: seats.length,
toMove,
turnTimeoutSecs: 0,
multipleWordsPerTurn: true,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
seats,
};
}
describe('resultBadge', () => {
it('active: your move vs opponent', () => {
const g = game([seat(0, 'me', 5), seat(1, 'a', 3)], 'active', 0);
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
});
it('open (awaiting an opponent) reads as in-progress, not a finished result', () => {
const g = game([seat(0, 'me', 0), seat(1, '', 0)], 'open', 0);
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
});
it('finished two-player: victory / defeat / draw', () => {
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({
key: 'result.victory',
emoji: '🏆',
});
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), 'me')).toEqual({
key: 'result.defeat',
emoji: '🥈',
});
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), 'me')).toEqual({
key: 'result.draw',
emoji: '🏅',
});
});
it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => {
// The opponent won by resignation (isWinner) although neither side scored — the lobby
// must read this as a loss, matching the game-detail screen (regression).
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({
key: 'result.defeat',
emoji: '🥈',
});
});
it('finished four-player: places by score', () => {
const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]);
expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' });
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
});
it('tie for the lead (3-4p): a shared victory for the leaders, a placed finish for those below', () => {
// The reported case, viewer-side: two seats tie for the lead (-8), one is last (-14) — no single
// winner(), so this must NOT read as a full draw for everyone.
expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.victory', emoji: '🏆' });
expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.place3', emoji: '🥉' });
});
it('an aborted game reads as a draw for everyone, whatever the scores', () => {
const g = { ...game([seat(0, 'me', 50), seat(1, 'a', 30)]), endReason: 'aborted' };
expect(resultBadge(g, 'me')).toEqual({ key: 'result.draw', emoji: '🏅' });
});
});
describe('seatMedal', () => {
it('is empty for a game still in progress', () => {
expect(seatMedal(game([seat(0, 'a', 5), seat(1, 'b', 3)], 'active'), 0)).toBe('');
});
it('finished: places each seat by final score', () => {
const g = game([seat(0, 'a', 100), seat(1, 'b', 400, true), seat(2, 'c', 300), seat(3, 'd', 200)]);
expect(seatMedal(g, 1)).toBe('🏆'); // 400 → 1st
expect(seatMedal(g, 2)).toBe('🥈'); // 300 → 2nd
expect(seatMedal(g, 3)).toBe('🥉'); // 200 → 3rd
expect(seatMedal(g, 0)).toBe('🏅'); // 100 → 4th
});
it('a tie for the lead shares first place; the rest place below (competition ranking)', () => {
// The owner's case: a scoreless end deducted racks to -8 / -14 / -8, tying seats 0 and 2 for the
// lead — they must SHARE the trophy, not all show the last-place medal (the reported bug).
const g = game([seat(0, 'a', -8), seat(1, 'b', -14), seat(2, 'c', -8)]);
expect(seatMedal(g, 0)).toBe('🏆');
expect(seatMedal(g, 2)).toBe('🏆');
expect(seatMedal(g, 1)).toBe('🥉'); // two seats rank above it → 3rd
});
it('an excluded / resigned seat gets no medal and does not push the others down', () => {
const g = game([seat(0, 'a', 50), seat(1, 'b', 900, false, true), seat(2, 'c', 30)]);
expect(seatMedal(g, 1)).toBe(''); // resigned: no medal, though its frozen score is the highest
expect(seatMedal(g, 0)).toBe('🏆'); // ranked among the rest: highest → 1st
expect(seatMedal(g, 2)).toBe('🥈');
});
});