Files
noitu/web/tests/room-session.test.js
T
tiennm99 9be86a3cad refactor(web): share alert banner and turn actions, fix restyle leaks
Add AlertBanner and turnActions to replace duplicated markup and
wrappers, and share component-test setup.

Fixes: oversized dismiss, help, rules and skip-link text after the type
ramp collapse; report button hover styled as submit; chat draft lost
when the panel folds; resign/claim/kick styles leaking app-wide.
2026-09-28 15:19:59 +07:00

266 lines
7.8 KiB
JavaScript

// The online screen's request machine, extracted so its two failure modes —
// a stale resume token that never gets an answer, and a lobby action the
// socket refused — are things this file can prove directly, without a
// mounted page or a real socket.
import { describe, expect, it } from 'vitest';
import { createRoomSession } from '../src/lib/stores/room-session.svelte.js';
/** Records what reached the socket for each request kind. */
function senders(accepted = true) {
/** @type {string[]} */
const sent = [];
return {
sent,
create: () => {
sent.push('create');
return accepted;
},
join: (/** @type {string} */ code) => {
sent.push(`join:${code}`);
return accepted;
},
quickMatch: () => {
sent.push('quickMatch');
return accepted;
}
};
}
describe('joining or creating a room', () => {
it('holds the request until the socket is open', () => {
const s = senders();
const session = createRoomSession();
session.request({ kind: 'create' });
expect(session.flush(false, s)).toBe(false);
expect(s.sent).toEqual([]);
expect(session.flush(true, s)).toBe(true);
expect(s.sent).toEqual(['create']);
expect(session.state.pending).toBeNull();
});
it('sends a join with its room code', () => {
const s = senders();
const session = createRoomSession();
session.request({ kind: 'join', code: 'K7M2QP' });
session.flush(true, s);
expect(s.sent).toEqual(['join:K7M2QP']);
});
it('holds a refused request so the next open connection carries it', () => {
const refused = senders(false);
const session = createRoomSession();
session.request({ kind: 'create' });
expect(session.flush(true, refused)).toBe(false);
expect(session.flush(true, refused)).toBe(false);
expect(refused.sent).toEqual(['create', 'create']);
expect(session.state.pending).not.toBeNull();
});
it('keeps only the latest request when asked twice before sending', () => {
const s = senders();
const session = createRoomSession();
session.request({ kind: 'create' });
session.request({ kind: 'quickMatch' });
session.flush(true, s);
expect(s.sent).toEqual(['quickMatch']);
});
it('clears every latch a fresh request opens with', () => {
const session = createRoomSession();
session.setNeedName(true);
session.setStalled(true);
session.request({ kind: 'create' });
expect(session.state.needName).toBe(false);
expect(session.state.stalled).toBe(false);
expect(session.state.resuming).toBe(false);
});
});
describe('resuming a stored session', () => {
it('holds a request behind the resume and refuses to send it while resuming', () => {
const s = senders();
const session = createRoomSession();
session.startResume();
session.holdPendingJoin({ kind: 'join', code: 'K7M2QP' });
expect(session.flush(true, s)).toBe(false);
expect(s.sent).toEqual([]);
});
it('spends the held join once the resume lands in a room', () => {
const s = senders();
const session = createRoomSession();
session.startResume();
session.holdPendingJoin({ kind: 'join', code: 'K7M2QP' });
session.noteRoom();
expect(session.state.resuming).toBe(false);
// Arriving in a room answers the held code too — it is not sent
// afterwards as a second, redundant join.
expect(session.state.pending).toBeNull();
expect(session.flush(true, s)).toBe(false);
expect(s.sent).toEqual([]);
});
it('times out to the join form instead of staying stuck forever', () => {
// An older server answers a stale token with silence, never with an
// error.
const session = createRoomSession();
session.startResume();
session.noteResumeFailed(/* named */ true);
expect(session.state.resuming).toBe(false);
expect(session.state.resumeFailed).toBe(true);
});
it('does nothing if the resume already resolved before the timer fired', () => {
const session = createRoomSession();
session.startResume();
session.noteRoom();
session.noteResumeFailed(true);
// noteRoom() already cleared resuming; the stale timer callback must
// not reopen the failure state on top of a resume that succeeded.
expect(session.state.resumeFailed).toBe(false);
});
it('asks for a name instead of spending an invite code held behind a failed resume', () => {
const session = createRoomSession();
session.startResume();
session.holdPendingJoin({ kind: 'join', code: 'K7M2QP' });
session.noteResumeFailed(/* named */ false);
expect(session.state.needName).toBe(true);
expect(session.state.pending).toBeNull();
});
it('keeps a held join when the player already has a name, so it flushes next', () => {
const s = senders();
const session = createRoomSession();
session.startResume();
session.holdPendingJoin({ kind: 'join', code: 'K7M2QP' });
session.noteResumeFailed(/* named */ true);
expect(session.state.needName).toBe(false);
expect(session.flush(true, s)).toBe(true);
expect(s.sent).toEqual(['join:K7M2QP']);
});
});
describe('quick match', () => {
it('goes from queued to seated the same way a plain request does', () => {
const s = senders();
const session = createRoomSession();
session.request({ kind: 'quickMatch' });
expect(session.flush(true, s)).toBe(true);
expect(s.sent).toEqual(['quickMatch']);
session.resetQueued();
session.tickQueued();
session.tickQueued();
expect(session.state.queuedForS).toBe(2);
// RoomState arriving is what a match found looks like from here.
session.noteRoom();
expect(session.state.pending).toBeNull();
});
it('counts seconds only from the moment counting starts', () => {
const session = createRoomSession();
session.tickQueued();
expect(session.state.queuedForS).toBe(1);
session.resetQueued();
expect(session.state.queuedForS).toBe(0);
});
});
describe('held room actions', () => {
/** @param {boolean} accepted */
function dispatcher(accepted = true) {
/** @type {import('../src/lib/stores/room-session.svelte.js').RoomAction[]} */
const sent = [];
return {
sent,
/** @param {import('../src/lib/stores/room-session.svelte.js').RoomAction} action */
dispatch: (action) => {
sent.push(action);
return accepted;
}
};
}
it('does nothing when nothing is held', () => {
const d = dispatcher();
const session = createRoomSession();
expect(session.flushAction(true, d.dispatch)).toBe(false);
expect(d.sent).toEqual([]);
});
it('resends a held action once the socket reopens', () => {
const d = dispatcher();
const session = createRoomSession();
session.holdAction({ kind: 'setReady', ready: true });
expect(session.flushAction(false, d.dispatch)).toBe(false);
expect(d.sent).toEqual([]);
expect(session.flushAction(true, d.dispatch)).toBe(true);
expect(d.sent).toEqual([{ kind: 'setReady', ready: true }]);
expect(session.state.heldAction).toBeNull();
});
it('keeps retrying a held action the server keeps refusing', () => {
const d = dispatcher(false);
const session = createRoomSession();
session.holdAction({ kind: 'startGame' });
expect(session.flushAction(true, d.dispatch)).toBe(false);
expect(session.flushAction(true, d.dispatch)).toBe(false);
expect(d.sent).toEqual([{ kind: 'startGame' }, { kind: 'startGame' }]);
});
it('replaces an unset held action with the player\'s later intent', () => {
// Readying, then leaving before either reaches the server: only the
// leave should still be waiting to go out.
const d = dispatcher();
const session = createRoomSession();
session.holdAction({ kind: 'setReady', ready: true });
session.holdAction({ kind: 'leaveRoom' });
session.flushAction(true, d.dispatch);
expect(d.sent).toEqual([{ kind: 'leaveRoom' }]);
});
it('drops a held action when the screen is torn down before it can be sent', () => {
const d = dispatcher();
const session = createRoomSession();
session.holdAction({ kind: 'cancelQueue' });
session.clearAction();
expect(session.flushAction(true, d.dispatch)).toBe(false);
expect(d.sent).toEqual([]);
});
});