Files
lifegame/shared/tileEffects.test.js
Kevin 6749777b45 Extend tests for the real board and add tileEffects unit tests
game.test.js: rewritten for the new {type, rolls} action shape and 5-stat
player model. The full-playthrough test now walks the real 212-space board
turn by turn (deterministic dummy rolls — always the low end of each die's
range) until finish, asserting every stat stays numeric and every one of
the 9 real tile types actually gets resolved along the way. board-shape
assertions updated to match (212 spaces, no `choices` populated yet).

tileEffects.test.js (new): unit tests per tile type in isolation, including
exact deterministic assertions against real placeholder-table content (roll
a known value, assert the exact banded effect) and a multi-table
roll_table_ref cross-check computed independently from the raw table data —
no seeded-rng machinery anywhere, same explicit-action-value pattern the
reducer tests already used. Also a sweep resolving every real tile across
the full range of its die to catch any type/die combination that throws.

package.json: test script now runs shared/*.test.js instead of naming one
file.
2026-07-28 10:08:37 -07:00

111 lines
4.7 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveTileEffect, rollsNeededFor, DIE_SIZES } from './tileEffects.js';
import { board } from './board.js';
import rollTables from './rollTables.js';
const spaces = Object.values(board.spaces);
const byType = (type) => spaces.filter((s) => s.type === type);
test('DIE_SIZES covers every die actually referenced in the real board', () => {
const usedDice = new Set(spaces.map((s) => s.die).filter(Boolean));
for (const die of usedDice) assert.ok(DIE_SIZES[die], `unknown die size: ${die}`);
});
test('rollsNeededFor asks for exactly one roll per referenced table', () => {
const multi = byType('roll_table_ref').find((s) => s.externalTables.length === 3);
assert.ok(multi, 'expected a 3-table roll_table_ref tile in the real board');
assert.equal(rollsNeededFor(multi).length, 3);
const single = byType('dice_space')[0];
assert.equal(rollsNeededFor(single).length, 1);
const payday = byType('payday')[0];
assert.deepEqual(rollsNeededFor(payday), []);
});
test('dice_space resolves against its real referenced table with an exact banded effect', () => {
const space = byType('dice_space')[0];
const table = rollTables.tables.find((t) => t.source_doc_id === space.externalTables[0]);
assert.ok(table, 'dice_space tile should reference a real table');
const [loStr] = table.entries[0].range.split('-');
const roll = Number(loStr);
const result = resolveTileEffect(space, [roll]);
assert.deepEqual(result.statDelta, table.entries[0].effect);
assert.equal(result.todo, false, 'a real referenced table is not an invented placeholder');
});
test('multi-table roll_table_ref sums every referenced table\'s effect', () => {
const multi = byType('roll_table_ref').find((s) => s.externalTables.length >= 2);
const rolls = rollsNeededFor(multi).map(() => 1); // roll the minimum on every table
const expected = {};
for (const tableId of multi.externalTables) {
const table = rollTables.tables.find((t) => t.source_doc_id === tableId);
for (const [stat, delta] of Object.entries(table.entries[0].effect)) {
expected[stat] = (expected[stat] ?? 0) + delta;
}
}
assert.deepEqual(resolveTileEffect(multi, rolls).statDelta, expected);
});
test('stop applies the rolled D8 value directly to age, no table lookup', () => {
const stop = byType('stop')[0];
assert.equal(stop.die, 'D8');
const result = resolveTileEffect(stop, [5]);
assert.deepEqual(result.statDelta, { age: 5 });
assert.equal(result.todo, false, 'age += roll is a specified mechanic, not invented content');
});
test('choice tiles are a harmless no-op while no board-graph.json choices exist yet', () => {
const choice = byType('choice')[0];
assert.equal(choice.choices, undefined);
const result = resolveTileEffect(choice, []);
assert.deepEqual(result.statDelta, {});
assert.equal(result.todo, true);
});
test('inline_table tiles resolve against a synthesized placeholder, clearly marked todo', () => {
const inline = byType('inline_table')[0];
assert.deepEqual(rollsNeededFor(inline), [inline.die]);
const max = DIE_SIZES[inline.die];
const result = resolveTileEffect(inline, [max]); // top of the range
assert.equal(result.todo, true);
assert.equal(typeof result.statDelta.cash, 'number');
});
test('payday and named cash_bonus tiles use fixed placeholder amounts, no roll needed', () => {
const payday = byType('payday')[0];
assert.deepEqual(rollsNeededFor(payday), []);
const paydayResult = resolveTileEffect(payday, []);
assert.ok(paydayResult.statDelta.cash > 0);
assert.equal(paydayResult.todo, true);
const hundredK = byType('cash_bonus').find((s) => s.label === '100K');
assert.deepEqual(resolveTileEffect(hundredK, []).statDelta, { cash: 100000 });
const tenK = byType('cash_bonus').find((s) => s.label === '10K');
assert.deepEqual(resolveTileEffect(tenK, []).statDelta, { cash: 10000 });
});
test('401k (a cash_bonus with a die but no table) falls into the synthesized placeholder path', () => {
const four01k = byType('cash_bonus').find((s) => s.label === '401k');
assert.ok(four01k, 'expected a 401k cash_bonus tile');
assert.equal(four01k.die, 'D20');
assert.deepEqual(rollsNeededFor(four01k), ['D20']);
const result = resolveTileEffect(four01k, [20]);
assert.equal(result.todo, true);
});
test('every real tile resolves without throwing across the full range of its die', () => {
for (const space of spaces) {
const dice = rollsNeededFor(space);
for (const roll of [1, ...dice.map((d) => DIE_SIZES[d])]) {
const rolls = dice.map(() => roll);
assert.doesNotThrow(() => resolveTileEffect(space, rolls), `${space.id} (${space.type}) threw on roll=${roll}`);
}
}
});