Files
noitu/web/tests/history-export.test.js
tiennm99 1f2624c0e3 fix(web): clear stale rooms on refused resume, harden reconnect paths
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.
2026-09-29 20:33:16 +07:00

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');
});
});