clone

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

main
miau-is-true / tests / workshop.test.js
workshop.test.js 13.4K
1import test from 'node:test';2import assert from 'node:assert/strict';3import { createWorkshopScene } from '../src/scenes/workshop.js';45const makePack = (id, origin = 'local') => ({6 id, origin, levelCount: 2,7 manifest: { id, name: id.toUpperCase(), version: '1.0.0', author: 'Tester' },8});910function serviceWith(packs) {11 return {12 listPacks: () => packs,13 packLink: id => `https://example.test/#pack=${id}`,14 exportPack: id => ({ id }),15 removePack: id => { const index = packs.findIndex(pack => pack.id === id); if (index < 0) return false; packs.splice(index, 1); return true; },16 };17}1819function canvasContext() {20 const calls = [];21 return {22 canvas: { width: 800, height: 600 },23 calls,24 fillRect(...args) { calls.push(['fillRect', ...args]); }, strokeRect(...args) { calls.push(['strokeRect', ...args]); }, beginPath() {}, rect() {}, roundRect() {}, fill() {}, stroke() {},25 fillText(...args) { calls.push(['fillText', ...args]); }, measureText(value) { return { width: String(value).length * 8 }; },26 };27}2829test('bundled packs do not expose a remove action', () => {30 const scene = createWorkshopScene({ packs: [makePack('core', 'bundled')] });31 scene.handleInput('down');32 assert.deepEqual(scene.view().rows[1].actions, ['play', 'share', 'export']);33 scene.handleInput('right'); scene.handleInput('right'); scene.handleInput('right');34 assert.equal(scene.view().selectedActionName, 'export');35});3637test('removing an imported pack calls the service and refreshes rows', async () => {38 const packs = [makePack('garden')];39 const scene = createWorkshopScene({ service: serviceWith(packs) });40 scene.handleInput('down');41 scene.handleInput('right'); scene.handleInput('right'); scene.handleInput('right');42 const prompt = await scene.handleInput('enter');43 assert.equal(prompt.event.type, 'remove-confirm');44 assert.match(prompt.confirm.name, /GARDEN/);45 assert.deepEqual(packs.map(pack => pack.id), ['garden']);46 const result = await scene.handleInput('enter');47 assert.equal(result.event.type, 'remove');48 assert.deepEqual(scene.view().rows.map(row => row.id), ['__import__']);49 assert.equal(scene.view().feedback.message, 'PACK REMOVED');50});5152test('cancel leaves the pack installed and pointer choices can confirm removal', async () => {53 const packs = [makePack('garden')];54 const scene = createWorkshopScene({ service: serviceWith(packs) });55 scene.handleInput('down'); scene.handleInput('right'); scene.handleInput('right'); scene.handleInput('right');56 await scene.handleInput('enter');57 assert.equal(scene.view().confirm.choices[scene.view().confirm.choice], 'REMOVE');58 scene.handleInput('right'); scene.handleInput('enter');59 assert.deepEqual(packs.map(pack => pack.id), ['garden']);60 assert.equal(scene.view().confirm, null);61 scene.handleInput('right'); scene.handleInput('right'); scene.handleInput('right');62 scene.draw(canvasContext(), { width: 800, height: 600 });63 await scene.hit(650, 245); // REMOVE action opens the card.64 scene.draw(canvasContext(), { width: 800, height: 600 });65 const result = await scene.hit(300, 340); // REMOVE choice on the card.66 assert.equal(result.event.type, 'remove');67 assert.deepEqual(packs, []);68});6970test('share action passes the generated URL to the callback and gives feedback', async () => {71 const shared = [];72 const scene = createWorkshopScene({73 service: serviceWith([makePack('garden')]),74 onShare: (id, url) => shared.push({ id, url }),75 });76 scene.handleInput('down'); scene.handleInput('right');77 const result = await scene.handleInput('enter');78 assert.deepEqual(shared, [{ id: 'garden', url: 'https://example.test/#pack=garden' }]);79 assert.equal(result.event.type, 'share');80 assert.equal(scene.view().feedback.message, 'LINK IS COPIED');81});8283test('validation note shows five errors and copy details can be injected', async () => {84 let copied = '';85 const scene = createWorkshopScene({ copyToClipboard: async value => { copied = value; } });86 scene.showErrors([87 { message: 'one', detail: 'ONE detail' }, { message: 'two' }, { message: 'three' },88 { message: 'four' }, { message: 'five' }, { message: 'six' }, { message: 'seven' },89 ]);90 const view = scene.view();91 assert.deepEqual(view.note.visible, ['one', 'two', 'three', 'four', 'five']);92 assert.equal(view.note.remaining, 2);93 assert.equal(await scene.handleInput('c'), true);94 assert.equal(copied, 'one\nONE detail\ntwo\nthree\nfour\nfive\nsix\nseven');95 assert.equal(scene.view().feedback.message, 'DETAILS COPIED');96});9798test('warning notes add an amber Workshop chip and NOTES action', async () => {99 let copied = '';100 const pack = {101 ...makePack('clockwork'),102 levels: [{ id: 'door', title: 'Key and door', objects: [] }],103 warnings: [104 { word: 'SHUT', levelId: 'door', levelTitle: 'Key and door', sentence: 'DOOR IS SHUT', message: 'SHUT is not a rule word here.', detail: 'levels[0].objects[2].text' },105 { word: 'OPEN', levelId: 'door', levelTitle: 'Key and door', sentence: 'DOOR IS OPEN', message: 'OPEN is not a rule word here.', detail: 'levels[0].objects[3].text' },106 ],107 };108 const scene = createWorkshopScene({ packs: [pack], copyToClipboard: async value => { copied = value; } });109 scene.handleInput('down');110 assert.deepEqual(scene.view().rows[1].actions, ['play', 'notes', 'share', 'export', 'remove']);111 assert.equal(scene.view().rows[1].notes, 2);112 scene.handleInput('right');113 const result = await scene.handleInput('enter');114 assert.equal(result.event.type, 'notes');115 assert.equal(scene.view().note.kind, 'warning');116 assert.match(scene.accessibilityText(), /Pack notes.*Proof 1, Key and door: SHUT isn't a rule word here/);117 assert.deepEqual(scene.view().note.visible, ['Proof 1, Key and door: SHUT isn\'t a rule word here, so DOOR IS SHUT would turn every door into a SHUT.', 'Proof 1, Key and door: OPEN isn\'t a rule word here, so DOOR IS OPEN would turn every door into an OPEN.']);118 assert.equal(scene.view().note.visible.some(line => line.includes('\u00b7')), false);119 scene.draw(canvasContext(), { width: 800, height: 600 });120 const labels = scene.hitLayout.rows.flatMap(row => row.actions.map(action => action.action));121 assert.ok(labels.includes('notes'));122 scene.draw(canvasContext(), { width: 390, height: 844 });123 const mobileRow = scene.hitLayout.rows.find(row => row.index === 1);124 assert.ok(mobileRow.actions.every(action => action.x >= 24 && action.x + action.width <= 366));125 assert.ok(mobileRow.actions.every(action => action.height >= 44));126 assert.ok(scene.hitLayout.noteCard.x > 0 && scene.hitLayout.noteCard.y > 0);127 assert.ok(scene.hitLayout.noteCard.x + scene.hitLayout.noteCard.width <= 390);128 assert.ok(scene.hitLayout.noteCard.y + scene.hitLayout.noteCard.height <= 844);129 const noteText = canvasContext();130 scene.draw(noteText, { width: 390, height: 844 });131 const renderedNoteLines = noteText.calls.filter(call => call[0] === 'fillText' && String(call[1]).includes('Proof 1'));132 assert.ok(renderedNoteLines.length >= 2, 'long note text wraps inside the mobile card');133 assert.ok(renderedNoteLines.every(call => String(call[1]).length < 42), 'wrapped note lines stay bounded');134 const closed = scene.hit(2, 2);135 assert.equal(closed.event.type, 'notes-close');136 await scene.handleInput('enter');137 const closeTarget = scene.hitLayout.noteClose;138 const closedByButton = scene.hit(closeTarget.x + 4, closeTarget.y + 4);139 assert.equal(closedByButton.event.type, 'notes-close');140 await scene.handleInput('enter');141 assert.equal(await scene.handleInput('c'), true);142 assert.match(copied, /SHUT/);143 scene.handleInput('escape');144 assert.equal(scene.view().note, null);145});146147test('service envelopes show structured errors and do not trigger failed callbacks', async () => {148 const shared = []; const exported = [];149 const scene = createWorkshopScene({150 packs: [makePack('garden')],151 service: {152 packLink: () => ({ ok: false, url: '', errors: [{ message: 'Link blocked', detail: 'Payload too large.' }] }),153 exportPack: () => ({ ok: false, blob: null, errors: [{ message: 'Export blocked', detail: 'Download unavailable.' }] }),154 },155 onShare: (...args) => shared.push(args), onExport: (...args) => exported.push(args),156 });157 scene.handleInput('down'); scene.handleInput('right');158 const shareFailure = await scene.handleInput('enter');159 assert.equal(shareFailure.note.visible[0], 'Link blocked');160 assert.deepEqual(shared, []);161 scene.showErrors([]);162 scene.handleInput('right'); scene.handleInput('enter');163 assert.equal(scene.view().note.visible[0], 'Export blocked');164 assert.deepEqual(exported, []);165});166167test('hit selects a rendered row and dispatches its action cell', async () => {168 const played = [];169 const scene = createWorkshopScene({ service: serviceWith([makePack('garden')]), onPlay: id => played.push(id) });170 scene.draw(canvasContext(), { width: 800, height: 600 });171 // The imported pack is row 1; its PLAY cell is the first action at x 368.172 const result = await scene.hit(380, 130 + 84 + 42);173 assert.deepEqual(played, ['garden']);174 assert.equal(result.event.type, 'play');175 assert.equal(scene.view().selectedRow, 1);176 assert.equal(scene.view().selectedActionName, 'play');177});178179test('tapping a pack row opens its actions and action hit targets are at least 44px high', () => {180 const scene = createWorkshopScene({ packs: [makePack('garden')] });181 const ctx = canvasContext();182 scene.draw(ctx, { width: 800, height: 600 });183 const rowTap = scene.hit(100, 130 + 70 + 25);184 assert.equal(rowTap.event.type, 'actions');185 assert.equal(rowTap.event.packId, 'garden');186 assert.equal(scene.view().selectedActionName, 'play');187 const packRow = scene.hitLayout.rows.find(row => row.index === 1);188 assert.ok(packRow.actions.every(action => action.height >= 44));189});190191test('mobile Workshop keeps header and pack actions within the viewport while selection scrolls', () => {192 const scene = createWorkshopScene({ packs: Array.from({ length: 6 }, (_, index) => makePack(`pack-${index}`)) });193 const ctx = canvasContext();194 scene.draw(ctx, { width: 390, height: 844 });195 const labels = ctx.calls.filter(call => call[0] === 'fillText').map(call => call[1]);196 assert.ok(labels.includes('Packs of proofs'));197 assert.ok(labels.includes("you've added"));198 assert.ok(!labels.includes("Packs of proofs you've added"));199 assert.equal(labels.includes('ENTER'), false);200 assert.equal(labels.includes('ESC'), false);201 for (const row of scene.hitLayout.rows) {202 for (const action of row.actions) {203 assert.ok(action.x >= 24 && action.x + action.width <= 366);204 assert.ok(action.y >= row.y && action.y + action.height <= row.y + row.height);205 assert.ok(action.height >= 44);206 }207 }208 for (let index = 0; index < 5; index += 1) scene.handleInput('down');209 scene.draw(canvasContext(), { width: 390, height: 844 });210 assert.ok(scene.hitLayout.rows.some(row => row.index === scene.selectedRow));211 assert.ok(scene.hitLayout.rows.every(row => row.y + row.height <= 844));212});213214test('IMPORT label is vertically centered in its row', () => {215 const scene = createWorkshopScene({ packs: [makePack('garden')] });216 const ctx = canvasContext();217 scene.draw(ctx, { width: 800, height: 600 });218 const importLabel = ctx.calls?.find(call => call[0] === 'fillText' && call[1] === 'IMPORT…');219 const importIcon = ctx.calls?.find(call => call[0] === 'fillText' && call[1] === '+');220 assert.equal(importLabel[3], 167);221 assert.equal(importIcon[3], importLabel[3]);222});223224test('header hints render keycaps before lowercase verbs', () => {225 const scene = createWorkshopScene({ packs: [makePack('garden')] });226 const ctx = canvasContext();227 scene.draw(ctx, { width: 800, height: 600 });228 const labels = ctx.calls.filter(call => call[0] === 'fillText').map(call => call[1]);229 for (const label of ['↑↓', 'choose', '←→', 'action', 'ENTER', 'select', 'ESC', 'back']) assert.ok(labels.includes(label), `missing ${label}`);230 assert.equal(labels.some(label => String(label).includes('[')), false);231});232233test('Workshop hides header keycaps below 760px and for coarse pointers', () => {234 const scene = createWorkshopScene({ packs: [makePack('garden')] });235 const medium = canvasContext();236 scene.draw(medium, { width: 700, height: 600 });237 const mediumLabels = medium.calls.filter(call => call[0] === 'fillText').map(call => call[1]);238 assert.equal(mediumLabels.includes('ENTER'), false);239 assert.equal(mediumLabels.includes('ESC'), false);240 const coarse = canvasContext();241 scene.draw(coarse, { width: 800, height: 600, coarsePointer: true });242 const coarseLabels = coarse.calls.filter(call => call[0] === 'fillText').map(call => call[1]);243 assert.equal(coarseLabels.includes('ENTER'), false);244 assert.equal(coarseLabels.includes('ESC'), false);245});246247test('uses deck copy, resolves COPY OF to a source name, and uses sentence case errors', () => {248 const scene = createWorkshopScene({ packs: [249 makePack('source'),250 { ...makePack('fork'), forkOf: 'source', manifest: { ...makePack('fork').manifest, name: 'Fork' } },251 ] });252 assert.equal(scene.view().title, "Packs of proofs you've added");253 const ctx = canvasContext();254 scene.draw(ctx, { width: 800, height: 600 });255 const labels = ctx.calls.filter(call => call[0] === 'fillText').map(call => call[1]);256 assert.ok(labels.includes("Packs of proofs you've added"));257 assert.ok(labels.includes('COPY OF SOURCE'));258 assert.equal(labels.includes('COPY OF source'), false);259 scene.showErrors([{ message: 'bad pack', detail: 'detail' }]);260 scene.draw(ctx, { width: 800, height: 600 });261 assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'This pack has problems'));262});