1import test from 'node:test';2import assert from 'node:assert/strict';3import { MAX_RULE_PATHS, RuleParseLimitError, parseRules } from '../src/engine/rules.js';45const properties = new Set(['YOU', 'PUSH', 'STOP', 'WIN']);6const word = (text, x, y, id = `${text}-${x}-${y}`) => ({ kind: 'word', text, x, y, id });7const rules = objects => parseRules(objects, properties).rules.map(rule => rule.text);89test('stacked connector alternatives select one word per cell', () => {10 assert.deepEqual(rules([11 word('ROCK', 0, 0), word('AND', 1, 0), word('IS', 1, 0), word('FLAG', 2, 0),12 ]), ['ROCK IS FLAG']);13});1415test('stacked noun starts remain valid alternatives', () => {16 assert.deepEqual(rules([17 word('MIAU', 0, 0), word('AND', 1, 0), word('ROCK', 1, 0), word('IS', 2, 0), word('YOU', 3, 0),18 ]), ['ROCK IS YOU']);19});2021test('stacked predicates and subjects enumerate every valid rule', () => {22 assert.deepEqual(rules([23 word('ROCK', 0, 0), word('IS', 1, 0), word('WIN', 2, 0), word('PUSH', 2, 0),24 ]), ['ROCK IS WIN', 'ROCK IS PUSH']);25 assert.deepEqual(rules([26 word('ROCK', 0, 0), word('FLAG', 0, 0), word('IS', 1, 0), word('WIN', 2, 0),27 ]), ['ROCK IS WIN', 'FLAG IS WIN']);28});2930test('stacked AND and IS keeps the valid subject chain', () => {31 assert.deepEqual(rules([32 word('ROCK', 0, 0), word('AND', 1, 0), word('FLAG', 2, 0), word('IS', 2, 0), word('IS', 3, 0), word('WIN', 4, 0),33 ]), ['ROCK IS WIN', 'FLAG IS WIN']);34});3536test('rule order follows spatial origin and horizontal before vertical', () => {37 assert.deepEqual(rules([38 word('FLAG', 4, 3, 'late'), word('IS', 5, 3), word('WIN', 6, 3),39 word('ROCK', 0, 0, 'early'), word('IS', 1, 0), word('STOP', 2, 0),40 ]), ['ROCK IS STOP', 'FLAG IS WIN']);41});4243test('sentence order follows spatial origin instead of authored object order', () => {44 const parsed = parseRules([45 word('FLAG', 4, 3, 'flag'), word('IS', 5, 3, 'flag-is'), word('WIN', 6, 3, 'flag-win'),46 word('ROCK', 0, 0, 'rock'), word('IS', 1, 0, 'rock-is'), word('STOP', 2, 0, 'rock-stop'),47 ], properties);48 assert.deepEqual(parsed.sentences.map(sentence => sentence.ids), [49 ['rock', 'rock-is', 'rock-stop'],50 ['flag', 'flag-is', 'flag-win'],51 ]);52});5354test('predicate sentence spans include the AND connector source id', () => {55 const parsed = parseRules([56 word('ROCK', 0, 0, 'rock'), word('IS', 1, 0, 'is'), word('WIN', 2, 0, 'win'),57 word('AND', 3, 0, 'and'), word('PUSH', 4, 0, 'push'),58 ], properties);59 assert.deepEqual(parsed.sentences, [{60 ids: ['rock', 'is', 'win', 'and', 'push'],61 axis: 'h',62 }]);63});6465test('ordinary stacked alternatives remain fully enumerable under the parser budget', () => {66 const parsed = parseRules([67 word('ROCK', 0, 0, 'rock-a'), word('FLAG', 0, 0, 'rock-b'),68 word('AND', 1, 0, 'subject-and'),69 word('MOUSE', 2, 0, 'mouse-a'), word('BELL', 2, 0, 'mouse-b'),70 word('IS', 3, 0, 'is'),71 word('WIN', 4, 0, 'win-a'), word('PUSH', 4, 0, 'win-b'),72 ], properties);73 assert.deepEqual(parsed.rules.map(rule => rule.text).sort(), [74 'ROCK IS WIN', 'ROCK IS PUSH', 'FLAG IS WIN', 'FLAG IS PUSH',75 'MOUSE IS WIN', 'MOUSE IS PUSH', 'BELL IS WIN', 'BELL IS PUSH',76 ].sort());77 assert.equal(parsed.sentences.length, 8);78});7980test('pathological stacked grammar fails deterministically before returning partial results', () => {81 const objects = [];82 const alternatives = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];83 for (const [x, values] of [[0, alternatives], [2, alternatives], [4, alternatives], [6, alternatives], [8, alternatives]]) {84 for (const [index, text] of values.entries()) objects.push(word(text, x, 0, `${text}-${x}-${index}`));85 }86 objects.push(word('AND', 1, 0, 'and-subject-a'), word('AND', 3, 0, 'and-subject-b'));87 objects.push(word('IS', 5, 0, 'is'));88 objects.push(word('AND', 7, 0, 'and-predicate-a'), word('AND', 9, 0, 'and-predicate-b'));89 assert.throws(() => parseRules(objects, properties), error => {90 assert.ok(error instanceof RuleParseLimitError);91 assert.equal(error.kind, 'paths');92 assert.equal(error.limit, MAX_RULE_PATHS);93 return true;94 });95});9697test('near-budget stacked grammar still returns complete rules without quadratic filtering', () => {98 const objects = [];99 const alternatives = ['A', 'B', 'C', 'D', 'E', 'F'];100 for (const [x, values] of [[0, alternatives], [2, alternatives], [4, alternatives], [6, alternatives], [8, alternatives]]) {101 for (const [index, text] of values.entries()) objects.push(word(text, x, 0, `${text}-${x}-${index}`));102 }103 objects.push(word('AND', 1, 0, 'and-subject-a'), word('AND', 3, 0, 'and-subject-b'));104 objects.push(word('IS', 5, 0, 'is'));105 objects.push(word('AND', 7, 0, 'and-predicate-a'), word('AND', 9, 0, 'and-predicate-b'));106 const parsed = parseRules(objects, properties);107 assert.equal(parsed.rules.length, 36);108 assert.ok(parsed.sentences.length > 0);109});