Stage 7 polish: game rework + board zoom + tests (Parts D/E/F/I)
- Board: fixed-viewport transform-scale zoom (animated) with counter-scaled cqw labels, corner letters, bonus-label modes (boardlabels), contrasting grid lines
- Game: Screen shell + game tab-bar (Draw/Skip/Hint/Shuffle) via HoldConfirm popovers; MakeMove 🏁 + compact popup; rack collapses used slots; hint places tiles on board (placementFromHint) + no_hint_available toast; Scores:N replaces Hints; history slide-down (swipe/click, scroll-locked); check-word alphabet/length limit + in-memory cache + 5s throttle
- backend: no_hint_available result code split + test
- vitest: banner rotator + linkify, resultBadge, boardlabels, placementFromHint (29 tests); Playwright smoke updated; prod bundle ~74 KB gzip
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createBannerRotator, defaultBannerConfig, linkify } from './banner';
|
||||
|
||||
describe('linkify', () => {
|
||||
it('escapes html and renders markdown links', () => {
|
||||
expect(linkify('a < b & c')).toBe('a < b & c');
|
||||
expect(linkify('see [docs](https://x.com) now')).toBe(
|
||||
'see <a href="https://x.com" target="_blank" rel="noopener noreferrer">docs</a> now',
|
||||
);
|
||||
});
|
||||
it('drops a non-http(s) link target (keeps the label)', () => {
|
||||
expect(linkify('[x](ftp://evil)')).toBe('x');
|
||||
expect(linkify('[y](javascript:boom)')).toBe('y');
|
||||
});
|
||||
});
|
||||
|
||||
describe('banner rotator', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('holds a fitting message then advances, and scrolls an overflowing one', () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg = { ...defaultBannerConfig, holdMs: 1000, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 50 };
|
||||
const shown: number[] = [];
|
||||
let scrolled = 0;
|
||||
const overflow = [0, 200]; // item 0 fits, item 1 overflows
|
||||
const r = createBannerRotator(
|
||||
[{ md: 'a' }, { md: 'b' }],
|
||||
{
|
||||
overflowPx: (i) => overflow[i],
|
||||
show: (i) => shown.push(i),
|
||||
scrollTo: () => scrolled++,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
r.start();
|
||||
expect(shown).toEqual([0]);
|
||||
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 0
|
||||
vi.advanceTimersByTime(cfg.holdMs); // advance to item 1
|
||||
expect(shown).toEqual([0, 1]);
|
||||
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 1 (overflows)
|
||||
vi.advanceTimersByTime(cfg.edgePauseMs); // edge pause -> scrollTo
|
||||
expect(scrolled).toBe(1);
|
||||
r.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bonusLabel } from './boardlabels';
|
||||
|
||||
describe('bonusLabel', () => {
|
||||
it('none mode and plain squares have no label', () => {
|
||||
expect(bonusLabel('none', 'TW', 'en')).toBeNull();
|
||||
expect(bonusLabel('beginner', '', 'en')).toBeNull();
|
||||
});
|
||||
|
||||
it('classic is a localized single tag', () => {
|
||||
expect(bonusLabel('classic', 'TW', 'en')).toEqual({ kind: 'single', text: '3W' });
|
||||
expect(bonusLabel('classic', 'DL', 'en')).toEqual({ kind: 'single', text: '2L' });
|
||||
expect(bonusLabel('classic', 'TW', 'ru')).toEqual({ kind: 'single', text: '3С' });
|
||||
expect(bonusLabel('classic', 'DL', 'ru')).toEqual({ kind: 'single', text: '2Б' });
|
||||
});
|
||||
|
||||
it('beginner is a localized split label', () => {
|
||||
expect(bonusLabel('beginner', 'TW', 'en')).toEqual({ kind: 'split', top: '3×', bottom: 'word' });
|
||||
expect(bonusLabel('beginner', 'DL', 'en')).toEqual({ kind: 'split', top: '2×', bottom: 'letter' });
|
||||
expect(bonusLabel('beginner', 'TL', 'ru')).toEqual({ kind: 'split', top: '3×', bottom: 'буква' });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
direction,
|
||||
newPlacement,
|
||||
place,
|
||||
placementFromHint,
|
||||
rackView,
|
||||
recallAt,
|
||||
reset,
|
||||
@@ -62,3 +63,19 @@ describe('placement state machine', () => {
|
||||
expect(toSubmit(newPlacement(rack))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('placementFromHint', () => {
|
||||
it('maps hint letters and blanks onto rack slots', () => {
|
||||
const p = placementFromHint(
|
||||
[
|
||||
{ row: 7, col: 7, letter: 'C', blank: false },
|
||||
{ row: 7, col: 8, letter: 'A', blank: false },
|
||||
{ row: 7, col: 9, letter: 'B', blank: true },
|
||||
],
|
||||
['C', 'A', BLANK, 'T'],
|
||||
);
|
||||
expect(p.pending).toHaveLength(3);
|
||||
expect(p.pending[0]).toMatchObject({ rackIndex: 0, letter: 'C', blank: false });
|
||||
expect(p.pending[2]).toMatchObject({ rackIndex: 2, letter: 'B', blank: true });
|
||||
});
|
||||
});
|
||||
|
||||
+20
-1
@@ -4,7 +4,7 @@
|
||||
// payload. It is board-agnostic (the gateway/engine does full legality validation at
|
||||
// submit), which keeps it trivially unit-testable.
|
||||
|
||||
import type { Direction } from './model';
|
||||
import type { Direction, Tile } from './model';
|
||||
import type { PlacedTile } from './client';
|
||||
|
||||
export interface PendingTile {
|
||||
@@ -36,6 +36,25 @@ export function newPlacement(rack: string[]): Placement {
|
||||
return { rack: [...rack], pending: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* placementFromHint turns a hint move's tiles into a pending placement by matching each
|
||||
* tile to a rack slot (a blank "?" for blank tiles, else the matching letter), so the
|
||||
* player sees the suggested move laid out and decides whether to commit it.
|
||||
*/
|
||||
export function placementFromHint(tiles: Tile[], rack: string[]): Placement {
|
||||
const used = new Set<number>();
|
||||
const pending: PendingTile[] = [];
|
||||
const take = (pred: (letter: string, i: number) => boolean) => rack.findIndex((l, i) => !used.has(i) && pred(l, i));
|
||||
for (const t of tiles) {
|
||||
let idx = t.blank ? take((l) => l === BLANK) : take((l) => l === t.letter.toUpperCase());
|
||||
if (idx < 0) idx = take((l) => l === BLANK); // fall back to a blank
|
||||
if (idx < 0) continue;
|
||||
used.add(idx);
|
||||
pending.push({ rackIndex: idx, row: t.row, col: t.col, letter: t.letter.toUpperCase(), blank: rack[idx] === BLANK });
|
||||
}
|
||||
return { rack: [...rack], pending };
|
||||
}
|
||||
|
||||
function usedIndexes(p: Placement): Set<number> {
|
||||
return new Set(p.pending.map((t) => t.rackIndex));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resultBadge } from './result';
|
||||
import type { GameView, Seat } from './model';
|
||||
|
||||
const seat = (s: number, accountId: string, score: number, isWinner = false): Seat => ({
|
||||
seat: s,
|
||||
accountId,
|
||||
displayName: accountId,
|
||||
score,
|
||||
hintsUsed: 0,
|
||||
isWinner,
|
||||
});
|
||||
|
||||
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
||||
return {
|
||||
id: 'g',
|
||||
variant: 'english',
|
||||
dictVersion: 'v1',
|
||||
status,
|
||||
players: seats.length,
|
||||
toMove,
|
||||
turnTimeoutSecs: 0,
|
||||
moveCount: 0,
|
||||
endReason: '',
|
||||
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('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 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: '🥈' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user