Stage 7: regression tests for the polished UI (logic + behaviour)
Tests · UI / test (push) Successful in 14s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 14s

Lock the polish branch's behaviour so a future UI edit surfaces as a failing
assertion to re-agree or fix.

Unit (vitest, node env):
- placement: recallIndex, cellOccupied/isBlankSlot, non-linear direction, the
  single-tile submit default, and placementFromHint blank-fallback / rack-exhausted.
- banner: the marquee scroll-cycle repeat-then-advance, stop(), root-relative and
  multiple links.
- client.GatewayError. Extract the check-word constraints out of Game.svelte into a
  pure lib/checkword.ts (sanitize + canCheck) and cover them.

E2E (playwright mock, Chromium + WebKit):
- commit via the 🏁 control, history slide-down + close, the exchange dialog,
  check-word input sanitising + verdict, resign-to-finished, and the Settings
  board-label mode changing the on-board labels.
This commit is contained in:
Ilia Denisov
2026-06-03 17:33:47 +02:00
parent 4c475f2b0e
commit f8f7d39364
7 changed files with 267 additions and 5 deletions
+3 -5
View File
@@ -15,6 +15,7 @@
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
import { replay } from '../lib/board';
import { alphabet, centre, premiumGrid } from '../lib/premiums';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import {
BLANK,
newPlacement,
@@ -311,15 +312,12 @@
checkOpen = true;
}
function onCheckInput(e: Event) {
const allowed = new Set(alphabet(variant));
const raw = (e.target as HTMLInputElement).value.toUpperCase();
checkWord = Array.from(raw).filter((ch) => allowed.has(ch)).slice(0, 15).join('');
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabet(variant));
}
// Check is disabled while cooling down, for an already-checked word, or an out-of-range
// length. The input filter already restricts to the variant's alphabet.
function canCheck(): boolean {
const w = checkWord.trim();
return w.length >= 2 && w.length <= 15 && !checkedWords.has(w.toUpperCase()) && !cooling;
return canCheckWord(checkWord, checkedWords.has(checkWord.trim().toUpperCase()), cooling);
}
async function runCheck() {
if (!canCheck()) return;
+44
View File
@@ -12,6 +12,12 @@ describe('linkify', () => {
expect(linkify('[x](ftp://evil)')).toBe('x');
expect(linkify('[y](javascript:boom)')).toBe('y');
});
it('keeps root-relative links and renders several in one string', () => {
expect(linkify('go [home](/lobby) or [docs](https://x.com)')).toBe(
'go <a href="/lobby" target="_blank" rel="noopener noreferrer">home</a> or ' +
'<a href="https://x.com" target="_blank" rel="noopener noreferrer">docs</a>',
);
});
});
describe('banner rotator', () => {
@@ -43,4 +49,42 @@ describe('banner rotator', () => {
expect(scrolled).toBe(1);
r.stop();
});
it('repeats the scroll cycle while under holdMs, then advances', () => {
vi.useFakeTimers();
const cfg = { holdMs: 2500, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 100 };
const shown: number[] = [];
let scrolls = 0;
const r = createBannerRotator(
[{ md: 'long' }, { md: 'short' }],
{ overflowPx: (i) => (i === 0 ? 100 : 0), show: (i) => shown.push(i), scrollTo: () => scrolls++ },
cfg,
);
r.start();
vi.advanceTimersByTime(110); // fade(10) + edgePause(100) -> first scroll
expect(scrolls).toBe(1);
expect(shown).toEqual([0]);
vi.advanceTimersByTime(1200); // scrollDur(1000) + edgePause + edgePause -> re-show + second scroll
expect(scrolls).toBe(2);
expect(shown).toEqual([0, 0]); // re-shown to reset scroll, still item 0 (under holdMs)
vi.advanceTimersByTime(10_000);
expect(shown).toContain(1); // eventually exceeds holdMs and advances to the fitting message
r.stop();
});
it('stop() halts further advancement', () => {
vi.useFakeTimers();
const cfg = { ...defaultBannerConfig, holdMs: 100, fadeMs: 5, edgePauseMs: 5 };
const shown: number[] = [];
const r = createBannerRotator(
[{ md: 'a' }, { md: 'b' }],
{ overflowPx: () => 0, show: (i) => shown.push(i), scrollTo: () => {} },
cfg,
);
r.start();
vi.advanceTimersByTime(cfg.fadeMs);
r.stop();
vi.advanceTimersByTime(10_000);
expect(shown).toEqual([0]);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { canCheckWord, MAX_WORD_LEN, sanitizeCheckWord } from './checkword';
const EN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
describe('sanitizeCheckWord', () => {
it('upper-cases and keeps only letters of the alphabet', () => {
expect(sanitizeCheckWord('ca7t!', EN)).toBe('CAT');
expect(sanitizeCheckWord(' Hi 9 ', EN)).toBe('HI');
});
it('drops characters outside the active alphabet', () => {
expect(sanitizeCheckWord('cat', ['C', 'A'])).toBe('CA'); // T not in this alphabet
const RU = 'КОТ'.split('');
expect(sanitizeCheckWord('коt', RU)).toBe('КО'); // cyrillic kept, latin "t" dropped
});
it('caps the length at MAX_WORD_LEN', () => {
expect(sanitizeCheckWord('A'.repeat(30), EN)).toHaveLength(MAX_WORD_LEN);
});
});
describe('canCheckWord', () => {
it('allows a fresh, in-range word', () => {
expect(canCheckWord('CAT', false, false)).toBe(true);
});
it('rejects an out-of-range length', () => {
expect(canCheckWord('A', false, false)).toBe(false); // too short
expect(canCheckWord('A'.repeat(MAX_WORD_LEN + 1), false, false)).toBe(false); // too long
});
it('rejects an already-checked word or a cooling-down state', () => {
expect(canCheckWord('CAT', true, false)).toBe(false);
expect(canCheckWord('CAT', false, true)).toBe(false);
});
it('trims surrounding whitespace before measuring length', () => {
expect(canCheckWord(' ok ', false, false)).toBe(true);
expect(canCheckWord(' a ', false, false)).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
// Pure helpers for the in-game "check a word" panel: input sanitising and the gate on
// when a check may be sent. Kept separate from Game.svelte so the constraints (the
// variant alphabet, the length bounds, the answered-word cache and the cool-down
// throttle) are unit-testable and stay in lockstep with the UI.
/** The longest word that fits on a standard 15-cell board line. */
export const MAX_WORD_LEN = 15;
/** The shortest word worth checking. */
export const MIN_WORD_LEN = 2;
/**
* sanitizeCheckWord upper-cases the raw input and keeps only characters of the active
* variant's alphabet, capped at MAX_WORD_LEN — so the field can never hold something the
* dictionary could not contain.
*/
export function sanitizeCheckWord(raw: string, alphabet: string[]): string {
const allowed = new Set(alphabet);
return Array.from(raw.toUpperCase())
.filter((ch) => allowed.has(ch))
.slice(0, MAX_WORD_LEN)
.join('');
}
/**
* canCheckWord gates the Check action: the trimmed word must be of valid length, must not
* have been answered already (cached), and must not fall inside the cool-down window.
*/
export function canCheckWord(word: string, alreadyChecked: boolean, cooling: boolean): boolean {
const w = word.trim();
return w.length >= MIN_WORD_LEN && w.length <= MAX_WORD_LEN && !alreadyChecked && !cooling;
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { GatewayError } from './client';
describe('GatewayError', () => {
it('carries a stable code and is a real Error', () => {
const e = new GatewayError('no_hint_available');
expect(e).toBeInstanceOf(Error);
expect(e.name).toBe('GatewayError');
expect(e.code).toBe('no_hint_available');
expect(e.message).toBe('no_hint_available'); // message defaults to the code
});
it('keeps a custom message while the code stays the i18n lookup key', () => {
const e = new GatewayError('not_your_turn', 'It is not your turn');
expect(e.code).toBe('not_your_turn');
expect(e.message).toBe('It is not your turn');
});
});
+43
View File
@@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest';
import {
BLANK,
cellOccupied,
direction,
isBlankSlot,
newPlacement,
place,
placementFromHint,
rackView,
recallAt,
recallIndex,
reset,
toSubmit,
} from './placement';
@@ -62,6 +65,29 @@ describe('placement state machine', () => {
expect(toSubmit(place(newPlacement(rack), 0, 7, 7), 'V')?.dir).toBe('V');
expect(toSubmit(newPlacement(rack))).toBeNull();
});
it('recalls a tile by rack index and reports occupied cells / blank slots', () => {
let p = place(newPlacement(rack), 0, 7, 7);
p = place(p, 1, 7, 8);
expect(cellOccupied(p, 7, 7)).toBe(true);
expect(cellOccupied(p, 6, 6)).toBe(false);
p = recallIndex(p, 0);
expect(p.pending.map((t) => t.rackIndex)).toEqual([1]);
expect(isBlankSlot(newPlacement(rack), 2)).toBe(true); // '?' slot
expect(isBlankSlot(newPlacement(rack), 0)).toBe(false);
});
it('treats a non-linear placement as no inferred direction', () => {
let p = place(newPlacement(rack), 0, 7, 7);
p = place(p, 1, 8, 8); // diagonal
expect(direction(p)).toBeNull();
});
it('defaults a single-tile submit to H without an override', () => {
const sub = toSubmit(place(newPlacement(rack), 0, 7, 7));
expect(sub?.dir).toBe('H');
expect(sub?.tiles).toHaveLength(1);
});
});
describe('placementFromHint', () => {
@@ -78,4 +104,21 @@ describe('placementFromHint', () => {
expect(p.pending[0]).toMatchObject({ rackIndex: 0, letter: 'C', blank: false });
expect(p.pending[2]).toMatchObject({ rackIndex: 2, letter: 'B', blank: true });
});
it('falls back to a blank slot when the hint letter is not in the rack', () => {
const p = placementFromHint([{ row: 7, col: 7, letter: 'Z', blank: false }], ['A', BLANK]);
expect(p.pending).toHaveLength(1);
expect(p.pending[0]).toMatchObject({ rackIndex: 1, letter: 'Z', blank: true });
});
it('skips hint tiles once the rack is exhausted', () => {
const p = placementFromHint(
[
{ row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: false },
],
['A'],
);
expect(p.pending.map((t) => t.letter)).toEqual(['A']);
});
});