From 22c221a042cf431c2120e7d5090d4dae91cdde25 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 28 Oct 2026 23:38:29 +0100 Subject: [PATCH] feat(js): m01 util and m02 model with tests, fixtures, purity audit Co-Authored-By: Claude Sonnet 5.5 --- fixtures/m02-delta.json | 45 ++++++++++++++++++++ fixtures/m02-initial-state.json | 67 +++++++++++++++++++++++++++++ fixtures/m02-opposite.json | 33 +++++++++++++++ fixtures/m02-point-equals.json | 61 +++++++++++++++++++++++++++ js/solutions/model.js | 51 ++++++++++++++++++++++ js/solutions/todo.js | 4 ++ js/solutions/util.js | 29 +++++++++++++ js/src/model.js | 39 +++++++++++++++++ js/src/todo.js | 4 ++ js/src/util.js | 19 +++++++++ js/test/helpers.js | 9 ++-- js/test/m01-util.test.js | 47 +++++++++++++++++++++ js/test/m02-model.test.js | 29 +++++++++++++ js/test/purity.test.js | 75 +++++++++++++++++++++++++++++++++ 14 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 fixtures/m02-delta.json create mode 100644 fixtures/m02-initial-state.json create mode 100644 fixtures/m02-opposite.json create mode 100644 fixtures/m02-point-equals.json create mode 100644 js/solutions/model.js create mode 100644 js/solutions/todo.js create mode 100644 js/solutions/util.js create mode 100644 js/src/model.js create mode 100644 js/src/todo.js create mode 100644 js/src/util.js create mode 100644 js/test/m01-util.test.js create mode 100644 js/test/m02-model.test.js create mode 100644 js/test/purity.test.js diff --git a/fixtures/m02-delta.json b/fixtures/m02-delta.json new file mode 100644 index 0000000..5726e26 --- /dev/null +++ b/fixtures/m02-delta.json @@ -0,0 +1,45 @@ +{ + "function": "delta", + "cases": [ + { + "name": "up", + "args": [ + "Up" + ], + "expected": { + "x": 0, + "y": -1 + } + }, + { + "name": "down", + "args": [ + "Down" + ], + "expected": { + "x": 0, + "y": 1 + } + }, + { + "name": "left", + "args": [ + "Left" + ], + "expected": { + "x": -1, + "y": 0 + } + }, + { + "name": "right", + "args": [ + "Right" + ], + "expected": { + "x": 1, + "y": 0 + } + } + ] +} diff --git a/fixtures/m02-initial-state.json b/fixtures/m02-initial-state.json new file mode 100644 index 0000000..b171272 --- /dev/null +++ b/fixtures/m02-initial-state.json @@ -0,0 +1,67 @@ +{ + "function": "initialState", + "cases": [ + { + "name": "seed-7", + "args": [ + 7 + ], + "expected": { + "width": 20, + "height": 20, + "snake": [ + { + "x": 10, + "y": 10 + }, + { + "x": 9, + "y": 10 + }, + { + "x": 8, + "y": 10 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 7, + "score": 0, + "status": "Playing", + "frame": 0 + } + }, + { + "name": "seed-is-stored-as-given", + "args": [ + 4294967295 + ], + "expected": { + "width": 20, + "height": 20, + "snake": [ + { + "x": 10, + "y": 10 + }, + { + "x": 9, + "y": 10 + }, + { + "x": 8, + "y": 10 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 4294967295, + "score": 0, + "status": "Playing", + "frame": 0 + } + } + ] +} diff --git a/fixtures/m02-opposite.json b/fixtures/m02-opposite.json new file mode 100644 index 0000000..b875b49 --- /dev/null +++ b/fixtures/m02-opposite.json @@ -0,0 +1,33 @@ +{ + "function": "opposite", + "cases": [ + { + "name": "up", + "args": [ + "Up" + ], + "expected": "Down" + }, + { + "name": "down", + "args": [ + "Down" + ], + "expected": "Up" + }, + { + "name": "left", + "args": [ + "Left" + ], + "expected": "Right" + }, + { + "name": "right", + "args": [ + "Right" + ], + "expected": "Left" + } + ] +} diff --git a/fixtures/m02-point-equals.json b/fixtures/m02-point-equals.json new file mode 100644 index 0000000..c6eb068 --- /dev/null +++ b/fixtures/m02-point-equals.json @@ -0,0 +1,61 @@ +{ + "function": "pointEquals", + "cases": [ + { + "name": "same-point", + "args": [ + { + "x": 3, + "y": 4 + }, + { + "x": 3, + "y": 4 + } + ], + "expected": true + }, + { + "name": "different-x", + "args": [ + { + "x": 3, + "y": 4 + }, + { + "x": 2, + "y": 4 + } + ], + "expected": false + }, + { + "name": "different-y", + "args": [ + { + "x": 3, + "y": 4 + }, + { + "x": 3, + "y": 5 + } + ], + "expected": false + }, + { + "name": "swapped-coordinates", + "args": [ + { + "x": 3, + "y": 4 + }, + { + "x": 4, + "y": 3 + } + ], + "expected": false + } + ] +} diff --git a/js/solutions/model.js b/js/solutions/model.js new file mode 100644 index 0000000..daa96e7 --- /dev/null +++ b/js/solutions/model.js @@ -0,0 +1,51 @@ +// m02 model: the data the whole game is built from. Plain objects, never mutated. + +/** @typedef {{x: number, y: number}} Point */ +/** @typedef {'Up'|'Down'|'Left'|'Right'} Direction */ +/** @typedef {'Playing'|'Paused'|'GameOver'|'Won'} Status */ +/** @typedef {{width: number, height: number, snake: Point[], dir: Direction, nextDir: Direction, + * apple: Point|null, seed: number, score: number, status: Status, frame: number}} GameState */ + +/** A grid cell. */ +export const point = (x, y) => ({ x, y }) + +/** Structural equality: same x and same y. */ +export const pointEquals = (a, b) => a.x === b.x && a.y === b.y + +const deltas = { + Up: point(0, -1), + Down: point(0, 1), + Left: point(-1, 0), + Right: point(1, 0), +} + +/** The unit step for a direction (y grows downward). */ +export const delta = (dir) => deltas[dir] + +const opposites = { Up: 'Down', Down: 'Up', Left: 'Right', Right: 'Left' } + +/** The direction pointing the other way. */ +export const opposite = (dir) => opposites[dir] + +/** Everything that can happen to the game, as data. `Turn` is a function because it carries a direction. */ +export const Action = { + Tick: { tag: 'Tick' }, + Turn: (dir) => ({ tag: 'Turn', dir }), + TogglePause: { tag: 'TogglePause' }, + Restart: { tag: 'Restart' }, + Frame: { tag: 'Frame' }, +} + +/** A fresh 20x20 game: 3-segment snake heading Right, no apple yet. */ +export const initialState = (seed) => ({ + width: 20, + height: 20, + snake: [point(10, 10), point(9, 10), point(8, 10)], + dir: 'Right', + nextDir: 'Right', + apple: null, + seed, + score: 0, + status: 'Playing', + frame: 0, +}) diff --git a/js/solutions/todo.js b/js/solutions/todo.js new file mode 100644 index 0000000..529e744 --- /dev/null +++ b/js/solutions/todo.js @@ -0,0 +1,4 @@ +// Placeholder body for unimplemented stubs: replace each `todo('mNN')` call with real code. +export const todo = (module) => { + throw new Error(`TODO: implement in ${module}`) +} diff --git a/js/solutions/util.js b/js/solutions/util.js new file mode 100644 index 0000000..d273528 --- /dev/null +++ b/js/solutions/util.js @@ -0,0 +1,29 @@ +// m01 util: small helpers used by every later module. + +/** `range(n)` is `[0, 1, ..., n-1]`; `range(0)` is `[]`. */ +export const range = (n) => Array.from({ length: n }, (_, i) => i) + +/** `xs` without its last element; an empty list stays empty. Never mutates `xs`. */ +export const dropLast = (xs) => xs.slice(0, -1) + +/** True if some element `x` of `xs` satisfies `eq(x, p)`. `eq` lets callers compare structurally (e.g. `pointEquals`). */ +export const contains = (xs, p, eq) => xs.some((x) => eq(x, p)) + +/** `pipe(x, f, g)` is `g(f(x))`: thread a value through functions left to right. */ +export const pipe = (x, ...fns) => fns.reduce((acc, f) => f(acc), x) + +/** `compose(f, g)(x)` is `f(g(x))`: functions applied right to left. */ +export const compose = (...fns) => (x) => fns.reduceRight((acc, f) => f(acc), x) + +/** + * Freeze `obj` and everything reachable from it, then return it. + * Freezing is the one deliberate "mutation" here: it makes later accidental mutation throw. + */ +export const deepFreeze = (obj) => { + Object.freeze(obj) + // Freezing before recursing means cyclic structures stop at the already-frozen node. + Object.values(obj).forEach((v) => { + if (v !== null && typeof v === 'object' && !Object.isFrozen(v)) deepFreeze(v) + }) + return obj +} diff --git a/js/src/model.js b/js/src/model.js new file mode 100644 index 0000000..a8ceb1d --- /dev/null +++ b/js/src/model.js @@ -0,0 +1,39 @@ +import { todo } from './todo.js' + +/** @typedef {{x: number, y: number}} Point */ +/** @typedef {'Up'|'Down'|'Left'|'Right'} Direction */ +/** @typedef {'Playing'|'Paused'|'GameOver'|'Won'} Status */ +/** @typedef {{width: number, height: number, snake: Point[], dir: Direction, nextDir: Direction, + * apple: Point|null, seed: number, score: number, status: Status, frame: number}} GameState */ + +/** A grid cell `{x, y}`. */ +export const point = (x, y) => todo('m02') + +/** Structural equality: same x and same y. */ +export const pointEquals = (a, b) => todo('m02') + +/** The unit step for a direction: Up {0,-1}, Down {0,1}, Left {-1,0}, Right {1,0} (y grows downward). */ +export const delta = (dir) => todo('m02') + +/** The direction pointing the other way. */ +export const opposite = (dir) => todo('m02') + +/** + * Everything that can happen to the game, as data: + * `Tick`, `Turn(dir)`, `TogglePause`, `Restart`, `Frame`, each `{ tag, ... }`. + * `Turn` is a function because it carries a direction; the rest are constants + * (the getters below only exist so the stub compiles; replace them with plain values). + */ +export const Action = { + get Tick() { return todo('m02') }, + Turn: (dir) => todo('m02'), + get TogglePause() { return todo('m02') }, + get Restart() { return todo('m02') }, + get Frame() { return todo('m02') }, +} + +/** + * A fresh game: 20x20, snake [(10,10),(9,10),(8,10)] heading Right (dir and nextDir), + * apple null, the given seed, score 0, status 'Playing', frame 0. + */ +export const initialState = (seed) => todo('m02') diff --git a/js/src/todo.js b/js/src/todo.js new file mode 100644 index 0000000..529e744 --- /dev/null +++ b/js/src/todo.js @@ -0,0 +1,4 @@ +// Placeholder body for unimplemented stubs: replace each `todo('mNN')` call with real code. +export const todo = (module) => { + throw new Error(`TODO: implement in ${module}`) +} diff --git a/js/src/util.js b/js/src/util.js new file mode 100644 index 0000000..6e6f10f --- /dev/null +++ b/js/src/util.js @@ -0,0 +1,19 @@ +import { todo } from './todo.js' + +/** `range(n)` is `[0, 1, ..., n-1]`; `range(0)` is `[]`. */ +export const range = (n) => todo('m01') + +/** `xs` without its last element; an empty list stays empty. Never mutates `xs`. */ +export const dropLast = (xs) => todo('m01') + +/** True if some element `x` of `xs` satisfies `eq(x, p)`. `eq` lets callers compare structurally (e.g. `pointEquals`). */ +export const contains = (xs, p, eq) => todo('m01') + +/** `pipe(x, f, g)` is `g(f(x))`: thread a value through functions left to right. */ +export const pipe = (x, ...fns) => todo('m01') + +/** `compose(f, g)(x)` is `f(g(x))`: functions applied right to left. */ +export const compose = (...fns) => todo('m01') + +/** Freeze `obj` and everything reachable from it, then return it (same object). */ +export const deepFreeze = (obj) => todo('m01') diff --git a/js/test/helpers.js b/js/test/helpers.js index 97e5e2f..15128cc 100644 --- a/js/test/helpers.js +++ b/js/test/helpers.js @@ -8,11 +8,12 @@ const here = dirname(fileURLToPath(import.meta.url)) export const loadFixture = (name) => JSON.parse(readFileSync(join(here, '..', '..', 'fixtures', `${name}.json`), 'utf8')) -/** Deep-freeze for tests (independent of the student's deepFreeze). */ -export const freeze = (x) => { - if (x !== null && typeof x === 'object' && !Object.isFrozen(x)) { +/** Deep-freeze for tests (independent of the student's deepFreeze). Recurses into children even of already-frozen objects. */ +export const freeze = (x, seen = new WeakSet()) => { + if (x !== null && typeof x === 'object' && !seen.has(x)) { + seen.add(x) Object.freeze(x) - Object.values(x).forEach(freeze) + Object.values(x).forEach((v) => freeze(v, seen)) } return x } diff --git a/js/test/m01-util.test.js b/js/test/m01-util.test.js new file mode 100644 index 0000000..56160ef --- /dev/null +++ b/js/test/m01-util.test.js @@ -0,0 +1,47 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { range, dropLast, contains, pipe, compose, deepFreeze } from '../src/util.js' +import { freeze } from './helpers.js' + +test('m01 range(0) is empty', () => assert.deepEqual(range(0), [])) +test('m01 range(3) counts from zero up to n-1', () => assert.deepEqual(range(3), [0, 1, 2])) + +test('m01 dropLast of an empty list is empty', () => assert.deepEqual(dropLast([]), [])) +test('m01 dropLast removes only the last element', () => assert.deepEqual(dropLast([1, 2, 3]), [1, 2])) +test('m01 dropLast leaves its input untouched', () => { + const xs = freeze([1, 2, 3]) + assert.deepEqual(dropLast(xs), [1, 2]) + assert.deepEqual(xs, [1, 2, 3]) +}) + +const sameParity = (a, b) => a % 2 === b % 2 +test('m01 contains finds an element using the supplied equality', () => + assert.equal(contains([1, 2, 3], 5, sameParity), true)) +test('m01 contains is false when no element is equal', () => + assert.equal(contains([2, 4], 3, sameParity), false)) +test('m01 contains is false for an empty list', () => + assert.equal(contains([], 1, (a, b) => a === b), false)) +test('m01 contains compares structurally when given a structural equality', () => { + const eq = (a, b) => a.x === b.x && a.y === b.y + assert.equal(contains(freeze([{ x: 1, y: 2 }]), { x: 1, y: 2 }, eq), true) +}) + +test('m01 pipe threads a value through functions left to right', () => + assert.equal(pipe(2, (x) => x + 1, (x) => x * 10), 30)) +test('m01 pipe with no functions returns the value', () => assert.equal(pipe(5), 5)) + +test('m01 compose applies functions right to left', () => + assert.equal(compose((x) => x * 10, (x) => x + 1)(2), 30)) +test('m01 compose with no functions is the identity', () => assert.equal(compose()(5), 5)) + +test('m01 deepFreeze freezes nested objects and arrays', () => { + const o = deepFreeze({ a: { b: [1, { c: 2 }] } }) + assert.ok(Object.isFrozen(o)) + assert.ok(Object.isFrozen(o.a)) + assert.ok(Object.isFrozen(o.a.b)) + assert.ok(Object.isFrozen(o.a.b[1])) +}) +test('m01 deepFreeze returns the same object it was given', () => { + const o = {} + assert.equal(deepFreeze(o), o) +}) diff --git a/js/test/m02-model.test.js b/js/test/m02-model.test.js new file mode 100644 index 0000000..a0ed1be --- /dev/null +++ b/js/test/m02-model.test.js @@ -0,0 +1,29 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { point, pointEquals, delta, opposite, Action, initialState } from '../src/model.js' +import { loadFixture, freeze } from './helpers.js' + +const fixtureTests = (name, f) => { + const fx = loadFixture(name) + fx.cases.forEach((c) => + test(`m02 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected))) +} + +fixtureTests('m02-delta', delta) +fixtureTests('m02-opposite', opposite) +fixtureTests('m02-point-equals', pointEquals) +fixtureTests('m02-initial-state', initialState) + +test('m02 point builds a {x, y} record', () => assert.deepStrictEqual(point(3, 4), { x: 3, y: 4 })) + +test('m02 opposite is its own inverse', () => + ['Up', 'Down', 'Left', 'Right'].forEach((d) => assert.equal(opposite(opposite(d)), d))) + +test('m02 Action constants are tagged records', () => { + assert.deepStrictEqual(Action.Tick, { tag: 'Tick' }) + assert.deepStrictEqual(Action.TogglePause, { tag: 'TogglePause' }) + assert.deepStrictEqual(Action.Restart, { tag: 'Restart' }) + assert.deepStrictEqual(Action.Frame, { tag: 'Frame' }) +}) +test('m02 Action.Turn carries the direction', () => + assert.deepStrictEqual(Action.Turn('Left'), { tag: 'Turn', dir: 'Left' })) diff --git a/js/test/purity.test.js b/js/test/purity.test.js new file mode 100644 index 0000000..8b78267 --- /dev/null +++ b/js/test/purity.test.js @@ -0,0 +1,75 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readdirSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join, basename } from 'node:path' + +const srcDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src') + +// Files that are allowed to touch the outside world. +const excluded = (file) => ['runtime.js', 'browser.js', 'main.js'].includes(basename(file)) + +const banned = [ + ['let', /\blet\b/], + ['var', /\bvar\b/], + ['++', /\+\+/], + ['--', /--/], + ['.push(', /\.push\(/], + ['.pop(', /\.pop\(/], + ['.shift(', /\.shift\(/], + ['.unshift(', /\.unshift\(/], + ['.splice(', /\.splice\(/], + ['.sort(', /\.sort\(/], + ['.reverse(', /\.reverse\(/], + ['Math.random', /\bMath\.random\b/], + ['Date.now', /\bDate\.now\b/], + ['performance.now', /\bperformance\.now\b/], + ['document', /\bdocument\b/], + ['window', /\bwindow\b/], + ['addEventListener', /\baddEventListener\b/], +] + +const stripComments = (code) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '') +const violations = (code) => banned.filter(([, re]) => re.test(stripComments(code))).map(([name]) => name) + +const jsFiles = (dir) => + readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? jsFiles(join(dir, e.name)) : e.name.endsWith('.js') ? [join(dir, e.name)] : []) + +banned.forEach(([name]) => + test(`purity scanner flags a known-bad sample using ${name}`, () => { + const samples = { + let: 'let n = 0', + var: 'var n = 0', + '++': 'n++', + '--': 'n--', + '.push(': 'xs.push(1)', + '.pop(': 'xs.pop()', + '.shift(': 'xs.shift()', + '.unshift(': 'xs.unshift(1)', + '.splice(': 'xs.splice(0, 1)', + '.sort(': 'xs.sort()', + '.reverse(': 'xs.reverse()', + 'Math.random': 'const r = Math.random()', + 'Date.now': 'const t = Date.now()', + 'performance.now': 'const t = performance.now()', + document: 'document.title', + window: 'window.alert(1)', + addEventListener: 'x.addEventListener("click", f)', + } + assert.ok(violations(samples[name]).includes(name)) + })) + +test('purity scanner ignores banned words inside comments', () => { + const code = '// let var Math.random()\n/* xs.push(1); document; n++ */\nconst a = 1 // window\n' + assert.deepEqual(violations(code), []) +}) + +test('purity scanner accepts ordinary pure code', () => + assert.deepEqual(violations('export const f = (xs) => xs.map((x) => x + 1)'), [])) + +jsFiles(srcDir) + .filter((f) => !excluded(f)) + .forEach((f) => + test(`purity: ${f.slice(srcDir.length + 1)} has no banned constructs`, () => + assert.deepEqual(violations(readFileSync(f, 'utf8')), [])))