Files
two-keys/server/test/content.test.ts
T
joshuaandClaude Opus 5.5 0de6617c82 V1.0.0
Two Keys: co-op mystery games for couples. Eight games on four engines (story, defuse, sync, labyrinth), two-device and couch play, mobile-first UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:36:12 +02:00

78 lines
3.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { loadAllFiles } from '../src/content/loader';
import { gameSchema } from '../src/content/schemas';
import { validateGame } from '../src/content/validate';
import { CODE_ALPHABET, makeCode } from '../src/rooms/codes';
import { ROOM_TTL_MS, RoomStore } from '../src/rooms/store';
import { games, getGame, labyrinth, sync } from './helpers';
describe('content', () => {
it('loads all 8 games with no validation errors', () => {
const files = loadAllFiles();
expect(files.flatMap((f) => f.errors.map((e) => `${f.file}: ${e}`))).toEqual([]);
expect(files).toHaveLength(8);
expect([...games.values()].map((g) => g.engine).sort()).toEqual(
['defuse', 'labyrinth', 'story', 'story', 'story', 'story', 'story', 'sync'],
);
});
it('rejects a story chapter with no clue for one player', () => {
const game = structuredClone(getGame('clockmakers-attic', 'story'));
game.chapters[0].clues.B = [];
expect(validateGame(game).join('\n')).toMatch(/at least one clue for B/);
});
it('rejects a finale answer that is not an option, and a lock without 3 hints', () => {
const game = structuredClone(getGame('vineyard-hollow', 'story'));
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
game.finale.questions[0].answer = 'nobody';
expect(validateGame(game).join('\n')).toMatch(/not one of the options/);
const raw = structuredClone(getGame('vineyard-hollow', 'story')) as any;
raw.chapters[0].lock.hints.pop();
expect(gameSchema.safeParse(raw).success).toBe(false);
});
it('rejects a labyrinth whose lantern is only reachable through a trap', () => {
const game = structuredClone(labyrinth());
// Wall off (1,6)... the only trap-free way to the Listening lantern is via (3,6)/(3,7).
game.grid[6] = '#######.#.#';
expect(validateGame(game).join('\n')).toMatch(/not reachable/);
});
it('rejects a labyrinth with ragged rows or the wrong number of lanterns', () => {
const ragged = structuredClone(labyrinth());
ragged.grid[1] = ragged.grid[1] + '#';
expect(validateGame(ragged).join('\n')).toMatch(/same length/);
const fewer = structuredClone(labyrinth());
fewer.grid[7] = '#...#.T.#.#';
expect(validateGame(fewer).join('\n')).toMatch(/exactly 3 K/);
});
it('rejects a sync bank with fewer than 15 questions', () => {
const game = structuredClone(sync());
game.match = game.match.slice(0, 14);
expect(validateGame(game).join('\n')).toMatch(/at least 15/);
});
});
describe('rooms', () => {
it('makes 5-char codes with no confusable characters', () => {
for (let i = 0; i < 200; i++) {
const code = makeCode();
expect(code).toMatch(/^[A-Z2-9]{5}$/);
expect(code).not.toMatch(/[0O1I]/);
}
expect(CODE_ALPHABET).not.toMatch(/[0O1I]/);
});
it('expires rooms after 24h idle', () => {
const store = new RoomStore(null);
const { room } = store.create('in-sync', 'duo', 0);
expect(store.sweep(ROOM_TTL_MS - 1)).toBe(0);
expect(store.sweep(ROOM_TTL_MS + 1)).toBe(1);
expect(store.get(room.code)).toBeUndefined();
});
});