+136
@@ -181,6 +181,142 @@ test.describe('History — save / resume across a page refresh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Queue reliability — issue #10. A queued item must appear in the pending list
|
||||
* right after its REST response AND stay there: before the fix, a stale SSE
|
||||
* `state` snapshot (the on-connect resync taken just before the add was saved,
|
||||
* carrying no freshness marker) could arrive after the REST response and
|
||||
* clobber it — "add one: nothing happens, add another: both appear". The fix
|
||||
* gives every mutation a monotonic `GameState.version` and the client applies
|
||||
* an incoming state only when it is not older than the current one.
|
||||
*/
|
||||
test.describe('History — queue add is never clobbered by a stale SSE snapshot (#10)', () => {
|
||||
test('one added action appears immediately and survives late SSE snapshots', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
const id = await createUkGame(page);
|
||||
const origin = await page.evaluate(() => location.origin);
|
||||
|
||||
// Game screen at turn 1, nothing queued yet.
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
|
||||
await expect(page.getByTestId('pending-empty')).toBeVisible();
|
||||
|
||||
// Queue exactly ONE action and assert it appears immediately (turn/date
|
||||
// unchanged, no events yet).
|
||||
await page.getByTestId('action-textarea').fill('Hold the line at the channel');
|
||||
const [addResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/games/${id}/action`,
|
||||
),
|
||||
page.getByTestId('add-action').click(),
|
||||
]);
|
||||
const addBody = (await addResp.json()) as { game: { version: number; pending: unknown[] } };
|
||||
expect(addBody.game.pending).toHaveLength(1);
|
||||
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(1);
|
||||
await expect(page.getByTestId('pending-item').first()).toHaveAttribute('data-kind', 'action');
|
||||
await expect(page.getByTestId('pending-item').first()).toContainText('Hold the line at the channel');
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(0);
|
||||
await expect(page.getByTestId('title-queued')).toHaveText('1 queued');
|
||||
|
||||
// Let any in-flight / late SSE snapshot (on-connect resync, auto-reconnect)
|
||||
// have its chance to arrive. Under the stub these settle in milliseconds;
|
||||
// a second is generous. Before the version fix, a stale one could clobber
|
||||
// the just-added item here.
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The item is still there — the stale snapshot was dropped, not applied.
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(1);
|
||||
await expect(page.getByTestId('pending-item').first()).toContainText('Hold the line at the channel');
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
|
||||
// Protocol-level check: a later mutation (a second queued item) must carry
|
||||
// a strictly higher version than the one we just saw.
|
||||
await page.getByTestId('action-textarea').fill('Scramble the night fighters');
|
||||
const [add2Resp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/games/${id}/action`,
|
||||
),
|
||||
page.getByTestId('add-action').click(),
|
||||
]);
|
||||
const add2Body = (await add2Resp.json()) as { game: { version: number } };
|
||||
expect(add2Body.game.version).toBeGreaterThan(addBody.game.version);
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(2);
|
||||
|
||||
// The game persisted with a version survives a full page reload.
|
||||
await page.goto(`${origin}/#/game/${id}`);
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(2, { timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-item removal — issue #11. Each queued row has a small trash button that
|
||||
* opens a confirmation dialog; confirming removes the item server-side
|
||||
* (POST /api/games/:id/queue/remove, no turn advance) and cancelling keeps it.
|
||||
*/
|
||||
test.describe('History — remove a queued item with confirmation (#11)', () => {
|
||||
test('cancel keeps the item; confirm removes it', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
const id = await createUkGame(page);
|
||||
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
|
||||
// Queue one action.
|
||||
await page.getByTestId('action-textarea').fill('Open the back channel with Vichy');
|
||||
await page.getByTestId('add-action').click();
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(1);
|
||||
|
||||
// Trash icon → confirmation dialog.
|
||||
await page.getByTestId('remove-queue-item').click();
|
||||
const dialog = page.getByTestId('remove-confirm-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText('Open the back channel with Vichy');
|
||||
|
||||
// Cancel: the item stays queued.
|
||||
await page.getByTestId('remove-cancel').click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(1);
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
|
||||
// Open again and confirm: the item is removed server-side.
|
||||
await page.getByTestId('remove-queue-item').click();
|
||||
await expect(dialog).toBeVisible();
|
||||
const [removeResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' &&
|
||||
new URL(r.url()).pathname === `/api/games/${id}/queue/remove`,
|
||||
),
|
||||
page.getByTestId('remove-confirm').click(),
|
||||
]);
|
||||
const removeBody = (await removeResp.json()) as { game: { version: number; pending: unknown[] } };
|
||||
expect(removeBody.game.pending).toHaveLength(0);
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page.getByTestId('pending-empty')).toBeVisible();
|
||||
await expect(page.getByTestId('title-queued')).not.toBeVisible();
|
||||
// Still turn 1, no events — removing never advances the round.
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(0);
|
||||
|
||||
// The bumped version is persisted with the game (it survives a reload).
|
||||
const after = await page.evaluate(
|
||||
async (gameId) =>
|
||||
((await fetch(`/api/games/${gameId}`)).json()) as Promise<{ game: { version: number } }>,
|
||||
id,
|
||||
);
|
||||
expect(after.game.version).toBe(removeBody.game.version);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* URL deep-linking (issue #3). The active game's id lives in the URL fragment:
|
||||
* home is `#/` and an active game is `#/game/<id>`. A fresh page navigated to
|
||||
|
||||
Reference in New Issue
Block a user