4999478ded
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m16s
A player can remove a finished game from their own 'my games' list. The action is per-account, finished-only and irreversible (the game stays for the other players; there is no un-hide). - backend: migration 00012 game_hidden(account_id, game_id); store HideGame + hiddenGameIDs + ListGamesForAccount filtering; service HideGame (seat + finished checks, reusing ErrNotAPlayer / ErrGameActive); POST /api/v1/user/games/:id/hide. - gateway: game.hide edge op (reuses GameActionRequest -> Ack) + backendclient.HideGame. - ui: finished rows reveal a delete via swipe-left (touch) or a kebab tap (desktop), active rows get an inert chevron for icon alignment; optimistic removal + lobby-cache sync; mock + transport + client wiring; lobby.hideGame label (en/ru). - tests: integration (active->ErrGameActive, outsider->ErrNotAPlayer, per-account, idempotent), gateway transcode round-trip, mock e2e (kebab -> delete); hardened a pre-existing chat-screen .back transition flake surfaced by the new test's timing. - docs: ARCHITECTURE persistence list, FUNCTIONAL (+ _ru) lobby story, PLAN tracker.
202 lines
9.6 KiB
TypeScript
202 lines
9.6 KiB
TypeScript
import { expect, test, type Page } from './fixtures';
|
||
|
||
// 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();
|
||
// Wait for the screen-slide transition to settle so only the game pane remains;
|
||
// until it does, the leaving lobby pane's header (its menu button) is also in the
|
||
// DOM, which would make shared locators like .burger ambiguous.
|
||
await expect(page.locator('.pane')).toHaveCount(1);
|
||
}
|
||
|
||
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(); // ✅ commits the move directly (no popover)
|
||
|
||
// 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('a placed tile is saved as a draft and restored on reopening the game', 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.waitForTimeout(600); // let the debounced draft save flush to the mock store
|
||
|
||
// Leave the game and reopen it. The mock keeps the saved composition, so the pending tile is
|
||
// restored without re-placing it (Stage 17 #4/#6).
|
||
await page.evaluate(() => (location.hash = '/'));
|
||
await page.getByRole('button', { name: /Ann/ }).click();
|
||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
||
});
|
||
|
||
test('new game: variant buttons show a rules summary and the move-limit', async ({ page }) => {
|
||
await page.goto('/');
|
||
await page.getByRole('button', { name: /guest/i }).click();
|
||
await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
|
||
await expect(page.locator('.vrules').first()).toBeVisible(); // per-variant rules summary
|
||
await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons
|
||
});
|
||
|
||
test('a pending tile recalls on double-tap, not on a single tap', 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);
|
||
|
||
// A single tap must NOT recall it (changed in Stage 17 — recall was too easy to trigger).
|
||
await page.waitForTimeout(350); // clear the double-tap window from the placing tap
|
||
await page.locator('[data-cell].pending').first().click();
|
||
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
|
||
|
||
// A double-tap (two synchronous clicks on the same cell) recalls it to the rack.
|
||
await page.locator('[data-cell].pending').first().evaluate((el: HTMLElement) => {
|
||
el.click();
|
||
el.click();
|
||
});
|
||
await expect(page.locator('[data-cell].pending')).toHaveCount(0);
|
||
});
|
||
|
||
test('the board is a gapless checkerboard by default; grid lines toggle in Settings', async ({ page }) => {
|
||
await openGame(page);
|
||
await expect(page.locator('.grid.gridless')).toBeVisible(); // lines off by default
|
||
|
||
await page.evaluate(() => (location.hash = '/settings'));
|
||
await page.locator('.gridlines input').check(); // turn grid lines on
|
||
await page.evaluate(() => (location.hash = '/game/g1'));
|
||
|
||
await expect(page.locator('.grid')).toBeVisible();
|
||
await expect(page.locator('.grid.gridless')).toHaveCount(0);
|
||
});
|
||
|
||
test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => {
|
||
await openGame(page);
|
||
const before = await page.locator('.rack .tile').allTextContents();
|
||
expect(before.length).toBeGreaterThan(1);
|
||
|
||
await page.locator('button:has-text("🔀")').click(); // the shuffle tab (no pending tiles)
|
||
await page.waitForTimeout(650); // let the hop animation settle
|
||
|
||
// Same multiset of tiles after the shuffle — no tile is dropped or duplicated.
|
||
const after = await page.locator('.rack .tile').allTextContents();
|
||
expect([...after].sort()).toEqual([...before].sort());
|
||
});
|
||
|
||
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.getByRole('button', { name: 'Drop game' }).click(); // robust against menu growth
|
||
await page.locator('button.danger').click(); // confirm in the modal
|
||
await expect(page.locator('.status .over')).toBeVisible();
|
||
});
|
||
|
||
test('a placed tile drags from one board cell to another (Stage 17 relocation)', async ({ page }) => {
|
||
await openGame(page);
|
||
await page.locator('.rack .tile').first().click();
|
||
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
||
const pending = page.locator('[data-cell].pending');
|
||
await expect(pending).toHaveCount(1);
|
||
const from = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`;
|
||
|
||
const target = page.locator('[data-cell]:not(.filled):not(.pending)').nth(45);
|
||
const fb = await pending.first().boundingBox();
|
||
const tb = await target.boundingBox();
|
||
// Pointer-drag the placed tile to a new cell (mouse events synthesise pointer events).
|
||
await page.mouse.move(fb!.x + fb!.width / 2, fb!.y + fb!.height / 2);
|
||
await page.mouse.down();
|
||
await page.mouse.move(tb!.x + tb!.width / 2, tb!.y + tb!.height / 2, { steps: 10 });
|
||
await page.mouse.up();
|
||
|
||
// Still exactly one pending tile (relocated, not duplicated), now at a different cell.
|
||
await expect(pending).toHaveCount(1);
|
||
const to = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`;
|
||
expect(to).not.toBe(from);
|
||
});
|
||
|
||
test('chat and word-check open as their own screens and back to the game (Stage 17)', async ({ page }) => {
|
||
await openGame(page);
|
||
|
||
await page.locator('.burger').click();
|
||
await page.getByRole('button', { name: /^Chat$/ }).click();
|
||
await expect(page).toHaveURL(/\/game\/g1\/chat$/);
|
||
await expect(page.locator('.pane')).toHaveCount(1); // let the slide transition settle
|
||
await expect(page.locator('.chat')).toBeVisible();
|
||
// The outgoing game header and the incoming chat header both carry a .back mid-slide; wait
|
||
// for the game's to unmount so the click targets a single, settled button.
|
||
await expect(page.locator('.back')).toHaveCount(1);
|
||
await page.locator('.back').click(); // header back chevron returns to the game
|
||
await expect(page).toHaveURL(/\/game\/g1$/);
|
||
await expect(page.locator('.pane')).toHaveCount(1);
|
||
|
||
await page.locator('.burger').click();
|
||
await page.getByRole('button', { name: /Check word/ }).click();
|
||
await expect(page).toHaveURL(/\/game\/g1\/check$/);
|
||
await expect(page.locator('.pane')).toHaveCount(1);
|
||
await expect(page.locator('.check input')).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);
|
||
});
|