d733ce3119
Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode -> backend REST -> existing domain services): friends (incl. one-time friend codes), per-user blocks, friend-game invitations, profile editing + email binding, the statistics screen, and the in-game history + GCG export. Friends gain two add paths (interview decision, a deliberate plan change): one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited redeem); and play-gated requests (shared game required) where an explicit decline is permanent, an ignored request lapses after 30 days, and a code bypasses a decline. Migration 00006 widens friendships_status_chk and adds friend_codes. Lobby notification badge is poll + push: a new generic `notify` event drives it live; the client polls on open/focus. Language stays a single Settings control that writes through to the durable account's preferred_language. GCG export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file. Tests: backend unit + inttest (friend gate/decline/code, ListInvitations, GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN (Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN, TESTING, module READMEs.
87 lines
3.8 KiB
TypeScript
87 lines
3.8 KiB
TypeScript
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.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('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);
|
||
});
|