mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-13 14:16:18 +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.
132 lines
4.0 KiB
JavaScript
132 lines
4.0 KiB
JavaScript
// The transcript is the only artefact of a game that outlives the tab, so what
|
|
// it contains is worth pinning: the chain in playing order, who played what,
|
|
// and a filename that survives every filesystem.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import { chainToText, historyFilename } from '../src/lib/history-export.js';
|
|
|
|
/**
|
|
* @param {Partial<import('../src/lib/stores/game.svelte.js').ChainEntry>} entry
|
|
*/
|
|
function link(entry) {
|
|
return {
|
|
word: '',
|
|
typed: '',
|
|
byMe: false,
|
|
playerId: '',
|
|
points: 0,
|
|
syllables: 2,
|
|
opening: false,
|
|
...entry
|
|
};
|
|
}
|
|
|
|
const chain = [
|
|
link({ word: 'học sinh', opening: true, syllables: 0 }),
|
|
link({ word: 'sinh viên', byMe: true, playerId: 'p1', points: 2 }),
|
|
link({ word: 'viên chức', playerId: 'p2', points: 2 })
|
|
];
|
|
|
|
/** Resolves a seat to the name that seat was playing under. */
|
|
const nameOf = (/** @type {string} */ id) => (id === 'p2' ? 'Minh' : '');
|
|
|
|
const at = new Date(2026, 8, 7, 15, 25);
|
|
|
|
describe('chainToText', () => {
|
|
it('keeps the chain in playing order, opening word first', () => {
|
|
const lines = chainToText({ chain, at }).trim().split('\n');
|
|
const words = lines.filter((line) => /^\d+\./.test(line));
|
|
|
|
expect(words).toHaveLength(3);
|
|
expect(words[0]).toContain('học sinh');
|
|
expect(words[1]).toContain('sinh viên');
|
|
expect(words[2]).toContain('viên chức');
|
|
});
|
|
|
|
it('marks the opening word as played by neither side', () => {
|
|
const text = chainToText({ chain, at });
|
|
|
|
expect(text).toContain('1. học sinh (từ mở đầu)');
|
|
expect(text).not.toContain('học sinh — ');
|
|
});
|
|
|
|
it('names who played each word and what it scored', () => {
|
|
const text = chainToText({ chain, at, nameOf });
|
|
|
|
expect(text).toContain('2. sinh viên — Bạn +2');
|
|
expect(text).toContain('3. viên chức — Minh +2');
|
|
});
|
|
|
|
it('falls back to a generic label for a seat whose name is gone', () => {
|
|
// A player who left takes their name with them, and the transcript is
|
|
// written long after. The line still has to say it was not this
|
|
// player's word.
|
|
const text = chainToText({ chain, at });
|
|
|
|
expect(text).toContain('3. viên chức — Đối thủ +2');
|
|
});
|
|
|
|
it('heads the file with the result when there is one', () => {
|
|
const text = chainToText({
|
|
chain,
|
|
at,
|
|
result: { iWon: true, myScore: 4, chainLength: 3 }
|
|
});
|
|
|
|
expect(text).toContain('2026-09-07 15:25');
|
|
expect(text).toContain('Bạn thắng!');
|
|
expect(text).toContain('Điểm cuối cùng: 4');
|
|
expect(text).toContain('Số từ trong chuỗi: 3');
|
|
});
|
|
|
|
it('numbers a chain held only in part from the server\'s length, and marks the gap', () => {
|
|
// A player who rejoined mid-game holds the opening word and the latest
|
|
// move, and nothing between.
|
|
const partial = [
|
|
link({ word: 'học sinh', opening: true, syllables: 0 }),
|
|
link({ word: 'viên chức', playerId: 'p2', points: 2 })
|
|
];
|
|
const lines = chainToText({
|
|
chain: partial,
|
|
at,
|
|
nameOf,
|
|
result: { iWon: true, myScore: 4, chainLength: 30 }
|
|
})
|
|
.trim()
|
|
.split('\n');
|
|
const body = lines.slice(lines.indexOf('1. học sinh (từ mở đầu)'));
|
|
|
|
expect(body).toEqual(['1. học sinh (từ mở đầu)', '…', '30. viên chức — Minh +2']);
|
|
});
|
|
|
|
it('numbers a whole chain from one, with no gap marker', () => {
|
|
const text = chainToText({
|
|
chain,
|
|
at,
|
|
result: { iWon: true, myScore: 4, chainLength: 3 }
|
|
});
|
|
|
|
expect(text).toContain('1. học sinh');
|
|
expect(text).toContain('3. viên chức');
|
|
expect(text).not.toContain('…');
|
|
});
|
|
|
|
it('writes a chain with no result at all', () => {
|
|
const text = chainToText({ chain: [], at });
|
|
|
|
expect(text).toContain('2026-09-07 15:25');
|
|
expect(text).not.toContain('Bạn thắng!');
|
|
expect(text).not.toContain('Bạn thua.');
|
|
});
|
|
});
|
|
|
|
describe('historyFilename', () => {
|
|
it('stamps the local date and time without a colon', () => {
|
|
expect(historyFilename(at)).toBe('noi-tu-2026-09-07-1525.txt');
|
|
});
|
|
|
|
it('pads every field so names sort chronologically', () => {
|
|
expect(historyFilename(new Date(2026, 0, 2, 3, 4))).toBe('noi-tu-2026-01-02-0304.txt');
|
|
});
|
|
});
|