clone

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

main
miau-is-true / tests / solver-cli.test.js
solver-cli.test.js 7.2K
1import test from 'node:test';2import assert from 'node:assert/strict';3import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';4import { join } from 'node:path';5import { tmpdir } from 'node:os';6import { spawnSync } from 'node:child_process';78const root = new URL('..', import.meta.url);9const cli = new URL('../tools/solve.mjs', import.meta.url);1011function run(args) {12 const result = spawnSync(process.execPath, [cli.pathname, ...args], {13 cwd: root.pathname,14 encoding: 'utf8',15 });16 return { ...result, output: `${result.stdout}${result.stderr}` };17}1819function fixture(source) {20 const directory = mkdtempSync(join(tmpdir(), 'miau-solver-cli-'));21 const file = join(directory, 'fixture.cjs');22 writeFileSync(file, source);23 return { file, cleanup: () => rmSync(directory, { recursive: true, force: true }) };24}2526const simplePack = `module.exports = {27 manifest: { id: 'cli-fixture', name: 'CLI fixture', version: '1.0.0', engine: 1 },28 levels: [{ id: 'walk', width: 7, height: 2, objects: [29 { id: 'mw', kind: 'word', text: 'MIAU', x: 0, y: 0 },30 { id: 'mi', kind: 'word', text: 'IS', x: 1, y: 0 },31 { id: 'my', kind: 'word', text: 'YOU', x: 2, y: 0 },32 { id: 'fw', kind: 'word', text: 'FLAG', x: 4, y: 0 },33 { id: 'fi', kind: 'word', text: 'IS', x: 5, y: 0 },34 { id: 'fn', kind: 'word', text: 'WIN', x: 6, y: 0 },35 { id: 'player', kind: 'object', noun: 'MIAU', x: 0, y: 1 },36 { id: 'flag', kind: 'object', noun: 'FLAG', x: 3, y: 1 },37 ] }],38};`;3940const wonPack = `module.exports = {41 manifest: { id: 'cli-won', name: 'Won fixture', version: '1.0.0', engine: 1 },42 levels: [{ id: 'won', width: 3, height: 1, objects: [43 { id: 'player', kind: 'object', noun: 'MIAU', x: 0, y: 0 },44 { id: 'mw', kind: 'word', text: 'MIAU', x: 0, y: 0 },45 { id: 'mi', kind: 'word', text: 'IS', x: 1, y: 0 },46 { id: 'my', kind: 'word', text: 'YOU', x: 2, y: 0 },47 { id: 'mw2', kind: 'word', text: 'MIAU', x: 0, y: 0 },48 { id: 'mi2', kind: 'word', text: 'IS', x: 1, y: 0 },49 { id: 'mw3', kind: 'word', text: 'WIN', x: 2, y: 0 },50 ] }],51};`;5253const transientPack = `module.exports = {54 manifest: { id: 'cli-transient', name: 'Transient fixture', version: '1.0.0', engine: 1 },55 levels: [{ id: 'sink-rule', width: 7, height: 3, objects: [56 { id: 'mw', kind: 'word', text: 'MIAU', x: 0, y: 0 }, { id: 'mi', kind: 'word', text: 'IS', x: 1, y: 0 }, { id: 'my', kind: 'word', text: 'YOU', x: 2, y: 0 },57 { id: 'ww', kind: 'word', text: 'WATER', x: 0, y: 1 }, { id: 'wi', kind: 'word', text: 'IS', x: 1, y: 1 }, { id: 'ws', kind: 'word', text: 'SINK', x: 2, y: 1 },58 { id: 'fw', kind: 'word', text: 'FLAG', x: 4, y: 0 }, { id: 'fi', kind: 'word', text: 'IS', x: 5, y: 0 }, { id: 'fn', kind: 'word', text: 'WIN', x: 6, y: 0 },59 { id: 'rw', kind: 'word', text: 'ROCK', x: 2, y: 2 }, { id: 'ri', kind: 'word', text: 'IS', x: 3, y: 2 },60 { id: 'water', kind: 'object', noun: 'WATER', x: 4, y: 2 }, { id: 'stop', kind: 'word', text: 'STOP', x: 5, y: 2 },61 { id: 'flag', kind: 'object', noun: 'FLAG', x: 5, y: 2 }, { id: 'player', kind: 'object', noun: 'MIAU', x: 6, y: 2 },62 ] }],63};`;6465const metadataPack = simplePack.replace(66 "id: 'walk', width",67 "id: 'walk', allowBypass: true, allowHandsOff: true, mustForm: ['MIAU IS WIN'], solverBudget: 100, width",68);6970test('CLI preserves pack and level selection, route output, and replay', () => {71 const pack = fixture(simplePack);72 try {73 const result = run([pack.file, '--level', 'walk']);74 assert.equal(result.status, 0);75 assert.match(result.stdout, /CLI fixture \(cli-fixture\), 1 levels/);76 assert.match(result.stdout, /SOLVED\s+3 steps\s+RRR/);77 const replay = run([pack.file, '--level', 'walk', '--replay', 'RRR']);78 assert.equal(replay.status, 0);79 assert.match(replay.stdout, /start \(walk\)/);80 assert.match(replay.stdout, /1: R/);81 } finally {82 pack.cleanup();83 }84});8586test('CLI reports LIMIT and keeps non-solved exit behavior', () => {87 const result = run(['core', '--level', 'two-things-can-win', '--max', '1']);88 assert.equal(result.status, 1);89 assert.match(result.stdout, /LIMIT\s+after 1 states/);90 const visited = run(['--level', 'two-things-can-win', '--max-visited', '1', 'core']);91 assert.equal(visited.status, 1);92 assert.match(visited.stdout, /LIMIT\s+after 1 states \(visited cap\)/);93 const missing = run(['core', '--forbid']);94 assert.equal(missing.status, 2);95 assert.match(missing.stderr, /missing value/);96});9798test('CLI reports existential BYPASS and HANDS-OFF flags', () => {99 const result = run(['core', '--level', 'two-things-can-win', '--max', '5000']);100 assert.equal(result.status, 0);101 assert.match(result.stdout, /BYPASS\(6: RRRRRR\)/);102 assert.match(result.stdout, /HANDS-OFF\(6: RRRRRR\)/);103});104105test('CLI reports already-won levels without searching', () => {106 const pack = fixture(wonPack);107 try {108 const result = run([pack.file, '--level', 'won']);109 assert.equal(result.status, 0);110 assert.match(result.stdout, /SOLVED\s+0 steps\s+\s+WON-AT-START/);111 } finally {112 pack.cleanup();113 }114});115116test('CLI accepts repeatable forbid values and rejects an initial forbidden rule', () => {117 const result = run(['core', '--level', 'two-things-can-win', '--forbid', 'FLAG IS WIN', '--forbid', 'ROCK IS WIN']);118 assert.equal(result.status, 1);119 assert.match(result.stdout, /FORBIDDEN\s+after 0 states/);120});121122test('CLI forbid checks a transient formed then sunk rule', () => {123 const pack = fixture(transientPack);124 try {125 const solved = run([pack.file, '--level', 'sink-rule']);126 assert.equal(solved.status, 0);127 assert.match(solved.stdout, /SOLVED\s+1 steps\s+L/);128 const forbidden = run([pack.file, '--level', 'sink-rule', '--forbid', 'ROCK IS STOP']);129 assert.equal(forbidden.status, 1);130 assert.match(forbidden.stdout, /UNSOLVED\s+after/);131 } finally {132 pack.cleanup();133 }134});135136test('CLI applies solverBudget and mustForm metadata while suppressing allowed warnings', () => {137 const pack = fixture(metadataPack);138 try {139 const result = run([pack.file, '--level', 'walk']);140 assert.equal(result.status, 1);141 assert.match(result.stdout, /BYPASS\(3: RRR\)/);142 assert.match(result.stdout, /HANDS-OFF\(3: RRR\)/);143 assert.match(result.stdout, /SKIPPABLE\(MIAU IS WIN\)/);144 assert.doesNotMatch(result.stdout, /WARNING:/);145 } finally {146 pack.cleanup();147 }148});149150test('CLI fails and labels a mustForm search that reaches its budget', () => {151 const pack = fixture(metadataPack.replace('solverBudget: 100', 'solverBudget: 1'));152 try {153 const result = run([pack.file, '--level', 'walk']);154 assert.equal(result.status, 1);155 assert.match(result.stdout, /LIMIT\s+after 1 states/);156 assert.match(result.stdout, /LIMIT\(MIAU IS WIN\)/);157 } finally {158 pack.cleanup();159 }160});161162test('explicit --max overrides a lower solverBudget', () => {163 const pack = fixture(metadataPack.replace('solverBudget: 100', 'solverBudget: 1'));164 try {165 const result = run([pack.file, '--level', 'walk', '--max', '100']);166 assert.equal(result.status, 1);167 assert.match(result.stdout, /SOLVED\s+3 steps\s+RRR/);168 assert.match(result.stdout, /SKIPPABLE\(MIAU IS WIN\)/);169 assert.doesNotMatch(result.stdout, /LIMIT\(MIAU IS WIN\)/);170 } finally {171 pack.cleanup();172 }173});