// Unit tests for server-side game logic. Uses node:test (no extra deps). // Run with: node test/unit.test.js (NOT `node --test`) — see comment at bottom. const test = require("node:test"); const assert = require("node:assert"); const { normalize, maskWord, pickRevealIndex, isCloseGuess, } = require("../server/word-utils.js"); const roomStore = require("../server/room-store.js"); const { genCode, getRoom, setRoom, deleteRoom, allRooms, makeId } = roomStore; const { makeRoom, makePlayer } = require("../server/game-state.js"); // Unref the room-store cleanup interval so node exits cleanly after tests. function unrefAllHandles() { const handles = process._getActiveHandles ? process._getActiveHandles() : []; for (const h of handles) { if (h && typeof h.unref === "function") { try { h.unref(); } catch (_) {} } } } unrefAllHandles(); // --- word-utils tests --- test("normalize: lowercases and trims", () => { assert.strictEqual(normalize(" HELLO "), "hello"); assert.strictEqual(normalize("Foo Bar"), "foo bar"); assert.strictEqual(normalize("MULTI SPACE"), "multi space"); assert.strictEqual(normalize(""), ""); assert.strictEqual(normalize(undefined), ""); assert.strictEqual(normalize(null), ""); }); test("maskWord: blanks letters but preserves spaces", () => { const out = maskWord("ice cream"); // letters become "_", space stays assert.strictEqual(out, "___ _____"); }); test("maskWord: with all indices revealed produces lowercased word", () => { const word = "Apple"; const revealed = new Set([0, 1, 2, 3, 4]); const out = maskWord(word, revealed); assert.strictEqual(out, "apple"); }); test("maskWord: partial reveal mixes letters and underscores", () => { const out = maskWord("apple", new Set([0, 2])); // a _ p _ _ assert.strictEqual(out, "a_p__"); }); test("pickRevealIndex: picks unrevealed non-space index", () => { const word = "ab cd"; const revealed = new Set([0]); const idx = pickRevealIndex(word, revealed); // valid candidate indices: 1, 3, 4 assert.ok([1, 3, 4].includes(idx), `unexpected index ${idx}`); assert.notStrictEqual(idx, 2); // never the space }); test("pickRevealIndex: returns null when all letters revealed", () => { const word = "ab"; const revealed = new Set([0, 1]); assert.strictEqual(pickRevealIndex(word, revealed), null); }); test("revealHint flow: applying picked index reveals exactly N more letters", () => { // simulate revealing: count underscores decreases by exactly 1 each step let revealed = new Set(); const word = "rocket"; let prevMask = maskWord(word, revealed); let prevUnderscores = (prevMask.match(/_/g) || []).length; for (let i = 0; i < 3; i++) { const idx = pickRevealIndex(word, revealed); assert.notStrictEqual(idx, null); revealed.add(idx); const m = maskWord(word, revealed); const newUnderscores = (m.match(/_/g) || []).length; assert.strictEqual(newUnderscores, prevUnderscores - 1, "exactly 1 letter revealed"); prevUnderscores = newUnderscores; prevMask = m; } }); test("revealHint idempotence: re-applying same revealed set yields same mask", () => { const word = "tree"; const revealed = new Set([0, 2]); const m1 = maskWord(word, revealed); const m2 = maskWord(word, revealed); assert.strictEqual(m1, m2); }); test("isCloseGuess: edit distance 1-2 returns true", () => { assert.strictEqual(isCloseGuess("aple", "apple"), true); // 1 edit assert.strictEqual(isCloseGuess("aplee", "apple"), true); // 1 edit assert.strictEqual(isCloseGuess("apple", "apple"), false); // exact match -> not "close" assert.strictEqual(isCloseGuess("xyz", "apple"), false); // too far }); // --- room-store tests --- test("genCode: returns a unique 6-char alphanumeric code", () => { const seen = new Set(); for (let i = 0; i < 10; i++) { const c = genCode(); assert.strictEqual(typeof c, "string"); assert.strictEqual(c.length, 6); assert.ok(/^[A-Z0-9]+$/.test(c), `code ${c} should be uppercase alnum`); seen.add(c); } assert.strictEqual(seen.size, 10, "codes should be unique"); }); test("setRoom + getRoom: returns same instance, case-insensitive lookup", () => { const code = genCode(); const player = makePlayer({ nickname: "U", avatar: "x" }); const room = makeRoom({ code, hostId: player.id, mode: "skribbl", settings: {} }); room.players.push(player); setRoom(code, room); const got = getRoom(code); assert.strictEqual(got, room, "same instance"); // lower-case lookup also works const gotLower = getRoom(code.toLowerCase()); assert.strictEqual(gotLower, room); deleteRoom(code); assert.strictEqual(getRoom(code), undefined); }); test("makeId: returns a non-empty string id", () => { const id1 = makeId(); const id2 = makeId(); assert.strictEqual(typeof id1, "string"); assert.ok(id1.length > 0); assert.notStrictEqual(id1, id2); }); test("allRooms: exposes the underlying Map", () => { const map = allRooms(); assert.ok(map instanceof Map); }); // Force exit after a short tick — room-store uses an unrefable setInterval // that we already unrefed above, but be defensive. process.on("beforeExit", () => process.exit(0)); setTimeout(() => process.exit(0), 1500).unref();