chore: scaffold tracks and tooling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6dc5bdde9e
commit
e150b7fefb
@@ -5,3 +5,7 @@ obj/
|
||||
.scala-build/
|
||||
.bsp/
|
||||
*/web/dist/
|
||||
|
||||
# build outputs
|
||||
.fable/
|
||||
*/web/node_modules/
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
/// Placeholder; replaced by the m01 utilities.
|
||||
module Snake.Util
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
/// Placeholder; replaced by the m01 utilities.
|
||||
module Snake.Util
|
||||
Executable
+11
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
module Snake.Tests.Main
|
||||
|
||||
open Expecto
|
||||
|
||||
[<EntryPoint>]
|
||||
let main argv = runTestsInAssemblyWithCLIArgs [] argv
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "snake-course-js",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test \"test/*.test.js\""
|
||||
}
|
||||
}
|
||||
Executable
+10
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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
|
||||
Executable
+10
@@ -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
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
}
|
||||
Executable
+24
@@ -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
|
||||
@@ -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}`))
|
||||
Executable
+15
@@ -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
|
||||
Reference in New Issue
Block a user