diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts new file mode 100644 index 0000000..d8c7abb --- /dev/null +++ b/ui/e2e/game.spec.ts @@ -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 { + 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); +}); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 43f0e81..6701bc8 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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; diff --git a/ui/src/lib/banner.test.ts b/ui/src/lib/banner.test.ts index c01e674..6028bd6 100644 --- a/ui/src/lib/banner.test.ts +++ b/ui/src/lib/banner.test.ts @@ -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 home or ' + + 'docs', + ); + }); }); 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]); + }); }); diff --git a/ui/src/lib/checkword.test.ts b/ui/src/lib/checkword.test.ts new file mode 100644 index 0000000..5a05ccf --- /dev/null +++ b/ui/src/lib/checkword.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/checkword.ts b/ui/src/lib/checkword.ts new file mode 100644 index 0000000..cdb32d6 --- /dev/null +++ b/ui/src/lib/checkword.ts @@ -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; +} diff --git a/ui/src/lib/client.test.ts b/ui/src/lib/client.test.ts new file mode 100644 index 0000000..cc08a94 --- /dev/null +++ b/ui/src/lib/client.test.ts @@ -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'); + }); +}); diff --git a/ui/src/lib/placement.test.ts b/ui/src/lib/placement.test.ts index 337c307..c2efbc4 100644 --- a/ui/src/lib/placement.test.ts +++ b/ui/src/lib/placement.test.ts @@ -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']); + }); });