467 lines
15 KiB
JavaScript
467 lines
15 KiB
JavaScript
'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();
|
|
});
|