Files
functional-programming-snake/scripts/check-progression.sh
T
Lucas WintherandClaude Fable 5.2 edd57f0148 feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)
Audit pass:
- Frame rule counts frames since the last move, so every step lasts
  exactly movesEvery(score) frames, also right after a speed-up
- clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap
- Pong Restart keeps held keys
- Fact corrections (mulberry32 output coverage, Haskell IO, uniform)
- New fixture cases from a mutation analysis

Extensions (all three tracks, shared fixtures, course text):
- Turn queue (Exercise 10.6), persisted high score (10.7),
  replay viewer (10.8), play() end-to-end helper (9.5)
- Optional State (4.7) and optional Module 7b, stack-safe IO
- Module 12, property-based testing with generators, shrinking,
  invariants and a model-based oracle
- Module 13, Conway's Life with a Click event
- JS type checking via jsconfig, JSDoc and types.d.ts
- Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh,
  Playwright end-to-end suite (e2e/, scripts/e2e.sh)

Docs: docs/contract.md is the canonical contract; specs and build log
under docs/superpowers.

Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression
M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:05:29 +01:00

117 lines
4.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# Usage: scripts/check-progression.sh <js|scala|fsharp>
# Progression checkpoint: for each module N (that has tests in the track), build a temp copy where src/ holds the
# reference solutions of modules <= N and the untouched student stubs for the rest, then run the tests of modules
# M01..MN. All must pass: this proves a student who has finished module N has a green suite up to N, and that
# no earlier solution secretly depends on a later one. The optional module 07b sits between 07 and 08.
set -uo pipefail
track="${1:?usage: check-progression.sh <js|scala|fsharp>}"
repo="$(cd "$(dirname "$0")/.." && pwd)"
export PATH="$HOME/.dotnet:$HOME/.local/bin:$PATH"
# Module -> files (relative to src/ and solutions/) that the module introduces. A directory entry copies it whole.
files_for() {
case "$track:$1" in
js:01) echo "util.js todo.js" ;;
js:02) echo "model.js" ;;
js:03) echo "rules.js" ;;
js:04) echo "random.js spawn.js state.js" ;;
js:05) echo "game.js" ;;
js:06) echo "view.js" ;;
js:07) echo "io.js" ;;
js:07b) echo "safeio.js" ;; # optional module 7b
js:08) echo "stream.js" ;;
js:09) echo "input.js app.js runtime.js browser.js main.js" ;;
js:10) echo "replay.js" ;; # also edits game/input/stream/app/io/runtime/browser, already included by m05-m09
js:11) echo "pong" ;;
js:12) echo "check.js" ;;
js:13) echo "life" ;;
scala:01) echo "Util.scala" ;;
scala:02) echo "Model.scala" ;;
scala:03) echo "Rules.scala" ;;
scala:04) echo "Random.scala Spawn.scala State.scala" ;;
scala:05) echo "Game.scala" ;;
scala:06) echo "View.scala" ;;
scala:07) echo "IO.scala" ;;
scala:07b) echo "SafeIO.scala" ;;
scala:08) echo "Stream.scala" ;;
scala:09) echo "Input.scala App.scala Runtime.scala" ;;
scala:10) echo "Replay.scala" ;;
scala:11) echo "pong" ;;
scala:12) echo "Check.scala" ;;
scala:13) echo "life" ;;
fsharp:01) echo "Util.fs" ;; # Snake.fsproj lists every file from the start and is identical in both trees
fsharp:02) echo "Model.fs" ;;
fsharp:03) echo "Rules.fs" ;;
fsharp:04) echo "Random.fs Spawn.fs State.fs" ;;
fsharp:05) echo "Game.fs" ;;
fsharp:06) echo "View.fs" ;;
fsharp:07) echo "IO.fs" ;;
fsharp:07b) echo "SafeIO.fs" ;;
fsharp:08) echo "Stream.fs" ;;
fsharp:09) echo "Input.fs App.fs Runtime.fs" ;;
fsharp:10) echo "Replay.fs" ;;
fsharp:11) echo "Pong" ;;
fsharp:12) echo "Check.fs" ;;
fsharp:13) echo "Life" ;;
*) echo "" ;;
esac
}
case "$track" in
js) has() { compgen -G "$repo/js/test/m$1-*.test.js" >/dev/null; } ;;
scala) has() { compgen -G "$repo/scala/test/M$1*.test.scala" >/dev/null; } ;;
fsharp) has() { compgen -G "$repo/fsharp/tests/M$1*.fs" >/dev/null; } ;;
*) echo "unknown track: $track" >&2; exit 2 ;;
esac
# Modules in course order. "07b" (optional) comes after 07: order is position in this list, not numeric value.
modules="01 02 03 04 05 06 07 07b 08 09 10 11 12 13"
# Scala's `./test.sh M07` (munit glob "*M07*") and F#'s (Expecto --filter-test-list, a substring) also select the
# M07b tests, unlike JS's "m07-*" glob. Before module 07b is reached its tests must not run, so delete them from
# the temp copy (and, for F#, their line in tests/Snake.Tests.fsproj; Expecto finds test lists by attribute).
drop_tests() {
case "$track" in
scala) rm -f "$tmp"/scala/test/M"$1"*.test.scala ;;
fsharp) for f in "$tmp"/fsharp/tests/M"$1"*.fs; do
[ -e "$f" ] || continue
rm -f "$f"; sed -i "/Include=\"$(basename "$f")\"/d" "$tmp/fsharp/tests/Snake.Tests.fsproj"
done ;;
esac
}
status=0
for n in $modules; do
has "$n" || continue
tmp="$(mktemp -d)"
tar -C "$repo" --exclude=.git --exclude=node_modules --exclude=bin --exclude=obj \
--exclude=.scala-build --exclude=.bsp --exclude=.superpowers -cf - . | tar -C "$tmp" -xf -
reached=""; past=0
for m in $modules; do
if [ $past -eq 1 ]; then # a later module: drop its tests if a reached module's tag is a prefix of its tag
for r in $reached; do [[ "$m" == "$r"?* ]] && drop_tests "$m"; done
continue
fi
for f in $(files_for "$m"); do
[ -e "$tmp/$track/solutions/$f" ] || continue # module not (yet) present in this track
rm -rf "$tmp/$track/src/$f"
cp -r "$tmp/$track/solutions/$f" "$tmp/$track/src/$f"
done
reached="$reached $m"
[ "$m" = "$n" ] && past=1
done
ok=1
for m in $reached; do
has "$m" || continue
if ! out="$(cd "$tmp/$track" && ./test.sh "M$m" 2>&1)"; then
echo "checkpoint M$n: FAIL (M$m tests fail with solutions up to M$n)"
echo "$out" | tail -40
ok=0; status=1; break
fi
done
[ $ok -eq 1 ] && echo "checkpoint M$n: ok (M01..M$n pass)"
rm -rf "$tmp"
done
exit $status