Batch of LLM-assisted fixes/feature updates
This commit is contained in:
1 parent
7c6453160a
commit
05aaf394df
16 files changed
+5597
-912
No files matched your search
@@ -0,0 +1,466 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { JSDOM } = require('jsdom');
|
||||
|
||||
const GAME_HTML = fs.readFileSync(path.join(__dirname, '..', 'public', 'game.html'), 'utf8');
|
||||
const MENU_HTML = fs.readFileSync(path.join(__dirname, '..', 'public', 'menu.html'), 'utf8');
|
||||
|
||||
const flush = async (rounds = 4) => {
|
||||
for (let i = 0; i < rounds; i += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
|
||||
function createFakeSocket() {
|
||||
return {
|
||||
handlers: new Map(),
|
||||
emitted: [],
|
||||
on(event, handler) {
|
||||
const list = this.handlers.get(event) || [];
|
||||
list.push(handler);
|
||||
this.handlers.set(event, list);
|
||||
return this;
|
||||
},
|
||||
off(event, handler) {
|
||||
this.handlers.set(
|
||||
event,
|
||||
(this.handlers.get(event) || []).filter((fn) => fn !== handler)
|
||||
);
|
||||
return this;
|
||||
},
|
||||
emit(event, payload) {
|
||||
this.emitted.push({ event, payload });
|
||||
},
|
||||
trigger(event, payload) {
|
||||
for (const handler of this.handlers.get(event) || []) handler(payload);
|
||||
},
|
||||
lastEmitted(event) {
|
||||
return [...this.emitted].reverse().find((entry) => entry.event === event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(playerId, overrides = {}) {
|
||||
return {
|
||||
roomName: 'testroom',
|
||||
inProgress: true,
|
||||
gameHost: playerId,
|
||||
gameSettings: {
|
||||
scoreLimit: 0,
|
||||
roundLimit: 15,
|
||||
timeLimit: 60,
|
||||
botCount: 0,
|
||||
startText: 'It was a dark and stormy night...'
|
||||
},
|
||||
players: [
|
||||
{
|
||||
player_id: playerId,
|
||||
name: 'Alice',
|
||||
score: 0,
|
||||
isBot: false,
|
||||
hasSubmitted: false,
|
||||
hasVoted: false
|
||||
},
|
||||
{
|
||||
player_id: 'bob-2',
|
||||
name: 'Bob',
|
||||
score: 0,
|
||||
isBot: false,
|
||||
hasSubmitted: false,
|
||||
hasVoted: false
|
||||
}
|
||||
],
|
||||
yarnStory: [{ player: 'Narrator', str: 'It was a dark and stormy night...' }],
|
||||
currentPhase: 'writing',
|
||||
currentRound: 1,
|
||||
roundLimit: 15,
|
||||
scoreLimit: 0,
|
||||
timeRemaining: 42,
|
||||
round_data: [],
|
||||
lastResults: null,
|
||||
gameWinner: null,
|
||||
ownEntryIndex: -1,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function loadGame() {
|
||||
const socket = createFakeSocket();
|
||||
const dom = new JSDOM(GAME_HTML, {
|
||||
url: 'http://localhost/game/testroom',
|
||||
runScripts: 'dangerously',
|
||||
beforeParse(window) {
|
||||
window.io = () => socket;
|
||||
window.alert = () => {};
|
||||
window.confirm = () => true;
|
||||
window.document.cookie = 'yarn_player_name=Alice';
|
||||
}
|
||||
});
|
||||
|
||||
socket.trigger('connect');
|
||||
const join = socket.lastEmitted('joinRoom').payload;
|
||||
const playerId = join.playerUUID;
|
||||
|
||||
const send = (state) => {
|
||||
const { ownEntryIndex, ...rest } = state;
|
||||
socket.trigger('gameState', { ...rest, ownEntryIndex });
|
||||
};
|
||||
|
||||
return { dom, window: dom.window, document: dom.window.document, socket, playerId, send };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lobby & settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('scoreLimit 0 is sent as 0 instead of silently becoming 100', async (t) => {
|
||||
const { dom, document, socket, playerId, send } = loadGame();
|
||||
t.after(() => dom.window.close());
|
||||
|
||||
send(makeState(playerId, { inProgress: false, currentPhase: 'lobby', round_data: [] }));
|
||||
|
||||
const scoreInput = document.getElementById('score-limit');
|
||||
assert.equal(scoreInput.value, '0');
|
||||
document.getElementById('start-game-btn').click();
|
||||
|
||||
const start = socket.lastEmitted('startGame').payload;
|
||||
assert.equal(start.scoreLimit, 0, 'zero score limit must survive');
|
||||
assert.equal(start.roundLimit, 15);
|
||||
assert.equal(start.timeLimit, 60);
|
||||
assert.equal(start.botCount, 0);
|
||||
assert.equal(start.startText, 'It was a dark and stormy night...');
|
||||
});
|
||||
|
||||
test('non-host lobby sees a read-only settings summary and no start button', (t) => {
|
||||
const { dom, document, playerId, send } = loadGame();
|
||||
t.after(() => dom.window.close());
|
||||
|
||||
send(
|
||||
makeState(playerId, {
|
||||
inProgress: false,
|
||||
currentPhase: 'lobby',
|
||||
gameHost: 'someone-else',
|
||||
gameSettings: {
|
||||
scoreLimit: 20,
|
||||
roundLimit: 10,
|
||||
timeLimit: 45,
|
||||
botCount: 2,
|
||||
startText: 'Start here.'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(document.getElementById('start-game-btn'), null);
|
||||
const summary = document.querySelector('.settings-summary').textContent;
|
||||
assert.match(summary, /Rounds: 10/);
|
||||
assert.match(summary, /Score limit: 20/);
|
||||
assert.match(summary, /Time: 45s/);
|
||||
assert.match(summary, /Bots: 2/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Writing phase & input preservation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('in-progress typing survives gameState and playersUpdate broadcasts', (t) => {
|
||||
const { dom, document, playerId, send, socket } = loadGame();
|
||||
t.after(() => dom.window.close());
|
||||
|
||||
const state = makeState(playerId);
|
||||
send(state);
|
||||
|
||||
const input = document.getElementById('entry-input');
|
||||
assert.ok(input, 'writing input is rendered');
|
||||
input.value = 'Half a sentence, still typing';
|
||||
|
||||
// Someone else submits (playersUpdate) and a player joins (gameState).
|
||||
const bobSubmitted = {
|
||||
...state,
|
||||
players: state.players.map((player) =>
|
||||
player.player_id === 'bob-2' ? { ...player, hasSubmitted: true } : player
|
||||
)
|
||||
};
|
||||
socket.trigger('playersUpdate', bobSubmitted.players);
|
||||
send(bobSubmitted);
|
||||
socket.trigger('timerUpdate', 41);
|
||||
|
||||
const sameInput = document.getElementById('entry-input');
|
||||
assert.equal(sameInput, input, 'the input node is not rebuilt');
|
||||
assert.equal(sameInput.value, 'Half a sentence, still typing');
|
||||
assert.equal(document.getElementById('timer').textContent, '41');
|
||||
});
|
||||
|
||||
test('let someone join mid-typing without losing text', (t) => {
|
||||
const { dom, document, playerId, send, socket } = loadGame();
|
||||
t.after(() => dom.window.close());
|
||||
|
||||
send(makeState(playerId));
|
||||
const input = document.getElementById('entry-input');
|
||||
input.value = 'Draft';
|
||||
|
||||
// Simulate a new player joining: server broadcasts a full gameState.
|
||||
socket.trigger('gameState', makeState(playerId, {
|
||||
players: [
|
||||
...makeState(playerId).players,
|
||||
{ player_id: 'carol-3', name: 'Carol', score: 0, isBot: false, hasSubmitted: false, hasVoted: false }
|
||||
]
|
||||
}));
|
||||
|
||||
assert.equal(document.getElementById('entry-input').value, 'Draft');
|
||||
});
|
||||
|
||||
test('refresh restores the submitted state from the server', (t) => {
|
||||
const { dom, document, playerId, send } = loadGame();
|
||||
t.after(() => dom.window.close());
|
||||
|
||||
const state = makeState(playerId, {
|
||||
players: [
|
||||
{ player_id: playerId, name: 'Alice', score: 0, isBot: false, hasSubmitted: true, hasVoted: false },
|
||||
{ player_id: 'bob-2', name: 'Bob', score: 0, isBot: false, hasSubmitted: false, hasVoted: false }
|
||||
]
|
||||
});
|
||||
send(state);
|
||||
|
||||
assert.equal(document.getElementById('entry-input'), null, 'no input after refresh');
|
||||
assert.match(document.getElementById('game-bar').textContent, /Entry submitted/);
|
||||
});
|
||||
|
||||
test('the header shows round progress and the score target', (t) => {
|
||||
const { dom, document, playerId, send } = loadGame();
|
||||
t.after(() => dom.window.close());
|
||||
|
||||
send(makeState(playerId, { currentRound: 3, roundLimit: 15, scoreLimit: 25 }));
|
||||
const meta = document.getElementById('room-meta').textContent;
|
||||
assert.match(meta, /Round 3\/15/);
|
||||
assert.match(meta, /Target: 25 pts/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('your own entry cannot be voted for, and votes are confirmed by the server', (t) => {
|
||||
const { dom, document, playerId, send, socket } = loadGame();
|
||||
t.after(() => dom.window.close());
|
||||
|
||||
send(
|
||||
makeState(playerId, {
|
||||
currentPhase: 'voting',
|
||||
round_data: [
|
||||
{ index: 0, entry_text: 'Mine.', voteCount: 0 },
|
||||
{ index: 1, entry_text: 'Theirs.', voteCount: 0 }
|
||||
],
|
||||
timeRemaining: 20,
|
||||
ownEntryIndex: 0
|
||||
})
|
||||
);
|
||||
|
||||
const buttons = document.querySelectorAll('.vote-btn');
|
||||
assert.equal(buttons.length, 2);
|
||||
assert.equal(buttons[0].disabled, true);
|
||||
assert.equal(buttons[0].textContent.trim(), 'Yours');
|
||||
assert.equal(buttons[1].disabled, false);
|
||||
|
||||
buttons[1].click();
|
||||
assert.equal(socket.lastEmitted('submitVote').payload.entryIndex, 1);
|
||||
|
||||
// The server confirms with playersUpdate; the bar switches to "Vote cast".
|
||||
socket.trigger('playersUpdate', [
|
||||
{ player_id: playerId, name: 'Alice', score: 0, isBot: false, hasSubmitted: true, hasVoted: true },
|
||||
{ player_id: 'bob-2', name: 'Bob', score: 0, isBot: false, hasSubmitted: true, hasVoted: false }
|
||||
]);
|
||||
assert.match(document.getElementById('game-bar').textContent, /Vote cast/);
|
||||
});
|
||||
|
||||
test('refresh during voting restores the voted state', (t) => {
|
||||
const { dom, document, playerId, send } = loadGame();
|
||||
t.after(() => dom.window.close());
|
||||
|
||||
send(
|
||||
makeState(playerId, {
|
||||
currentPhase: 'voting',
|
||||
round_data: [{ index: 0, entry_text: 'Mine.', voteCount: 0 }],
|
||||
timeRemaining: 20,
|
||||
ownEntryIndex: 0,
|
||||
players: [
|
||||
{ player_id: playerId, name: 'Alice', score: 0, isBot: false, hasSubmitted: true, hasVoted: true },
|
||||
{ player_id: 'bob-2', name: 'Bob', score: 0, isBot: false, hasSubmitted: true, hasVoted: true }
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(document.querySelectorAll('.vote-btn').length, 0);
|
||||
assert.match(document.getElementById('game-bar').textContent, /Vote cast/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Results & game over
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('round results list every entry with author, votes and points', (t) => {
|
||||
const { dom, document, playerId, send, socket } = loadGame();
|
||||
t.after(() => dom.window.close());
|
||||
|
||||
send(makeState(playerId, { currentPhase: 'voting', round_data: [] }));
|
||||
|
||||
const winner = {
|
||||
index: 1,
|
||||
entry_text: 'The winning line.',
|
||||
player_id: 'bob-2',
|
||||
player_name: 'Bob',
|
||||
votes: 2,
|
||||
points: 5,
|
||||
bonus: 3,
|
||||
wasTie: false
|
||||
};
|
||||
|
||||
socket.trigger('roundResults', {
|
||||
winner,
|
||||
reason: 'winner',
|
||||
round: 1,
|
||||
players: makeState(playerId).players,
|
||||
story: [
|
||||
{ player: 'Narrator', str: 'It was a dark and stormy night...' },
|
||||
{ player: 'Bob', str: 'The winning line.' }
|
||||
],
|
||||
results: [
|
||||
{ index: 0, entry_text: 'A losing line.', player_id: playerId, player_name: 'Alice', votes: 0, points: 0, bonus: 0 },
|
||||
winner
|
||||
]
|
||||
});
|
||||
|
||||
const rows = document.querySelectorAll('#results-table tbody tr');
|
||||
assert.equal(rows.length, 2);
|
||||
assert.match(rows[0].textContent, /A losing line\./);
|
||||
assert.match(rows[0].textContent, /Alice/);
|
||||
assert.match(rows[1].textContent, /The winning line\./);
|
||||
assert.match(rows[1].textContent, /Bob/);
|
||||
assert.match(rows[1].textContent, /\+5/);
|
||||
assert.ok(rows[1].classList.contains('winner'));
|
||||
});
|
||||
|
||||
test('game over shows the final story with attribution and export controls', (t) => {
|
||||
const { dom, document, playerId, send, socket } = loadGame();
|
||||
t.after(() => dom.window.close());
|
||||
|
||||
send(makeState(playerId, { currentPhase: 'voting', round_data: [] }));
|
||||
|
||||
socket.trigger('gameOver', {
|
||||
winner: { player_id: playerId, name: 'Alice', score: 12 },
|
||||
players: makeState(playerId).players,
|
||||
story: [
|
||||
{ player: 'Narrator', str: 'It was a dark and stormy night...' },
|
||||
{ player: 'Alice', str: 'A heroic ending.' }
|
||||
]
|
||||
});
|
||||
|
||||
assert.match(document.getElementById('main-text').textContent, /Alice is victorious with 12 points/);
|
||||
const segment = [...document.querySelectorAll('.story-segment')].at(-1);
|
||||
assert.equal(segment.dataset.player, 'Alice');
|
||||
assert.match(segment.textContent, /A heroic ending\./);
|
||||
|
||||
assert.ok(document.getElementById('download-story-btn'));
|
||||
assert.ok(document.getElementById('copy-story-btn'));
|
||||
assert.ok(document.getElementById('play-again-btn'));
|
||||
|
||||
document.getElementById('play-again-btn').click();
|
||||
assert.ok(socket.lastEmitted('playAgain'));
|
||||
});
|
||||
|
||||
test('host controls: skip and kick are wired to the server', (t) => {
|
||||
const { dom, document, playerId, send, socket } = loadGame();
|
||||
t.after(() => dom.window.close());
|
||||
|
||||
send(makeState(playerId));
|
||||
document.getElementById('skip-btn').click();
|
||||
assert.ok(socket.lastEmitted('skipPhase'));
|
||||
|
||||
document.querySelector('[data-kick="bob-2"]').click();
|
||||
assert.equal(socket.lastEmitted('kickPlayer').payload.playerId, 'bob-2');
|
||||
});
|
||||
|
||||
test('being kicked shows a notice to the player', (t) => {
|
||||
const { dom, window, socket } = loadGame();
|
||||
t.after(() => dom.window.close());
|
||||
|
||||
socket.trigger('kicked', { message: 'Removed by the host.' });
|
||||
assert.match(window.document.getElementById('toast').textContent, /Removed by the host/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Menu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadMenu(fetchImpl) {
|
||||
const dom = new JSDOM(MENU_HTML, {
|
||||
url: 'http://localhost/',
|
||||
runScripts: 'dangerously',
|
||||
beforeParse(window) {
|
||||
window.fetch = fetchImpl(window);
|
||||
window.alert = () => {};
|
||||
}
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
test('menu renders rooms and the past-stories archive', async (t) => {
|
||||
const requests = [];
|
||||
const dom = loadMenu((window) => async (url) => {
|
||||
requests.push(url);
|
||||
if (url === '/api/rooms') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{ name: 'lobby', playerCount: 2, inProgress: false, phase: 'lobby' },
|
||||
{ name: 'storytime', playerCount: 3, inProgress: true, phase: 'voting' }
|
||||
]
|
||||
})
|
||||
};
|
||||
}
|
||||
if (url === '/api/stories') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ stories: ['storytime - 2026-10-30 12-00-00.txt'] })
|
||||
};
|
||||
}
|
||||
if (url.startsWith('/api/stories/')) {
|
||||
return { ok: true, text: async () => 'Once upon a time.' };
|
||||
}
|
||||
void window;
|
||||
return { ok: false };
|
||||
});
|
||||
|
||||
t.after(() => dom.window.close());
|
||||
const { document } = dom.window;
|
||||
|
||||
document.getElementById('find-btn').click();
|
||||
await flush();
|
||||
|
||||
const rooms = document.querySelectorAll('.room-row');
|
||||
assert.equal(rooms.length, 2);
|
||||
assert.match(rooms[1].textContent, /storytime/);
|
||||
assert.match(rooms[1].textContent, /voting/);
|
||||
assert.match(rooms[1].textContent, /3 players/);
|
||||
|
||||
document.getElementById('stories-btn').click();
|
||||
await flush();
|
||||
|
||||
const stories = document.querySelectorAll('.story-row');
|
||||
assert.equal(stories.length, 1);
|
||||
assert.match(stories[0].textContent, /storytime - 2026-10-30/);
|
||||
|
||||
stories[0].click();
|
||||
await flush();
|
||||
assert.equal(document.getElementById('story-viewer').textContent, 'Once upon a time.');
|
||||
|
||||
assert.ok(requests.includes('/api/rooms'));
|
||||
assert.ok(requests.includes('/api/stories'));
|
||||
|
||||
// Stop the room-polling interval so the test process can exit.
|
||||
document.getElementById('find-btn').click();
|
||||
});
|
||||
Reference in new issue
Block a user