clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / tests / board.test.js
board.test.js 10.9K
1import test from 'node:test';2import assert from 'node:assert/strict';3import { drawBoard } from '../src/render/board.js';45function context() {6 const calls = [];7 const ctx = {8 canvas: { width: 320, height: 240 }, calls, styles: [],9 save() { calls.push(['save']); }, restore() { calls.push(['restore']); },10 translate(...args) { calls.push(['translate', ...args]); }, scale(...args) { calls.push(['scale', ...args]); },11 beginPath() { calls.push(['beginPath']); }, closePath() { calls.push(['closePath']); }, moveTo(...args) { calls.push(['moveTo', ...args]); }, lineTo(...args) { calls.push(['lineTo', ...args]); },12 rect(...args) { calls.push(['rect', ...args]); }, roundRect(...args) { calls.push(['roundRect', ...args]); },13 fill() { calls.push(['fill', this._fillStyle]); }, stroke() { calls.push(['stroke']); }, clip() { calls.push(['clip']); },14 fillRect(...args) { calls.push(['fillRect', ...args]); },15 arc(...args) { calls.push(['arc', ...args]); }, ellipse(...args) { calls.push(['ellipse', ...args]); },16 fillText(...args) { calls.push(['fillText', ...args]); },17 measureText(text) { return { width: String(text).length * 8 }; },18 };19 Object.defineProperty(ctx, 'fillStyle', { get() { return this._fillStyle; }, set(value) { this._fillStyle = value; this.styles.push(value); } });20 return ctx;21}2223const state = {24 width: 4, height: 3,25 objects: [26 { id: 'miau', kind: 'word', text: 'MIAU', x: 0, y: 0 },27 { id: 'is', kind: 'word', text: 'IS', x: 1, y: 0 },28 { id: 'you', kind: 'word', text: 'YOU', x: 2, y: 0 },29 { id: 'cat', kind: 'object', noun: 'CAT', x: 1, y: 1 },30 { id: 'wall', kind: 'object', noun: 'WALL', x: 3, y: 2 },31 ],32 rules: [{ subject: 'MIAU', predicate: 'YOU', text: 'MIAU IS YOU' }],33 sentences: [{ ids: ['miau', 'is', 'you'], axis: 'h' }],34};3536test('drawBoard computes geometry and keeps word tiles inside a 74% footprint', () => {37 const ctx = context();38 const geometry = drawBoard(ctx, state, { x: 20, y: 10, cell: 50, palette: { CAT: '🐈' } });39 assert.deepEqual(geometry, { x: 20, y: 10, cell: 50, width: 4, height: 3, boardWidth: 200, boardHeight: 150 });40 assert.ok(ctx.calls.some(call => call[0] === 'roundRect' && call[3] === 37));41 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐈'));42});4344test('drawBoard caches custom properties once per object for all render passes', () => {45 const ctx = context();46 let calls = 0;47 const game = {48 properties() {49 calls += 1;50 return [];51 },52 };53 drawBoard(ctx, state, { x: 20, y: 10, cell: 50, game });54 assert.equal(calls, state.objects.length);55});5657test('drawBoard draws sentence bars from physical sentence IDs and merges walls flush', () => {58 const ctx = context();59 drawBoard(ctx, state, { x: 20, y: 10, cell: 50 });60 const horizontalBars = ctx.calls.filter(call => call[0] === 'roundRect' && call[1] >= 20 && call[2] > 50);61 assert.ok(horizontalBars.length >= 1);62 assert.equal(horizontalBars[0][1], 26.5);63 assert.equal(horizontalBars[0][3], 139);64 assert.ok(ctx.calls.some(call => call[0] === 'fillRect' && call[1] === 170 && call[2] === 110 && call[3] === 50 && call[4] === 50));65 const barIndex = ctx.calls.indexOf(horizontalBars[0]);66 assert.deepEqual(ctx.calls[barIndex + 1], ['fill', '#F2EEE6']);67});6869test('drawBoard dims a no-YOU board and shows recovery copy', () => {70 const ctx = context();71 const lost = { ...state, rules: [], sentences: [] };72 drawBoard(ctx, lost, { x: 0, y: 0, cell: 40 });73 assert.ok(ctx.calls.some(call => call[0] === 'fillRect' && call[1] === 0 && call[2] === 0 && call[3] === 160 && call[4] === 120));74 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'NOTHING'));75 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'Z TO UNDO | R TO RESTART'));76});7778test('drawBoard consumes juice positions and scales moving entities', () => {79 const ctx = context();80 const frame = {81 positions: new Map([['cat', { x: 1.5, y: 1, scaleX: 1.12, scaleY: 0.94, moving: true }]]),82 sentences: [], effects: [], shake: { amplitude: 2 }, tint: null, wipe: null,83 };84 drawBoard(ctx, state, { x: 20, y: 10, cell: 50, glyphs: { CAT: '🐈' }, frame });85 assert.ok(ctx.calls.some(call => call[0] === 'translate' && call[1] === 2 * 0.35));86 assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === 1.12 && call[2] === 0.94));87 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐈'));88});8990test('drawBoard keeps native left-facing actors and mirrors right-facing actors', () => {91 const ctx = context();92 const frame = {93 positions: new Map([['cat', { x: 1, y: 1, facing: 'left' }]]),94 sentences: [], effects: [], shake: { amplitude: 0 }, tint: null, wipe: null,95 };96 drawBoard(ctx, state, { x: 20, y: 10, cell: 50, glyphs: { CAT: '🐈' }, frame });97 assert.equal(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1), false);98 ctx.calls.length = 0;99 frame.positions.set('cat', { x: 1, y: 1, facing: 'right' });100 drawBoard(ctx, state, { x: 20, y: 10, cell: 50, glyphs: { CAT: '🐈' }, frame });101 assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1));102});103104test('drawBoard keeps custom plain glyphs neutral and flips explicit mod faces', () => {105 const ctx = context();106 const frame = { positions: new Map([['cat', { x: 1, y: 1, facing: 'right' }]]), sentences: [], effects: [], shake: { amplitude: 0 }, tint: null, wipe: null };107 const custom = { ...state, objects: state.objects.map(object => object.id === 'cat' ? { ...object, noun: 'UNICORN' } : object) };108 drawBoard(ctx, custom, { x: 20, y: 10, cell: 50, palette: { UNICORN: '🦄' }, frame });109 assert.equal(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1), false);110 ctx.calls.length = 0;111 frame.positions.set('cat', { x: 1, y: 1, facing: 'left' });112 drawBoard(ctx, custom, { x: 20, y: 10, cell: 50, palette: { UNICORN: { glyph: '🦄', faces: 'right' } }, frame });113 assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1));114});115116test('drawBoard faces MIAU forward when a bundled pack repeats the cat as a string', () => {117 const ctx = context();118 const miauState = {119 ...state,120 objects: state.objects.map(object => object.id === 'cat' ? { ...object, noun: 'MIAU' } : object),121 };122 const frame = { positions: new Map([['cat', { x: 1, y: 1, facing: 'right' }]]), sentences: [], effects: [] };123 drawBoard(ctx, miauState, { x: 20, y: 10, cell: 50, palette: { MIAU: '🐈' }, frame });124 assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1));125 ctx.calls.length = 0;126 frame.positions.set('cat', { x: 1, y: 1, facing: 'left' });127 drawBoard(ctx, miauState, { x: 20, y: 10, cell: 50, palette: { MIAU: '🐈' }, frame });128 assert.equal(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1), false);129});130131test('drawBoard compares movement facing with native mod facing and isolates glyph squash', () => {132 const ctx = context();133 const stateWithModActor = {134 ...state,135 objects: state.objects.map(object => object.id === 'cat' ? { ...object, noun: 'UNICORN' } : object),136 };137 const frame = { positions: new Map([['cat', { x: 1, y: 1, facing: 'left', glyphScaleX: 0.8 }]]), sentences: [], effects: [], shake: { amplitude: 0 }, tint: null, wipe: null };138 drawBoard(ctx, stateWithModActor, { x: 20, y: 10, cell: 50, palette: { UNICORN: { glyph: '🦄', faces: 'right' } }, frame });139 assert.ok(ctx.calls.some(call => call[0] === 'scale' && call[1] === -0.8 && call[2] === 1));140});141142test('drawBoard leaves a non-directional FENN paw glyph unchanged', () => {143 const ctx = context();144 const fennState = {145 ...state,146 objects: state.objects.map(object => object.id === 'cat' ? { ...object, noun: 'FENN' } : object),147 };148 drawBoard(ctx, fennState, {149 x: 20, y: 10, cell: 50,150 palette: { FENN: '🐾' },151 frame: { positions: new Map([['cat', { x: 1, y: 1, facing: 'right' }]]), sentences: [], effects: [] },152 });153 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '🐾'));154 assert.equal(ctx.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1), false);155});156157test('drawBoard animates sentence bars from frozen cells and renders frame overlays', () => {158 const ctx = context();159 const frame = {160 positions: new Map(),161 sentences: [{ ids: ['miau', 'is', 'you'], axis: 'h', progress: 0.5, frozenCells: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }] }],162 effects: [{ type: 'sink', text: 'MIAU', cell: { x: 1, y: 1 }, color: 'SINK', progress: 0.25 }],163 shake: { amplitude: 0 }, tint: { progress: 0.5, color: 'cool' }, wipe: { progress: 0.5 },164 };165 drawBoard(ctx, state, { x: 20, y: 10, cell: 50, frame });166 const animatedBar = ctx.calls.find(call => call[0] === 'roundRect' && call[1] === 26.5 && call[2] === 56.75);167 assert.ok(animatedBar, 'expected the sentence bar to use the frozen cells');168 assert.equal(animatedBar[3], 69.5);169 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'MIAU'));170 assert.ok(ctx.calls.some(call => call[0] === 'fillRect' && call[1] === 20 && call[2] === 10 && call[3] === 200 && call[4] === 150));171});172173test('drawBoard renders automaton wait as two centered bottom-gutter bars', () => {174 const ctx = context();175 const automatonState = {176 ...state,177 objects: [...state.objects, { id: 'bell', kind: 'object', noun: 'BELL', x: 1, y: 1 }],178 automata: [{ id: 'bell-clock', subject: 'BELL', property: 'TICK', phase: 0, active: true, pattern: [{ action: 'wait' }] }],179 };180 drawBoard(ctx, automatonState, {181 x: 20,182 y: 10,183 cell: 50,184 game: { properties: object => object.id === 'bell' ? ['TICK'] : [] },185 });186 const cueFills = ctx.calls.filter(call => call[0] === 'fill' && call[1] === '#C6F432');187 assert.equal(cueFills.length, 2);188 const cueRects = ctx.calls.filter(call => call[0] === 'rect' && call[3] <= 2 && call[4] <= 5);189 assert.equal(cueRects.length, 2);190 assert.ok(cueRects.every(call => call[2] > 10 + 50 * 1.5), 'wait bars belong in the bottom gutter');191});192193test('drawBoard moves the automaton chevron to the active carrier cell', () => {194 const ctx = context();195 const automatonState = {196 ...state,197 objects: [...state.objects, { id: 'bell', kind: 'object', noun: 'BELL', x: 2, y: 1 }],198 automata: [{ id: 'bell-clock', subject: 'BELL', property: 'TICK', phase: 0, active: true, pattern: [{ action: 'move', dx: -1, dy: 0 }] }],199 };200 drawBoard(ctx, automatonState, {201 x: 20,202 y: 10,203 cell: 50,204 game: { properties: object => object.id === 'bell' ? ['TICK'] : [] },205 });206 const cueFills = ctx.calls.filter(call => call[0] === 'fill' && call[1] === '#C6F432');207 assert.equal(cueFills.length, 1);208 const cuePoints = ctx.calls209 .filter(call => call[0] === 'moveTo' || call[0] === 'lineTo')210 .flatMap(call => [call[1], call[2]]);211 assert.ok(cuePoints.some(value => value >= 20 + 2 * 50 && value <= 20 + 2 * 50 + 50 * 0.13), 'left-facing cue stays inside the outer cell gutter');212});