diff --git a/.gitignore b/.gitignore
index cbda906..35b071a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,7 @@ obj/
.scala-build/
.bsp/
*/web/dist/
+
+# build outputs
+.fable/
+*/web/node_modules/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4c4e462
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+# Functional Programming Course
diff --git a/fixtures/m00-sanity.json b/fixtures/m00-sanity.json
new file mode 100644
index 0000000..dfabb63
--- /dev/null
+++ b/fixtures/m00-sanity.json
@@ -0,0 +1,7 @@
+{
+ "function": "sanity",
+ "cases": [
+ { "name": "one-plus-one", "args": [1, 1], "expected": 2 },
+ { "name": "two-plus-three", "args": [2, 3], "expected": 5 }
+ ]
+}
diff --git a/fsharp/solutions/Snake.fsproj b/fsharp/solutions/Snake.fsproj
new file mode 100644
index 0000000..2ff9cb0
--- /dev/null
+++ b/fsharp/solutions/Snake.fsproj
@@ -0,0 +1,9 @@
+
+
+ net10.0
+ false
+
+
+
+
+
diff --git a/fsharp/solutions/Util.fs b/fsharp/solutions/Util.fs
new file mode 100644
index 0000000..d84ca40
--- /dev/null
+++ b/fsharp/solutions/Util.fs
@@ -0,0 +1,2 @@
+/// Placeholder; replaced by the m01 utilities.
+module Snake.Util
diff --git a/fsharp/src/Snake.fsproj b/fsharp/src/Snake.fsproj
new file mode 100644
index 0000000..2ff9cb0
--- /dev/null
+++ b/fsharp/src/Snake.fsproj
@@ -0,0 +1,9 @@
+
+
+ net10.0
+ false
+
+
+
+
+
diff --git a/fsharp/src/Util.fs b/fsharp/src/Util.fs
new file mode 100644
index 0000000..d84ca40
--- /dev/null
+++ b/fsharp/src/Util.fs
@@ -0,0 +1,2 @@
+/// Placeholder; replaced by the m01 utilities.
+module Snake.Util
diff --git a/fsharp/test.sh b/fsharp/test.sh
new file mode 100755
index 0000000..3269d3a
--- /dev/null
+++ b/fsharp/test.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# Usage: ./test.sh [MNN] (no arg = all tests)
+set -euo pipefail
+cd "$(dirname "$0")"
+export PATH="$HOME/.dotnet:$PATH"
+export DOTNET_NOLOGO=1 DOTNET_CLI_TELEMETRY_OPTOUT=1
+if [ $# -ge 1 ]; then
+ exec dotnet run --project tests -- --filter-test-list "$1"
+else
+ exec dotnet run --project tests
+fi
diff --git a/fsharp/tests/Fixtures.fs b/fsharp/tests/Fixtures.fs
new file mode 100644
index 0000000..031ad3e
--- /dev/null
+++ b/fsharp/tests/Fixtures.fs
@@ -0,0 +1,21 @@
+/// Loads shared fixtures from ../../fixtures/.json.
+/// Typed decoders (point, direction, status, action, state, opt) arrive with the model (Task 13).
+module Snake.Tests.Fixtures
+
+open System.IO
+open System.Text.Json
+
+let private dir =
+ Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "fixtures"))
+
+let load (name: string) : JsonElement =
+ use doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(dir, name + ".json")))
+ doc.RootElement.Clone()
+
+/// The cases array of a fixture.
+let cases (name: string) : JsonElement list =
+ (load name).GetProperty("cases").EnumerateArray() |> List.ofSeq
+
+/// Decode a nullable JSON value into an Option.
+let opt (f: JsonElement -> 'a) (v: JsonElement) : 'a option =
+ if v.ValueKind = JsonValueKind.Null then None else Some(f v)
diff --git a/fsharp/tests/M00Sanity.fs b/fsharp/tests/M00Sanity.fs
new file mode 100644
index 0000000..d60d81f
--- /dev/null
+++ b/fsharp/tests/M00Sanity.fs
@@ -0,0 +1,23 @@
+module Snake.Tests.M00Sanity
+
+open System.Text.Json
+open Expecto
+open Snake.Tests
+
+[]
+let tests =
+ testList "M00 sanity" [
+ test "arithmetic" { Expect.equal (1 + 1) 2 "1 + 1" }
+
+ test "fixture loads and cases hold" {
+ Expect.equal ((Fixtures.load "m00-sanity").GetProperty("function").GetString()) "sanity" "function"
+ for c in Fixtures.cases "m00-sanity" do
+ let a = c.GetProperty("args").EnumerateArray() |> Seq.map (fun e -> e.GetInt32()) |> List.ofSeq
+ Expect.equal (List.sum a) (c.GetProperty("expected").GetInt32()) (c.GetProperty("name").GetString())
+ }
+
+ test "opt decodes null" {
+ use d = JsonDocument.Parse("null")
+ Expect.isNone (Fixtures.opt (fun e -> e.GetInt32()) d.RootElement) "null"
+ }
+ ]
diff --git a/fsharp/tests/Main.fs b/fsharp/tests/Main.fs
new file mode 100644
index 0000000..11455d9
--- /dev/null
+++ b/fsharp/tests/Main.fs
@@ -0,0 +1,6 @@
+module Snake.Tests.Main
+
+open Expecto
+
+[]
+let main argv = runTestsInAssemblyWithCLIArgs [] argv
diff --git a/fsharp/tests/Snake.Tests.fsproj b/fsharp/tests/Snake.Tests.fsproj
new file mode 100644
index 0000000..253cfda
--- /dev/null
+++ b/fsharp/tests/Snake.Tests.fsproj
@@ -0,0 +1,17 @@
+
+
+ Exe
+ net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/package.json b/js/package.json
new file mode 100644
index 0000000..9d59117
--- /dev/null
+++ b/js/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "snake-course-js",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "node --test \"test/*.test.js\""
+ }
+}
diff --git a/js/solutions/.gitkeep b/js/solutions/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/js/src/.gitkeep b/js/src/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/js/test.sh b/js/test.sh
new file mode 100755
index 0000000..16b1df1
--- /dev/null
+++ b/js/test.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+# Usage: ./test.sh [MNN] (no arg = all tests)
+set -euo pipefail
+cd "$(dirname "$0")"
+if [ $# -ge 1 ]; then
+ m=$(echo "$1" | tr 'A-Z' 'a-z')
+ exec node --test "test/${m}-*.test.js"
+else
+ exec node --test "test/*.test.js"
+fi
diff --git a/js/test/helpers.js b/js/test/helpers.js
new file mode 100644
index 0000000..97e5e2f
--- /dev/null
+++ b/js/test/helpers.js
@@ -0,0 +1,18 @@
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { dirname, join } from 'node:path'
+
+const here = dirname(fileURLToPath(import.meta.url))
+
+/** Load fixtures/.json as { function, cases: [{ name, args, expected }] }. */
+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)) {
+ Object.freeze(x)
+ Object.values(x).forEach(freeze)
+ }
+ return x
+}
diff --git a/js/test/m00-sanity.test.js b/js/test/m00-sanity.test.js
new file mode 100644
index 0000000..b03e66a
--- /dev/null
+++ b/js/test/m00-sanity.test.js
@@ -0,0 +1,16 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadFixture, freeze } from './helpers.js'
+
+test('m00 arithmetic', () => assert.equal(1 + 1, 2))
+
+test('m00 fixture loads and cases hold', () => {
+ const fx = loadFixture('m00-sanity')
+ assert.equal(fx.function, 'sanity')
+ fx.cases.forEach((c) => assert.equal(c.args[0] + c.args[1], c.expected, c.name))
+})
+
+test('m00 freeze is deep', () => {
+ const o = freeze({ a: { b: [1] } })
+ assert.ok(Object.isFrozen(o.a) && Object.isFrozen(o.a.b))
+})
diff --git a/scala/project.scala b/scala/project.scala
new file mode 100644
index 0000000..504bb5c
--- /dev/null
+++ b/scala/project.scala
@@ -0,0 +1,3 @@
+//> using scala 3.5.2
+//> using test.dep org.scalameta::munit:1.0.4
+//> using test.dep com.lihaoyi::ujson:4.4.3
diff --git a/scala/solutions/.gitkeep b/scala/solutions/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/scala/src/.gitkeep b/scala/src/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/scala/test.sh b/scala/test.sh
new file mode 100755
index 0000000..9f0549e
--- /dev/null
+++ b/scala/test.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+# Usage: ./test.sh [MNN] (no arg = all tests)
+set -euo pipefail
+cd "$(dirname "$0")"
+SCALA_CLI="${SCALA_CLI:-$(command -v scala-cli || echo "$HOME/.local/bin/scala-cli")}"
+if [ $# -ge 1 ]; then
+ exec "$SCALA_CLI" test src test project.scala -- "*$1*"
+else
+ exec "$SCALA_CLI" test src test project.scala
+fi
diff --git a/scala/test/Fixtures.test.scala b/scala/test/Fixtures.test.scala
new file mode 100644
index 0000000..fd74b37
--- /dev/null
+++ b/scala/test/Fixtures.test.scala
@@ -0,0 +1,18 @@
+package snake
+
+import java.nio.file.{Files, Paths}
+
+/** Loads shared fixtures from ../fixtures/.json.
+ * Typed decoders (point, direction, status, action, state, opt) arrive with the model (Task 11). */
+object Fixtures:
+ private val dir = Paths.get(sys.props.getOrElse("user.dir", ".")).resolve("..").resolve("fixtures").normalize
+
+ def load(name: String): ujson.Value =
+ ujson.read(Files.readString(dir.resolve(s"$name.json")))
+
+ /** The cases array of a fixture. */
+ def cases(name: String): Seq[ujson.Value] = load(name)("cases").arr.toSeq
+
+ /** Decode a nullable JSON value into an Option. */
+ def opt[A](v: ujson.Value)(f: ujson.Value => A): Option[A] =
+ if v.isNull then None else Some(f(v))
diff --git a/scala/test/M00Sanity.test.scala b/scala/test/M00Sanity.test.scala
new file mode 100644
index 0000000..585091b
--- /dev/null
+++ b/scala/test/M00Sanity.test.scala
@@ -0,0 +1,18 @@
+package snake
+
+class M00Sanity extends munit.FunSuite:
+ test("arithmetic") { assertEquals(1 + 1, 2) }
+
+ test("fixture loads and cases hold") {
+ val fx = Fixtures.load("m00-sanity")
+ assertEquals(fx("function").str, "sanity")
+ Fixtures.cases("m00-sanity").foreach { c =>
+ val a = c("args").arr
+ assertEquals(a(0).num.toInt + a(1).num.toInt, c("expected").num.toInt, c("name").str)
+ }
+ }
+
+ test("opt decodes null") {
+ assertEquals(Fixtures.opt(ujson.Null)(_.num), None)
+ assertEquals(Fixtures.opt(ujson.Num(3))(_.num.toInt), Some(3))
+ }
diff --git a/scripts/check-stubs.sh b/scripts/check-stubs.sh
new file mode 100755
index 0000000..30c9ea4
--- /dev/null
+++ b/scripts/check-stubs.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# Usage: scripts/check-stubs.sh
+# Runs each module M01..M11 that has tests against the stubs in src/. Fails if any module's tests pass.
+set -uo pipefail
+track="${1:?usage: check-stubs.sh }"
+repo="$(cd "$(dirname "$0")/.." && pwd)"
+dir="$repo/$track"
+case "$track" in
+ js) has() { compgen -G "$dir/test/m$1-*.test.js" >/dev/null; } ;;
+ scala) has() { compgen -G "$dir/test/M$1*.test.scala" >/dev/null; } ;;
+ fsharp) has() { compgen -G "$dir/tests/M$1*.fs" >/dev/null; } ;;
+ *) echo "unknown track: $track" >&2; exit 2 ;;
+esac
+status=0
+for n in 01 02 03 04 05 06 07 08 09 10 11; do
+ m="M$n"
+ if ! has "$n"; then echo "$m: no tests"; continue; fi
+ if (cd "$dir" && ./test.sh "$m") >/dev/null 2>&1; then
+ echo "$m: FAIL (tests pass against stubs)"; status=1
+ else
+ echo "$m: ok (fails against stubs)"
+ fi
+done
+exit $status
diff --git a/scripts/serve.mjs b/scripts/serve.mjs
new file mode 100644
index 0000000..ca56502
--- /dev/null
+++ b/scripts/serve.mjs
@@ -0,0 +1,30 @@
+// Tiny zero-dependency static file server for the repo root. Tooling, not course code.
+// Usage: node scripts/serve.mjs [port] (default 8000)
+import { createServer } from 'node:http'
+import { readFile, stat } from 'node:fs/promises'
+import { extname, join, normalize, resolve, sep } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
+const port = Number(process.argv[2] ?? 8000)
+const types = {
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
+ '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.css': 'text/css',
+ '.md': 'text/markdown; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png',
+ '.wasm': 'application/wasm', '.map': 'application/json',
+}
+
+createServer(async (req, res) => {
+ try {
+ const path = normalize(decodeURIComponent(new URL(req.url, 'http://x').pathname))
+ let file = join(root, path)
+ if (file !== root && !file.startsWith(root + sep)) throw Object.assign(new Error(), { code: 'EACCES' })
+ if ((await stat(file)).isDirectory()) file = join(file, 'index.html')
+ const body = await readFile(file)
+ res.writeHead(200, { 'content-type': types[extname(file)] ?? 'application/octet-stream' })
+ res.end(body)
+ } catch (e) {
+ res.writeHead(e.code === 'EACCES' ? 403 : 404, { 'content-type': 'text/plain' })
+ res.end(e.code === 'EACCES' ? 'forbidden' : 'not found')
+ }
+}).listen(port, () => console.log(`Serving ${root} on http://localhost:${port}`))
diff --git a/scripts/verify.sh b/scripts/verify.sh
new file mode 100755
index 0000000..0dcab93
--- /dev/null
+++ b/scripts/verify.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Usage: scripts/verify.sh
+# Copies the repo to a temp dir, swaps src/ for solutions/, runs the track's full test suite.
+set -euo pipefail
+track="${1:?usage: verify.sh }"
+case "$track" in js|scala|fsharp) ;; *) echo "unknown track: $track" >&2; exit 2;; esac
+repo="$(cd "$(dirname "$0")/.." && pwd)"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+tar -C "$repo" --exclude=.git --exclude=node_modules --exclude=bin --exclude=obj \
+ --exclude=.scala-build --exclude=.bsp --exclude=.superpowers -cf - . | tar -C "$tmp" -xf -
+rm -rf "$tmp/$track/src"
+cp -r "$tmp/$track/solutions" "$tmp/$track/src"
+cd "$tmp/$track"
+./test.sh