e07886ecb1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
The offline native guest is now offered only Erudit (the profileless default from the variants fix), so the specs that picked the English "Scrabble" variant timed out. Play Erudit instead: the single `.variant` click both selects it and asserts nothing else is offered; the vs_ai test plays НОЖ from the pinned Erudit rack (ОЖЬЯНАО), and the bundled ru_erudit dawg drives the robot reply.
118 lines
7.4 KiB
TypeScript
118 lines
7.4 KiB
TypeScript
import { test, expect, type Page } from './fixtures';
|
||
|
||
// Simulate the native (Capacitor) channel. @capacitor/core — loaded during boot by initNativeShell —
|
||
// derives the platform from window.androidBridge / window.webkit and REPLACES any pre-set
|
||
// window.Capacitor with its own shim (whose getPlatform() would otherwise report 'web'), so the
|
||
// reliable native signal is the bridge marker, injected before any app script runs. clientChannel()
|
||
// then resolves to 'android', which flips the offline-first cold boot (app.svelte.ts), the loader's
|
||
// bundled-dict tier (lib/dict/loader.ts) and every native gate. The bridge is a stub with no real
|
||
// native runtime behind it; initNativeShell tolerates that (its @capacitor/app addListener is wrapped).
|
||
async function simulateNative(page: Page): Promise<void> {
|
||
await page.addInitScript(() => {
|
||
(window as unknown as { androidBridge: { postMessage: () => void } }).androidBridge = { postMessage: () => {} };
|
||
(window as unknown as { Capacitor: { getPlatform: () => string } }).Capacitor = { getPlatform: () => 'android' };
|
||
});
|
||
}
|
||
|
||
// The native cold boot skips the blocking login and lands straight in the lobby as a device-local
|
||
// guest in (auto) offline mode: the header carries the offline tint and the tab bar is present with
|
||
// no guest sign-in click. Offline the seeded online game (vs Ann) is hidden and Stats is disabled.
|
||
async function expectOfflineGuestLobby(page: Page): Promise<void> {
|
||
await expect(page.locator('header.nav.offline')).toBeVisible();
|
||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
|
||
await expect(page.locator('button.tab').nth(1)).toBeDisabled();
|
||
}
|
||
|
||
// Pick a rack tile by its glyph and drop it on a board square (the pinned-seed rack is all-distinct).
|
||
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
|
||
await page.locator('.rack .tile', { hasText: glyph }).first().click();
|
||
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
|
||
}
|
||
|
||
// typePin clicks the on-screen keypad digits (mirrors hotseat.spec — the pad has no OK button, so the
|
||
// 4th digit is the action; wait for the dots to clear so a call right after a previous entry keeps them).
|
||
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('native offline-first', () => {
|
||
test('cold-boots to the offline guest lobby, plays local vs_ai, reconciles a server guest online', async ({ page }) => {
|
||
await simulateNative(page);
|
||
await page.goto('/');
|
||
await expectOfflineGuestLobby(page);
|
||
|
||
// Play a local Erudit vs_ai game with a pinned bag seed (deals the rack ОЖЬЯНАО). Erudit is the
|
||
// only variant the profileless offline guest is offered (the new-account default), so the single
|
||
// `.variant` click both selects it and asserts nothing else is on offer. The dictionary comes from
|
||
// the APK's bundled tier (./dict/ru_erudit@dev.dawg served from dist-e2e), so no network is needed
|
||
// — this is the device-local guest playing with zero connectivity.
|
||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
||
await page.locator('button.tab').nth(0).click();
|
||
await page.locator('.variant').click();
|
||
await page.locator('button.invite').click();
|
||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||
|
||
// The human plays НОЖ horizontally across the centre (7,5)-(7,7); the local robot replies with a
|
||
// real move (which needs the bundled dictionary), so the board gains tiles beyond НОЖ's three.
|
||
await placeTile(page, 'Н', 7, 5);
|
||
await placeTile(page, 'О', 7, 6);
|
||
await placeTile(page, 'Ж', 7, 7);
|
||
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
|
||
await page.locator('.make').click();
|
||
await expect(async () => {
|
||
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
|
||
}).toPass({ timeout: 15000 });
|
||
|
||
// Back to the lobby (still offline): the device-local vs_ai game (🤖) is listed. Then the network
|
||
// returns: reconciliation silently mints + adopts a server guest and clears the auto-offline, so
|
||
// online lights up — the offline tint drops, the seeded online game (vs Ann) appears and Stats
|
||
// re-enables. The local game STAYS visible in the unified lobby alongside the server games — a
|
||
// reconciled guest keeps its device-local games.
|
||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||
await expect(page.locator('.rowwrap').filter({ hasText: '🤖' })).toBeVisible(); // the local vs_ai game, listed while offline
|
||
await page.evaluate(() => (window as unknown as { __native: { reconcile(): void } }).__native.reconcile());
|
||
await expect(page.locator('header.nav.offline')).toHaveCount(0);
|
||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible({ timeout: 15000 });
|
||
await expect(page.locator('.rowwrap').filter({ hasText: '🤖' })).toBeVisible(); // still listed online — the reconciled guest keeps it
|
||
await expect(page.locator('button.tab').nth(1)).toBeEnabled();
|
||
|
||
// The native sign-in surface is guest + email only: on Profile the Telegram + VK LINK buttons are
|
||
// hidden (their login widget / full-page redirect cannot return into the WebView), while email account
|
||
// management stays. loginWidgetAvailable/vkWebLinkAvailable are true under the mock, so on web these
|
||
// buttons DO show (social.spec links Telegram there) — a count of 0 here proves the native gate, not a
|
||
// merely-absent button.
|
||
await page.getByRole('button', { name: /Settings/ }).click();
|
||
await page.getByRole('button', { name: 'Profile', exact: true }).click();
|
||
await expect(page.getByRole('button', { name: /Link Telegram/i })).toHaveCount(0);
|
||
await expect(page.getByRole('button', { name: /Link VK/i })).toHaveCount(0);
|
||
await expect(page.getByText('you@example.com')).toBeVisible();
|
||
});
|
||
|
||
test('starts a 2-player hotseat game as an offline guest', async ({ page }) => {
|
||
await simulateNative(page);
|
||
await page.goto('/');
|
||
await expectOfflineGuestLobby(page);
|
||
|
||
// New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest.
|
||
// A minimal create: set the mandatory host (master) PIN, decline a seat, the sole offered variant
|
||
// (Erudit — the profileless guest's default), two open seats.
|
||
await page.locator('button.tab').nth(0).click();
|
||
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
||
await page.locator('.hostpin .plink').click();
|
||
await typePin(page, '9999');
|
||
await typePin(page, '9999');
|
||
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
|
||
await page.locator('.variant').click();
|
||
await page.locator('.pname').nth(0).fill('Ann');
|
||
await page.locator('.pname').nth(1).fill('Bob');
|
||
await page.locator('button.invite').click();
|
||
|
||
// The hotseat game starts: the board renders — a 2-player local pass-and-play game running with no
|
||
// network, reached straight from the native offline-first boot as the device-local guest.
|
||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||
});
|
||
});
|