chore: scaffold tracks and tooling

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-28 23:34:21 +01:00
co-authored by Claude Opus 5.5
parent 6dc5bdde9e
commit e150b7fefb
27 changed files with 282 additions and 0 deletions
+4
View File
@@ -5,3 +5,7 @@ obj/
.scala-build/
.bsp/
*/web/dist/
# build outputs
.fable/
*/web/node_modules/
+1
View File
@@ -0,0 +1 @@
# Functional Programming Course
+7
View File
@@ -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 }
]
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Compile Include="Util.fs" />
</ItemGroup>
</Project>
+2
View File
@@ -0,0 +1,2 @@
/// Placeholder; replaced by the m01 utilities.
module Snake.Util
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Compile Include="Util.fs" />
</ItemGroup>
</Project>
+2
View File
@@ -0,0 +1,2 @@
/// Placeholder; replaced by the m01 utilities.
module Snake.Util
Executable
+11
View File
@@ -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
+21
View File
@@ -0,0 +1,21 @@
/// Loads shared fixtures from ../../fixtures/<name>.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)
+23
View File
@@ -0,0 +1,23 @@
module Snake.Tests.M00Sanity
open System.Text.Json
open Expecto
open Snake.Tests
[<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"
}
]
+6
View File
@@ -0,0 +1,6 @@
module Snake.Tests.Main
open Expecto
[<EntryPoint>]
let main argv = runTestsInAssemblyWithCLIArgs [] argv
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Fixtures.fs" />
<Compile Include="M00Sanity.fs" />
<Compile Include="Main.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Expecto" Version="11.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../src/Snake.fsproj" />
</ItemGroup>
</Project>
+8
View File
@@ -0,0 +1,8 @@
{
"name": "snake-course-js",
"private": true,
"type": "module",
"scripts": {
"test": "node --test \"test/*.test.js\""
}
}
View File
View File
Executable
+10
View File
@@ -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
+18
View File
@@ -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/<name>.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
}
+16
View File
@@ -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))
})
+3
View File
@@ -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
View File
View File
Executable
+10
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
package snake
import java.nio.file.{Files, Paths}
/** Loads shared fixtures from ../fixtures/<name>.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))
+18
View File
@@ -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))
}
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Usage: scripts/check-stubs.sh <js|scala|fsharp>
# 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 <js|scala|fsharp>}"
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
+30
View File
@@ -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}`))
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Usage: scripts/verify.sh <js|scala|fsharp>
# 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 <js|scala|fsharp>}"
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