beda6ccd3d
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
iOS Safari/WKWebView does not shrink the layout viewport for the soft keyboard (Android Chrome does): the visual viewport shrinks AND offsets down toward the focused field, and the values do not cleanly revert. The pinned shell tracked only the height (--vvh), not the offset, so its top-anchored content misaligned on iOS — empty space below the form, worst on a repeat focus. Fix (one place; covers NewGame, Chat, Feedback): - app.svelte.ts syncViewport: also mirror visualViewport.offsetTop into --vv-top, and scroll the focused field into view on the keyboard-open transition (iOS does not reliably scroll a pinned document to it). - app.css: the pinned app-shell body follows top=--vv-top + height=--vvh (was inset:0), so it stays on the visible area. - e2e/viewport.spec.ts emulates the visual-viewport resize+offset (a fake window.visualViewport) to verify the shell follows, on Chromium+WebKit. Hotseat: returning to the lobby now re-locks the current seat (its PIN is re-prompted) — LocalSource.relock, cleared in Game.svelte onDestroy. Root-caused a crash it exposed: a $props() value (id) reads back undefined during Svelte teardown, so isLocalGameId(id) threw and aborted onDestroy (breaking navigation) — read the id from the loaded view instead.
147 lines
5.5 KiB
TypeScript
147 lines
5.5 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import type { StateView } from '../model';
|
|
|
|
// getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node,
|
|
// so games live in the source's in-memory cache — created once, then driven without reload).
|
|
vi.mock('../dict', () => ({
|
|
getDawg: async () => {
|
|
const { Dawg } = await import('../dict/dawg');
|
|
const { readFileSync } = await import('node:fs');
|
|
return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
|
},
|
|
}));
|
|
|
|
import { LocalSource } from './source';
|
|
import type { Seat } from './serialize';
|
|
import { newLock } from '../pin';
|
|
|
|
async function hotseat(id: string, seats: Seat[], hostDigits = '9999'): Promise<LocalSource> {
|
|
const src = new LocalSource();
|
|
await src.create({
|
|
id,
|
|
variant: 'scrabble_en',
|
|
dictVersion: 'sample',
|
|
seed: 3n,
|
|
multipleWords: true,
|
|
seats,
|
|
hotseat: true,
|
|
hostPin: await newLock(hostDigits),
|
|
});
|
|
return src;
|
|
}
|
|
|
|
describe('LocalSource hotseat', () => {
|
|
it('creates a pass-and-play game: not vs_ai, board visible, first seat to move', async () => {
|
|
const src = await hotseat('local:h1', [
|
|
{ kind: 'human', name: 'Ann' },
|
|
{ kind: 'human', name: 'Bob' },
|
|
]);
|
|
const st = await src.gameState('local:h1');
|
|
expect(st.game.vsAi).toBe(false);
|
|
expect(st.game.hotseat).toBe(true);
|
|
expect(st.game.toMove).toBe(0);
|
|
expect(st.locked).toBeFalsy(); // seat 0 has no PIN
|
|
expect(st.rack.length).toBe(7);
|
|
expect(st.game.seats.map((s) => s.displayName)).toEqual(['Ann', 'Bob']);
|
|
});
|
|
|
|
it('hides the rack of a PIN-locked seat until the right PIN unlocks it', async () => {
|
|
const src = await hotseat('local:h2', [
|
|
{ kind: 'human', name: 'Ann', pin: await newLock('1234') },
|
|
{ kind: 'human', name: 'Bob' },
|
|
]);
|
|
const st = await src.gameState('local:h2');
|
|
expect(st.locked).toBe(true);
|
|
expect(st.rack).toEqual([]);
|
|
|
|
await expect(src.unlockSeat('local:h2', '0000')).rejects.toMatchObject({ code: 'wrong_pin' });
|
|
|
|
const opened = await src.unlockSeat('local:h2', '1234');
|
|
expect(opened.locked).toBe(false);
|
|
expect(opened.rack.length).toBe(7);
|
|
});
|
|
|
|
it('re-locks the seat when the turn advances and again when it comes back', async () => {
|
|
const src = await hotseat('local:h3', [
|
|
{ kind: 'human', name: 'A', pin: await newLock('1111') },
|
|
{ kind: 'human', name: 'B' },
|
|
{ kind: 'human', name: 'C' },
|
|
]);
|
|
await src.unlockSeat('local:h3', '1111');
|
|
await src.pass('local:h3'); // seat 0 -> 1
|
|
let st = await src.gameState('local:h3');
|
|
expect(st.game.toMove).toBe(1);
|
|
expect(st.locked).toBeFalsy(); // seat 1 has no PIN
|
|
|
|
await src.pass('local:h3'); // 1 -> 2
|
|
await src.pass('local:h3'); // 2 -> 0
|
|
st = await src.gameState('local:h3');
|
|
expect(st.game.toMove).toBe(0);
|
|
expect(st.locked).toBe(true); // seat 0 needs re-unlocking
|
|
});
|
|
|
|
it('lets the host skip the current turn, gated by the master PIN', async () => {
|
|
const src = await hotseat('local:h4', [
|
|
{ kind: 'human', name: 'A' },
|
|
{ kind: 'human', name: 'B' },
|
|
], '4321');
|
|
expect((await src.gameState('local:h4')).game.toMove).toBe(0);
|
|
await expect(src.hostAction('local:h4', '0000', 'skip')).rejects.toMatchObject({ code: 'wrong_pin' });
|
|
const st = await src.hostAction('local:h4', '4321', 'skip');
|
|
expect(st).not.toBeNull();
|
|
expect((st as StateView).game.toMove).toBe(1);
|
|
});
|
|
|
|
it('lets the host resign a seat (ending a two-player game)', async () => {
|
|
const src = await hotseat('local:h5', [
|
|
{ kind: 'human', name: 'A' },
|
|
{ kind: 'human', name: 'B' },
|
|
], '4321');
|
|
const st = await src.hostAction('local:h5', '4321', 'resign', 1);
|
|
expect((st as StateView).game.status).toBe('finished');
|
|
});
|
|
|
|
it('lets the host terminate the game, deleting it', async () => {
|
|
const src = await hotseat('local:h6', [
|
|
{ kind: 'human', name: 'A' },
|
|
{ kind: 'human', name: 'B' },
|
|
], '4321');
|
|
const r = await src.hostAction('local:h6', '4321', 'terminate');
|
|
expect(r).toBeNull();
|
|
await expect(src.gameState('local:h6')).rejects.toMatchObject({ code: 'game_not_found' });
|
|
});
|
|
|
|
it('re-locks an unlocked seat on relock (returning to the lobby)', async () => {
|
|
const src = await hotseat('local:h9', [
|
|
{ kind: 'human', name: 'Ann', pin: await newLock('1234') },
|
|
{ kind: 'human', name: 'Bob' },
|
|
]);
|
|
const opened = await src.unlockSeat('local:h9', '1234');
|
|
expect(opened.locked).toBe(false);
|
|
src.relock('local:h9');
|
|
const st = await src.gameState('local:h9');
|
|
expect(st.locked).toBe(true); // returning to the game re-prompts the seat PIN
|
|
expect(st.rack).toEqual([]);
|
|
});
|
|
|
|
it('verifies the master PIN', async () => {
|
|
const src = await hotseat('local:h7', [
|
|
{ kind: 'human', name: 'A' },
|
|
{ kind: 'human', name: 'B' },
|
|
], '4321');
|
|
expect(await src.verifyHostPin('local:h7', '4321')).toBe(true);
|
|
expect(await src.verifyHostPin('local:h7', '0000')).toBe(false);
|
|
});
|
|
|
|
it('persists a naturally finished game (scoreless run) as finished, and unlocked', async () => {
|
|
const src = await hotseat('local:h8', [
|
|
{ kind: 'human', name: 'A', pin: await newLock('1111') },
|
|
{ kind: 'human', name: 'B', pin: await newLock('2222') },
|
|
]);
|
|
for (let i = 0; i < 6; i++) await src.pass('local:h8');
|
|
const st = await src.gameState('local:h8');
|
|
expect(st.game.status).toBe('finished');
|
|
expect(st.locked).toBeFalsy(); // a finished game shows its final rack, never an unlock button
|
|
});
|
|
});
|