Stage 7: regression tests for the polished UI (logic + behaviour)
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:
@@ -0,0 +1,86 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
// Behaviour/display coverage for the polished game screen, driven entirely by the mock
|
||||
// transport (no backend). These lock the round-1..4 interactions so future UI edits
|
||||
// surface as a failing assertion — to be re-agreed or fixed. The pure logic behind them
|
||||
// (placement, check-word, board labels, result badges) is unit-tested separately.
|
||||
|
||||
async function openGame(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
}
|
||||
|
||||
test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => {
|
||||
await openGame(page);
|
||||
await page.locator('.rack .tile').first().click();
|
||||
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
||||
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
||||
|
||||
await page.locator('.make').click(); // open the MakeMove popover (short tap)
|
||||
await page.locator('.pop.go').click(); // "Make move ✅"
|
||||
|
||||
// After the commit the placement is cleared: no pending tile, no 🏁 control.
|
||||
await expect(page.locator('[data-cell].pending')).toHaveCount(0);
|
||||
await expect(page.locator('.make')).toBeHidden();
|
||||
});
|
||||
|
||||
test('history slides the board down and closes on a board tap', async ({ page }) => {
|
||||
await openGame(page);
|
||||
await page.locator('.burger').click();
|
||||
await page.locator('.dropdown button').nth(0).click(); // History
|
||||
await expect(page.locator('.history')).toBeVisible();
|
||||
await expect(page.locator('.boardwrap.slid')).toBeVisible();
|
||||
|
||||
await page.locator('.boardwrap').click(); // tapping the board closes it
|
||||
await expect(page.locator('.history')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Draw opens the exchange dialog and confirms a selection', async ({ page }) => {
|
||||
await openGame(page);
|
||||
await page.locator('button:has-text("🔄")').click(); // Draw tab
|
||||
await expect(page.locator('.exch')).toBeVisible();
|
||||
|
||||
await page.locator('.etile').first().click();
|
||||
await expect(page.locator('.etile.sel')).toHaveCount(1);
|
||||
await page.locator('button.confirm').click();
|
||||
await expect(page.locator('.exch')).toBeHidden();
|
||||
});
|
||||
|
||||
test('check-word sanitises input and shows a verdict', async ({ page }) => {
|
||||
await openGame(page);
|
||||
await page.locator('.burger').click();
|
||||
await page.locator('.dropdown button').nth(2).click(); // Check word
|
||||
|
||||
const input = page.locator('.check input');
|
||||
await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased
|
||||
await expect(input).toHaveValue('QZA');
|
||||
|
||||
await page.locator('.check button').click(); // Check (enabled: length 3)
|
||||
await expect(page.locator('.ok, .bad')).toBeVisible();
|
||||
});
|
||||
|
||||
test('dropping the game ends it and shows the result', async ({ page }) => {
|
||||
await openGame(page);
|
||||
await page.locator('.burger').click();
|
||||
await page.locator('.dropdown button').nth(3).click(); // Drop game
|
||||
await page.locator('button.danger').click(); // confirm in the modal
|
||||
await expect(page.locator('.status .over')).toBeVisible();
|
||||
});
|
||||
|
||||
test('the board-label mode in Settings changes the on-board labels', async ({ page }) => {
|
||||
await openGame(page);
|
||||
// beginner (default) renders split "3× / word" labels.
|
||||
await expect(page.locator('.bsplit').first()).toBeVisible();
|
||||
await expect(page.locator('.b1')).toHaveCount(0);
|
||||
|
||||
// Switch to "classic" in Settings (in-SPA hash nav keeps the guest session).
|
||||
await page.evaluate(() => (location.hash = '/settings'));
|
||||
await page.locator('.seg').nth(2).locator('.opt').nth(1).click(); // board labels -> classic
|
||||
await page.evaluate(() => (location.hash = '/game/g1'));
|
||||
|
||||
// classic renders single "3W"/"2L" labels and no split labels.
|
||||
await expect(page.locator('.b1').first()).toBeVisible();
|
||||
await expect(page.locator('.bsplit')).toHaveCount(0);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user