From e150b7fefbd5a7b77255b16777dc0c5f915c1825 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 28 Oct 2026 23:34:21 +0100 Subject: [PATCH] chore: scaffold tracks and tooling Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 ++++ README.md | 1 + fixtures/m00-sanity.json | 7 +++++++ fsharp/solutions/Snake.fsproj | 9 +++++++++ fsharp/solutions/Util.fs | 2 ++ fsharp/src/Snake.fsproj | 9 +++++++++ fsharp/src/Util.fs | 2 ++ fsharp/test.sh | 11 +++++++++++ fsharp/tests/Fixtures.fs | 21 +++++++++++++++++++++ fsharp/tests/M00Sanity.fs | 23 +++++++++++++++++++++++ fsharp/tests/Main.fs | 6 ++++++ fsharp/tests/Snake.Tests.fsproj | 17 +++++++++++++++++ js/package.json | 8 ++++++++ js/solutions/.gitkeep | 0 js/src/.gitkeep | 0 js/test.sh | 10 ++++++++++ js/test/helpers.js | 18 ++++++++++++++++++ js/test/m00-sanity.test.js | 16 ++++++++++++++++ scala/project.scala | 3 +++ scala/solutions/.gitkeep | 0 scala/src/.gitkeep | 0 scala/test.sh | 10 ++++++++++ scala/test/Fixtures.test.scala | 18 ++++++++++++++++++ scala/test/M00Sanity.test.scala | 18 ++++++++++++++++++ scripts/check-stubs.sh | 24 ++++++++++++++++++++++++ scripts/serve.mjs | 30 ++++++++++++++++++++++++++++++ scripts/verify.sh | 15 +++++++++++++++ 27 files changed, 282 insertions(+) create mode 100644 README.md create mode 100644 fixtures/m00-sanity.json create mode 100644 fsharp/solutions/Snake.fsproj create mode 100644 fsharp/solutions/Util.fs create mode 100644 fsharp/src/Snake.fsproj create mode 100644 fsharp/src/Util.fs create mode 100755 fsharp/test.sh create mode 100644 fsharp/tests/Fixtures.fs create mode 100644 fsharp/tests/M00Sanity.fs create mode 100644 fsharp/tests/Main.fs create mode 100644 fsharp/tests/Snake.Tests.fsproj create mode 100644 js/package.json create mode 100644 js/solutions/.gitkeep create mode 100644 js/src/.gitkeep create mode 100755 js/test.sh create mode 100644 js/test/helpers.js create mode 100644 js/test/m00-sanity.test.js create mode 100644 scala/project.scala create mode 100644 scala/solutions/.gitkeep create mode 100644 scala/src/.gitkeep create mode 100755 scala/test.sh create mode 100644 scala/test/Fixtures.test.scala create mode 100644 scala/test/M00Sanity.test.scala create mode 100755 scripts/check-stubs.sh create mode 100644 scripts/serve.mjs create mode 100755 scripts/verify.sh 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