a8f78c7880
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 22s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, and the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 pure net-state reducer (test-first). O2 reactive store + event wiring (connection/offline become thin shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs (ARCHITECTURE, FUNCTIONAL +_ru, TESTING, deploy/README). Also disable the manual android-build CI workflow for now (rename to .disabled); the Android APK release comes later. Tests: gateway go green, svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
129 lines
6.7 KiB
TypeScript
129 lines
6.7 KiB
TypeScript
import { test, expect, type Page } from './fixtures';
|
|
|
|
// Offline pass-and-play (hotseat) is offered only in offline mode, which is now implicit (the net-state
|
|
// machine detects it — no toggle). The e2e drives the machine through the mock's __net hook. Standalone
|
|
// display mode is still forced so the mock's offline dictionary path (served from /e2edict/) matches an
|
|
// installed PWA.
|
|
async function forceStandalone(page: Page): Promise<void> {
|
|
await page.addInitScript(() => {
|
|
const orig = window.matchMedia.bind(window);
|
|
window.matchMedia = (q: string) =>
|
|
(q.includes('display-mode: standalone')
|
|
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
|
|
: orig(q)) as MediaQueryList;
|
|
});
|
|
}
|
|
|
|
async function enterLobby(page: Page): Promise<void> {
|
|
await page.getByRole('button', { name: /guest|гост/i }).first().click();
|
|
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
|
}
|
|
|
|
async function goOffline(page: Page): Promise<void> {
|
|
// The machine auto-detects offline (no toggle, no dialog); the lobby stays mounted and turns blue.
|
|
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
|
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
|
}
|
|
|
|
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
|
|
// It first waits for the dots to be empty, so a call made right after a previous entry does not lose
|
|
// digits during the 250 ms verdict pause (the 4th digit → pause → clear/advance).
|
|
async function typePin(page: Page, digits: string): Promise<void> {
|
|
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
|
|
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
|
|
}
|
|
|
|
test.describe('offline hotseat (pass-and-play)', () => {
|
|
test('create with a locked seat, unlock, host-skip, terminate from the lobby', async ({ page }) => {
|
|
await forceStandalone(page);
|
|
await page.goto('/');
|
|
await enterLobby(page);
|
|
await goOffline(page);
|
|
|
|
// A known seed: the bag deals a full rack deterministically, AND the seeded seating shuffle keeps
|
|
// the roster order (Ann first) so the locked-seat assertions below are stable. (Seat order is
|
|
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
|
|
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
|
|
|
|
// New game -> "with friends" offers an online (invite) / offline (hotseat) segment. Offline, the
|
|
// online segment is disabled and the offline (pass-and-play) one is forced, so the hotseat form shows.
|
|
await page.locator('button.tab').nth(0).click();
|
|
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
|
await expect(page.getByRole('button', { name: /Invite a friend|Пригласить друга/i })).toBeDisabled();
|
|
await expect(page.locator('.hostpin')).toBeVisible();
|
|
|
|
// The player rows are disabled until the mandatory host (master) PIN is set.
|
|
await expect(page.locator('.pname').first()).toBeDisabled();
|
|
|
|
// Set the host PIN (enter + confirm), then decline taking a seat.
|
|
await page.locator('.hostpin .plink').click();
|
|
await typePin(page, '9999');
|
|
await typePin(page, '9999');
|
|
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
|
|
|
|
// Pick the English variant (the plaques mirror Quick Match; "Scrabble" is the Latin English name).
|
|
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
|
|
|
// Two players: Ann (PIN-locked) and Bob (open).
|
|
await page.locator('.pname').nth(0).fill('Ann');
|
|
await page.locator('.pname').nth(1).fill('Bob');
|
|
|
|
// Row-delete: a 3rd player's kebab asks the host PIN, which ARMS a ❌ (not a silent delete);
|
|
// tapping the ❌ removes the row.
|
|
await page.getByRole('button', { name: /Add player|Добавить/i }).click();
|
|
await expect(page.locator('.prow')).toHaveCount(3);
|
|
await page.locator('.prow').nth(2).locator('.pkebab').click();
|
|
await typePin(page, '9999');
|
|
const rowDel = page.locator('.prow').nth(2).locator('.prow-del');
|
|
await expect(rowDel).toBeVisible();
|
|
await rowDel.click();
|
|
await expect(page.locator('.prow')).toHaveCount(2);
|
|
|
|
// Lock Ann's seat with a PIN.
|
|
await page.locator('.prow').nth(0).locator('.plink').click();
|
|
await typePin(page, '1234');
|
|
await typePin(page, '1234');
|
|
|
|
await page.locator('button.invite').click();
|
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
|
|
|
// Ann is to move and PIN-locked: the board is visible but her rack is withheld behind Unlock.
|
|
await expect(page.locator('.unlock')).toBeVisible();
|
|
await expect(page.locator('.rack .tile')).toHaveCount(0);
|
|
|
|
// A wrong PIN keeps it locked; the right PIN reveals the full rack.
|
|
await page.locator('.unlock').click();
|
|
await typePin(page, '0000');
|
|
await expect(page.locator('.pad')).toBeVisible(); // still open after a wrong PIN
|
|
await typePin(page, '1234');
|
|
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
|
|
|
// Opening the history reveals NO social controls on the seat plaques (a local game is
|
|
// account-less), and the Dictionary entry (the comms button) is kept for the active game.
|
|
await page.locator('.scoreboard').click();
|
|
await expect(page.locator('.fico')).toHaveCount(0);
|
|
await expect(page.locator('.chat-ico')).toBeVisible();
|
|
await page.locator('.scoreboard').click();
|
|
|
|
// Host override: 🔐 -> master PIN -> skip the current turn -> confirm. The turn passes to Bob,
|
|
// whose seat is open, so his rack shows without a lock.
|
|
await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click();
|
|
await typePin(page, '9999');
|
|
await page.getByRole('button', { name: /Skip|Пропустить/ }).click();
|
|
await page.getByRole('button', { name: /^(OK|ОК)$/ }).click();
|
|
await expect(page.locator('.unlock')).toHaveCount(0);
|
|
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
|
|
|
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
|
|
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
|
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
|
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(1);
|
|
await page.locator('.kebab').first().click();
|
|
const del = page.locator('.rowwrap.revealed .del');
|
|
await expect(del).toBeVisible();
|
|
await del.click();
|
|
await typePin(page, '9999');
|
|
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(0);
|
|
});
|
|
});
|