Add interpreter test suite
This commit is contained in:
parent
f972046318
commit
d9dd032441
3 changed files with 181 additions and 0 deletions
26
js/test/fixtures.mjs
Normal file
26
js/test/fixtures.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Test fixtures copied verbatim from mpl_codes js/site.js @ 5b50cc0 when
|
||||||
|
// the interpreter test suite migrated into this repo. These are the exact
|
||||||
|
// programs the site ships; the entries' third field is the site's i18n
|
||||||
|
// label key, preserved untouched so nothing about the facts changed in the
|
||||||
|
// migration.
|
||||||
|
|
||||||
|
export const SYMBOLS = [
|
||||||
|
['✎','\\trace','sym_trace'],['λ','\\lambda','sym_lambda'],['≜','\\coloneq','sym_def'],
|
||||||
|
['←','\\leftarrow','sym_assign'],['⟹','\\implies','sym_implies'],['|','|','sym_bar'],
|
||||||
|
['∀','\\forall','sym_forall'],['∈','\\in','sym_in'],['∧','\\and','sym_and'],
|
||||||
|
['∨','\\or','sym_or'],['≤','\\leq','sym_leq'],['≥','\\geq','sym_geq'],
|
||||||
|
['≠','\\neq','sym_neq'],['×','\\times','sym_times'],['÷','\\div','sym_div'],
|
||||||
|
['⊥','\\bot','sym_bot']
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EXERCISES = [
|
||||||
|
{t:'ex1_t',d:'ex1_d',lvl:'lvl1',code:'✎ "Hello, World!";\n✎ "Jambo!";\n✎ "你好!";\n✎ "مرحبا!";'},
|
||||||
|
{t:'ex2_t',d:'ex2_d',lvl:'lvl1',code:'length ← 5;\nwidth ← 3;\n✎("Area = " + length × width);'},
|
||||||
|
{t:'ex3_t',d:'ex3_d',lvl:'lvl2',code:'fact ≜ λn: (n ≤ 1 ⟹ 1) | (n × fact(n - 1));\n✎("5! = " + fact(5));'},
|
||||||
|
{t:'ex4_t',d:'ex4_d',lvl:'lvl2',code:'total ← 0;\n∀ n ∈ [1, 2, 3, 4, 5]: total ← total + n × n;\n✎("Σ = " + total);'},
|
||||||
|
{t:'ex5_t',d:'ex5_d',lvl:'lvl2',code:'even ≜ λn: (n = 0 ⟹ true) | ((n = 1 ⟹ false) | even(n - 2));\n∀ n ∈ [1, 2, 3, 4, 5, 6, 7, 8]: (even(n) ⟹ ✎(n)) | ⊥;'},
|
||||||
|
{t:'ex6_t',d:'ex6_d',lvl:'lvl3',code:'twice ≜ λf: λx: f(f(x));\ninc ≜ λn: n + 1;\n✎ twice(inc)(40);'}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_PROGRAM = '-- سلام · 你好 · Hola · Hello\ngreet ≜ λname: ✎("Salaam, " + name + "!");\ngreet("Fatima");\n\nfact ≜ λn: (n ≤ 1 ⟹ 1) | (n × fact(n - 1));\n✎("5! = " + fact(5));';
|
||||||
|
export const HERO_PROGRAM = 'fact ≜ λn: (n ≤ 1 ⟹ 1) | (n × fact(n - 1));\n✎("5! = " + fact(5));';
|
||||||
11
js/test/load.mjs
Normal file
11
js/test/load.mjs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// Single shared loader for the interpreter artifact. js/mpl.js must stay
|
||||||
|
// byte-identical to the file served at mpl.codes, so the loader adapts to
|
||||||
|
// the artifact — never the other way around. The artifact ends with a
|
||||||
|
// CommonJS export guard, so createRequire is sufficient today; if that
|
||||||
|
// guard ever changes, adapt here (e.g. node:vm), do not edit js/mpl.js.
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { runMPL, ESCAPES } = require('../mpl.js');
|
||||||
|
|
||||||
|
export { runMPL, ESCAPES };
|
||||||
144
js/test/mpl.test.mjs
Normal file
144
js/test/mpl.test.mjs
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
'use strict';
|
||||||
|
/* Interpreter tests — run with: node --test "js/test/*.test.mjs"
|
||||||
|
* Asserts EXACT outputs for every program mpl.codes ships, so the site can
|
||||||
|
* never claim an example works that doesn't. Migrated from the mpl_codes
|
||||||
|
* test suite (test/mpl.test.js @ 5b50cc0) with every assertion's input and
|
||||||
|
* expected value preserved exactly; only the runner mechanics changed.
|
||||||
|
*/
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { runMPL, ESCAPES } from './load.mjs';
|
||||||
|
import { SYMBOLS, EXERCISES, DEFAULT_PROGRAM, HERO_PROGRAM } from './fixtures.mjs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function run(src) {
|
||||||
|
const out = [];
|
||||||
|
runMPL(src, s => out.push(s));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('hero program computes factorial', () => {
|
||||||
|
assert.deepEqual(run(HERO_PROGRAM), ['5! = 120']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default playground program', () => {
|
||||||
|
assert.deepEqual(run(DEFAULT_PROGRAM), ['Salaam, Fatima!', '5! = 120']);
|
||||||
|
});
|
||||||
|
|
||||||
|
const EXPECTED_EXERCISES = [
|
||||||
|
['Hello, World!', 'Jambo!', '你好!', 'مرحبا!'],
|
||||||
|
['Area = 15'],
|
||||||
|
['5! = 120'],
|
||||||
|
['Σ = 55'],
|
||||||
|
['2', '4', '6', '8'],
|
||||||
|
['42'],
|
||||||
|
];
|
||||||
|
|
||||||
|
test('all six exercises produce their documented output', () => {
|
||||||
|
assert.equal(EXERCISES.length, EXPECTED_EXERCISES.length);
|
||||||
|
EXERCISES.forEach((ex, i) => {
|
||||||
|
assert.deepEqual(run(ex.code), EXPECTED_EXERCISES[i], `exercise ${i + 1}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all six type symbols lex as inert ∈-constraint atoms', () => {
|
||||||
|
// ℕ ℤ ℚ ℝ ℂ 𝔹 parse in constraint position and are discarded unevaluated
|
||||||
|
// (§5: "parsed today, not yet enforced"). 𝔹 is supplementary-plane: the
|
||||||
|
// lexer must treat the surrogate pair as one code point.
|
||||||
|
for (const T of ['ℕ', 'ℤ', 'ℚ', 'ℝ', 'ℂ', '𝔹']) {
|
||||||
|
assert.deepEqual(run(`f ≜ λx∈${T}: (x ⟹ 1) | 0;\n✎ f(true);`), ['1'],
|
||||||
|
`type symbol ${T}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('𝔹 (U+1D539) lexes as one code point, not two surrogate halves', () => {
|
||||||
|
// The exact gate case:
|
||||||
|
assert.deepEqual(run('f ≜ λx∈𝔹: (x ⟹ 1) | 0; ✎ f(true);'), ['1']);
|
||||||
|
// And no half-pair matching: 𝕊 (U+1D54A) shares 𝔹's high surrogate
|
||||||
|
// \uD835 but is NOT a type symbol — it must be rejected whole.
|
||||||
|
try {
|
||||||
|
run('✎ 𝕊;');
|
||||||
|
assert.fail('𝕊 should not lex');
|
||||||
|
} catch (e) {
|
||||||
|
assert.equal(e.key, 'err_char');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vendored examples 01 and 02 run in the M0 browser core', () => {
|
||||||
|
// 03–10 use constructs beyond the browser core (modules, resources,
|
||||||
|
// channels, metaprogramming) — running them is deliberately NOT claimed.
|
||||||
|
const ex = n => fs.readFileSync(path.join(__dirname, '..', '..', 'examples', n), 'utf8');
|
||||||
|
assert.deepEqual(run(ex('01_hello_world.mpl')), ['Hello, World!']);
|
||||||
|
assert.deepEqual(run(ex('02_factorial.mpl')), ['120']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undefined name reports err_undef at 1:1', () => {
|
||||||
|
try {
|
||||||
|
run('x + 1;');
|
||||||
|
assert.fail('should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
assert.equal(e.key, 'err_undef');
|
||||||
|
assert.equal(e.line, 1);
|
||||||
|
assert.equal(e.col, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('division by zero reports err_div0', () => {
|
||||||
|
try {
|
||||||
|
run('✎ 1 ÷ 0;');
|
||||||
|
assert.fail('should have thrown');
|
||||||
|
} catch (e) {
|
||||||
|
assert.equal(e.key, 'err_div0');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SYMBOLS table: one escape per glyph, all escapes known to the lexer', () => {
|
||||||
|
const glyphs = new Set();
|
||||||
|
const escapes = new Set();
|
||||||
|
for (const [glyph, esc] of SYMBOLS) {
|
||||||
|
assert.ok(!glyphs.has(glyph), `duplicate glyph ${glyph}`);
|
||||||
|
assert.ok(!escapes.has(esc), `duplicate escape ${esc}`);
|
||||||
|
glyphs.add(glyph);
|
||||||
|
escapes.add(esc);
|
||||||
|
if (esc.startsWith('\\')) {
|
||||||
|
// the palette's escape must be exactly what the lexer expands
|
||||||
|
assert.equal(ESCAPES[esc.slice(1)], glyph,
|
||||||
|
`lexer ESCAPES['${esc.slice(1)}'] must map to ${glyph}`);
|
||||||
|
} else {
|
||||||
|
// ASCII symbols (like |) are their own spelling
|
||||||
|
assert.equal(esc, glyph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every palette escape lexes identically to its glyph', () => {
|
||||||
|
// One program that uses all fifteen backslash-escaped glyphs.
|
||||||
|
const glyphProgram = [
|
||||||
|
'check ≜ λa, b: ((a ≤ b) ∧ (b ≥ a) ∧ (a ≠ b) ⟹ ✎ "cmp") | ⊥;',
|
||||||
|
'check(1, 2);',
|
||||||
|
't ← 0;',
|
||||||
|
'∀ n ∈ [1, 2, 3]: t ← t + n;',
|
||||||
|
'✎(t × 2);',
|
||||||
|
'✎(t ÷ 2);',
|
||||||
|
'✎((true ∨ false) ⟹ "or");',
|
||||||
|
].join('\n');
|
||||||
|
// Verify coverage: every escaped SYMBOLS glyph appears in the program.
|
||||||
|
for (const [glyph, esc] of SYMBOLS) {
|
||||||
|
if (esc.startsWith('\\')) {
|
||||||
|
assert.ok(glyphProgram.includes(glyph), `probe program must use ${glyph}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Build the escape-spelled variant (a space terminates each escape word).
|
||||||
|
let escProgram = glyphProgram;
|
||||||
|
for (const [glyph, esc] of SYMBOLS) {
|
||||||
|
if (esc.startsWith('\\')) escProgram = escProgram.split(glyph).join(esc + ' ');
|
||||||
|
}
|
||||||
|
const expected = ['cmp', '12', '3', 'or'];
|
||||||
|
assert.deepEqual(run(glyphProgram), expected);
|
||||||
|
assert.deepEqual(run(escProgram), expected, 'escape spelling must behave identically');
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue