mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-12 12:14:16 +00:00
A refused resume now leaves the room instead of showing a banner over a dead board, and /play offers a fresh game there. Entering /play forgets any stored token so it no longer races StartBotGame. An eliminated player who reconnects stays a spectator. Held lobby actions wait for the resume's RoomState; leaving while offline is not held. Chat clears its draft only when the line was sent and cannot send while offline. Also: away banners no longer announce every second; game over does not steal focus from the chat field; the clock offset uses the lowest-RTT pong; the history export numbers a resumed chain from its real length; chat replay keeps existing ordinals; one .primary button class replaces five copies; the footer links the data change list; the online heading joins the type ramp. New component, connection and storage-guard tests.
135 lines
3.7 KiB
JavaScript
135 lines
3.7 KiB
JavaScript
// @vitest-environment jsdom
|
|
|
|
// The module-level connection is what a screen actually calls. These tests
|
|
// drive it through a stubbed WebSocket, because what they pin is which resume
|
|
// token a screen's socket carries and what happens to the board when the
|
|
// server refuses one.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
|
import {
|
|
ClientMessageSchema,
|
|
ServerMessageSchema
|
|
} from '../src/lib/proto/noitu/v1/game_pb.js';
|
|
import { game } from '../src/lib/stores/game.svelte.js';
|
|
import { connect, disconnect } from '../src/lib/ws/connection.svelte.js';
|
|
import { receive } from './component-support.js';
|
|
|
|
const RESUME_KEY = 'noitu.resumeToken';
|
|
|
|
/** @type {FakeWebSocket[]} */
|
|
let sockets = [];
|
|
|
|
class FakeWebSocket {
|
|
/** @param {string} url */
|
|
constructor(url) {
|
|
this.url = url;
|
|
this.readyState = 0;
|
|
this.binaryType = '';
|
|
/** @type {Uint8Array[]} */
|
|
this.sent = [];
|
|
this.onopen = () => {};
|
|
this.onmessage = (/** @type {{ data: ArrayBuffer }} */ _event) => {};
|
|
this.onclose = () => {};
|
|
this.onerror = () => {};
|
|
sockets.push(this);
|
|
}
|
|
|
|
send(/** @type {Uint8Array} */ bytes) {
|
|
this.sent.push(bytes);
|
|
}
|
|
|
|
close() {}
|
|
|
|
open() {
|
|
this.readyState = 1;
|
|
this.onopen();
|
|
}
|
|
|
|
/**
|
|
* @param {string} kind
|
|
* @param {object} value
|
|
*/
|
|
deliver(kind, value) {
|
|
const msg = create(ServerMessageSchema, { payload: { case: kind, value } });
|
|
this.onmessage({ data: toBinary(ServerMessageSchema, msg).buffer });
|
|
}
|
|
|
|
helloToken() {
|
|
const [first] = this.sent.map((b) => fromBinary(ClientMessageSchema, new Uint8Array(b)));
|
|
return first.payload.case === 'hello' ? first.payload.value.resumeToken : null;
|
|
}
|
|
}
|
|
|
|
const greeting = { sessionId: 's1', resumeToken: 'fresh-token', protocolVersion: 1, acceptedNickname: 'Minh' };
|
|
|
|
beforeEach(() => {
|
|
sockets = [];
|
|
sessionStorage.clear();
|
|
vi.stubGlobal('WebSocket', FakeWebSocket);
|
|
game.leave();
|
|
});
|
|
|
|
afterEach(() => {
|
|
disconnect();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('opening a socket for a game of its own', () => {
|
|
it('does not replay a token left by an earlier game or tab', () => {
|
|
sessionStorage.setItem(RESUME_KEY, 'left-behind');
|
|
|
|
connect({ freshSession: true });
|
|
sockets[0].open();
|
|
|
|
expect(sockets[0].helloToken()).toBe('');
|
|
});
|
|
|
|
it('replays the stored token when nothing asked for a fresh session', () => {
|
|
sessionStorage.setItem(RESUME_KEY, 'left-behind');
|
|
|
|
connect();
|
|
sockets[0].open();
|
|
|
|
expect(sockets[0].helloToken()).toBe('left-behind');
|
|
});
|
|
|
|
it('keeps the token this game was given, so a dropped socket still resumes', () => {
|
|
sessionStorage.setItem(RESUME_KEY, 'left-behind');
|
|
connect({ freshSession: true });
|
|
sockets[0].open();
|
|
sockets[0].deliver('welcome', greeting);
|
|
|
|
// A rematch asks again on the socket that already exists. That must
|
|
// not throw away the token the game in progress is resumable by.
|
|
connect({ freshSession: true });
|
|
|
|
expect(sessionStorage.getItem(RESUME_KEY)).toBe('fresh-token');
|
|
expect(sockets).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('a resume the server refuses', () => {
|
|
it('clears the room the board still shows, and still says why', () => {
|
|
receive('roomState', {
|
|
roomCode: 'ABCD',
|
|
canStart: false,
|
|
maxPlayers: 4,
|
|
minPlayers: 2,
|
|
graceMs: 30_000,
|
|
players: [{ playerId: 'p1', name: 'Minh', isMe: true, connected: true }]
|
|
});
|
|
expect(game.state.phase).toBe('lobby');
|
|
sessionStorage.setItem(RESUME_KEY, 'old-token');
|
|
|
|
connect();
|
|
sockets[0].open();
|
|
sockets[0].deliver('welcome', greeting);
|
|
sockets[0].deliver('error', { code: 'game_already_over', message: '' });
|
|
|
|
expect(game.state.phase).toBe('idle');
|
|
expect(game.state.roomCode).toBe('');
|
|
expect(game.state.error).toBe('Ván đấu đã kết thúc.');
|
|
});
|
|
});
|