UI: fix last-move highlight, localize move history, clamp zoom overscroll
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 41s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
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 41s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
- Highlight tracks the last move overall (not the last word): a trailing pass/exchange now highlights nothing, so the board no longer lights up the opponent's old word after our own empty move. - Make the highlight event-driven: refreshed only on a real game event (open/refresh, opponent move, our own committed move) and dismissed the moment composing starts, so recalling a just-placed tile never re-triggers it. - Localize non-play move-history labels via new move.* catalog keys (pass/exchange/resign/timeout); the label printed the raw English action. - Clamp the zoomed board's pan at its edge (overscroll-behavior: none), removing the native rubber-band past the content. Tests: lastMoveCells unit coverage (trailing pass/exchange -> empty), i18n RU label assertions, an e2e overscroll-contract check on the zoomed viewport.
This commit is contained in:
+30
-16
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { lastPlayTiles, replay } from './board';
|
||||
import { lastMoveCells, replay } from './board';
|
||||
import type { MoveRecord } from './model';
|
||||
|
||||
function play(tiles: { row: number; col: number; letter: string; blank: boolean }[]): MoveRecord {
|
||||
@@ -17,18 +17,11 @@ function play(tiles: { row: number; col: number; letter: string; blank: boolean
|
||||
};
|
||||
}
|
||||
|
||||
const pass: MoveRecord = {
|
||||
player: 1,
|
||||
action: 'pass',
|
||||
dir: '',
|
||||
mainRow: 0,
|
||||
mainCol: 0,
|
||||
tiles: [],
|
||||
words: [],
|
||||
count: 0,
|
||||
score: 0,
|
||||
total: 0,
|
||||
};
|
||||
function simple(action: 'pass' | 'exchange'): MoveRecord {
|
||||
return { player: 1, action, dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
|
||||
}
|
||||
|
||||
const pass = simple('pass');
|
||||
|
||||
describe('board replay', () => {
|
||||
it('places play tiles and ignores non-play moves', () => {
|
||||
@@ -48,10 +41,31 @@ describe('board replay', () => {
|
||||
expect(b.length).toBe(15);
|
||||
expect(b[0].length).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
it('lastPlayTiles returns the most recent play, skipping passes', () => {
|
||||
describe('lastMoveCells', () => {
|
||||
it('returns the last move cells when the last move is a play', () => {
|
||||
const moves = [
|
||||
pass,
|
||||
play([
|
||||
{ row: 7, col: 7, letter: 'A', blank: false },
|
||||
{ row: 7, col: 8, letter: 'B', blank: false },
|
||||
]),
|
||||
];
|
||||
expect(lastMoveCells(moves)).toEqual(new Set(['7,7', '7,8']));
|
||||
});
|
||||
|
||||
it('highlights nothing when the last move is a pass after a play', () => {
|
||||
const moves = [play([{ row: 7, col: 7, letter: 'A', blank: false }]), pass];
|
||||
expect(lastPlayTiles(moves)).toHaveLength(1);
|
||||
expect(lastPlayTiles([pass])).toHaveLength(0);
|
||||
expect(lastMoveCells(moves).size).toBe(0);
|
||||
});
|
||||
|
||||
it('highlights nothing when the last move is an exchange', () => {
|
||||
const moves = [play([{ row: 7, col: 7, letter: 'A', blank: false }]), simple('exchange')];
|
||||
expect(lastMoveCells(moves).size).toBe(0);
|
||||
});
|
||||
|
||||
it('highlights nothing for an empty journal', () => {
|
||||
expect(lastMoveCells([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+8
-7
@@ -3,7 +3,7 @@
|
||||
// the dictionary-independent history invariant (ARCHITECTURE §9.1): apply each play's
|
||||
// placed tiles onto an empty grid.
|
||||
|
||||
import type { MoveRecord, Tile } from './model';
|
||||
import type { MoveRecord } from './model';
|
||||
import { BOARD_SIZE } from './premiums';
|
||||
|
||||
export interface BoardCell {
|
||||
@@ -36,10 +36,11 @@ export function replay(moves: MoveRecord[]): Board {
|
||||
return b;
|
||||
}
|
||||
|
||||
/** lastPlayTiles returns the tiles of the most recent play (for highlighting). */
|
||||
export function lastPlayTiles(moves: MoveRecord[]): Tile[] {
|
||||
for (let i = moves.length - 1; i >= 0; i--) {
|
||||
if (moves[i].action === 'play') return moves[i].tiles;
|
||||
}
|
||||
return [];
|
||||
/** lastMoveCells returns the cells of the last move's tiles (as "row,col" keys), but only
|
||||
* when that move placed tiles (a play). A trailing pass/exchange/resign/timeout highlights
|
||||
* nothing — the recent-move highlight tracks the last move overall, not the last word. */
|
||||
export function lastMoveCells(moves: MoveRecord[]): Set<string> {
|
||||
const last = moves.length ? moves[moves.length - 1] : null;
|
||||
if (!last || last.action !== 'play') return new Set();
|
||||
return new Set(last.tiles.map((t) => `${t.row},${t.col}`));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ describe('i18n catalog', () => {
|
||||
expect(translate('ru', 'game.bag', { n: 7 })).toBe('7 в мешке');
|
||||
});
|
||||
|
||||
it('localizes move-history action labels', () => {
|
||||
expect(translate('ru', 'move.exchange')).toBe('Обмен');
|
||||
expect(translate('ru', 'move.pass')).toBe('Пас');
|
||||
expect(translate('en', 'move.exchange')).toBe('Exchange');
|
||||
});
|
||||
|
||||
it('maps error codes to keys with a generic fallback', () => {
|
||||
expect(errorKey('not_your_turn')).toBe('error.not_your_turn');
|
||||
expect(errorKey('totally_unknown')).toBe('error.generic');
|
||||
|
||||
@@ -88,6 +88,11 @@ export const en = {
|
||||
'game.noHintOptions': 'No options with your letters.',
|
||||
'game.scores': 'Scores: {n}',
|
||||
|
||||
'move.pass': 'Pass',
|
||||
'move.exchange': 'Exchange',
|
||||
'move.resign': 'Resigned',
|
||||
'move.timeout': 'Timed out',
|
||||
|
||||
'result.victory': 'Victory',
|
||||
'result.defeat': 'Defeat',
|
||||
'result.draw': 'Draw',
|
||||
|
||||
@@ -89,6 +89,11 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
||||
'game.scores': 'Очков: {n}',
|
||||
|
||||
'move.pass': 'Пас',
|
||||
'move.exchange': 'Обмен',
|
||||
'move.resign': 'Сдача',
|
||||
'move.timeout': 'Тайм-аут',
|
||||
|
||||
'result.victory': 'Победа',
|
||||
'result.defeat': 'Поражение',
|
||||
'result.draw': 'Ничья',
|
||||
|
||||
Reference in New Issue
Block a user