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>
This commit is contained in:
1 parent
876f5aabb1
commit
edd57f0148
253 files changed
+21753
-503
No files matched your search
@@ -35,7 +35,8 @@ By the end of this module you can:
|
||||
- **explain** the type `IO<Stream<IO<unit>>>` layer by layer, and why `main` is a value;
|
||||
- **implement** an interpreter `runIO` as a loop and a driver `runApp` that holds the only mutable variable of the program;
|
||||
- **explain** how a `Platform` interface lets the runtime be tested with a fake instead of a browser;
|
||||
- **run** the game in a browser in your track, and **trace** one key press from the keyboard to the pixels.
|
||||
- **run** the game in a browser in your track, and **trace** one key press from the keyboard to the pixels;
|
||||
- **implement** `play`, which states the behaviour of the whole program, for a list of inputs, as one comparison of plain data.
|
||||
|
||||
## Concepts
|
||||
|
||||
@@ -124,11 +125,13 @@ And `main` is a **value**. Defining it performs nothing — no seed is asked for
|
||||
|
||||
```js
|
||||
const { ops, result: stream } = trace(main, 42)
|
||||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }]) // main asks for a seed, nothing else
|
||||
assert.deepStrictEqual(ops[0], { tag: 'RandomSeed' }) // main asks for a seed first
|
||||
const [outs] = stream.step(time(0)) // the first event…
|
||||
assert.equal(outs.length, 1) // …produces exactly one drawing: the new game
|
||||
```
|
||||
|
||||
(The real test also allows exactly one more operation after the seed, and lets the first drawing carry shapes after the new game's view. Both are for Module 10, where `main` also loads the stored best score and draws it; in this module, `ops` is simply `[{ tag: 'RandomSeed' }]`.)
|
||||
|
||||
The whole program, with its randomness, time and keyboard, is tested with the pure interpreter from Module 7 and `step` from Module 8. No browser.
|
||||
|
||||
**About `classicActions` and Module 10.** In Module 10, `main` will switch from `classicActions` to `speedActions`. The m09 tests that check the 150 ms behaviour therefore use a copy of `main` built from `classicActions` (`classicMain` in the test helpers), so they keep passing after Module 10.
|
||||
@@ -181,6 +184,8 @@ type Platform =
|
||||
OnKeyUp: (string -> unit) -> unit }
|
||||
```
|
||||
|
||||
(Module 10 adds two more methods, `loadBest` and `saveBest`, for a best score that survives a reload.)
|
||||
|
||||
The runtime only ever talks to a `Platform`. The browser implementation (`browser.js` in JS, `web/BrowserPlatform.scala`, `fsharp/web/BrowserPlatform.fs`) wraps a canvas, `requestAnimationFrame` and `crypto.getRandomValues`. The tests use a **fake platform** that records every call in a list and lets the test fire events by hand — a test double in the sense of [Martin Fowler's "Test Double"](https://martinfowler.com/bliki/TestDouble.html):
|
||||
|
||||
```js
|
||||
@@ -226,6 +231,8 @@ Remember section 12 of Module 7: the loop only helps if each continuation does a
|
||||
- store `next` in the variable;
|
||||
- run each IO in `ios` with `runIO`, in order.
|
||||
|
||||
(Module 13 adds a fourth registration, for mouse clicks on the canvas, and a matching `onClick` method to the platform. Write the three now; the tests of this module do not expect the fourth.)
|
||||
|
||||
That's the whole runtime. Go back to the stateful code at the start of Module 8, with its `let state` and logic in callbacks, and compare. There, the mutable variable held the *game state*, and game logic lived in the callbacks. Here, the variable holds the *current stream* — the runtime doesn't even know there's a game. It has no idea what a snake, a tick or a score is. All the game logic is in pure values; the runtime is a few lines of plumbing. That is what "pushing effects to the edge" means in practice.
|
||||
|
||||
```
|
||||
@@ -276,6 +283,30 @@ The first run restores Fable (a local .NET tool listed in `fsharp/.config/dotnet
|
||||
|
||||
Use the arrow keys or WASD. **You built a game in which every line of logic is pure.** Play a round. Then, before moving on, do the trace exercise in "Check your understanding": follow one key press from the keyboard to the pixels, naming each function it passes through.
|
||||
|
||||
### 10. Testing the program end to end without a browser
|
||||
|
||||
The tests so far check the pieces: `trace(main, 42)` for the first operation, one `step` for the first drawing, the runtime with a fake platform. Can you state the behaviour of the *whole* program in one line, the way a fixture states the behaviour of `update`?
|
||||
|
||||
Everything you need is already a value. `trace` interprets `main` and gives the stream; `run` (Module 8) feeds the stream a list of inputs and collects every IO it emits; `trace` again turns each emitted IO into the list of operations it would perform. Put together:
|
||||
|
||||
```
|
||||
play(main, seed, events) = run(trace(main, seed).result, events).map(io => trace(io, seed).ops)
|
||||
```
|
||||
|
||||
The result is a list with one entry per drawing, and each entry is the list of operations of that drawing. For our program each entry is a scene turned into `Draw` operations, so the whole game, with its seed, clock and keyboard, collapses into one comparison:
|
||||
|
||||
```js
|
||||
const events = [time(150), down('ArrowUp'), down('x'), time(300), time(310)]
|
||||
play(classicMain, 42, events)
|
||||
// equals: history(newGame(42), [Tick, Turn('Up'), Tick]).map(state => the Draw ops of view(state))
|
||||
```
|
||||
|
||||
Read that equality slowly. On the left is the program as the browser would run it, event by event. On the right is the game as Module 5 described it, a fold over actions, and the picture of each state as Module 6 described it. The test says they are the same thing. Nothing is mocked: `play` uses the same `main` that `main.js` hands to the runtime, and the only difference from a real session is the interpreter that answers the operations.
|
||||
|
||||
A test like this has a name in industry: a **golden** (or snapshot) test. You record the output of a known-good run once, keep it in a file, and from then on check that the program still produces exactly that. Module 10 keeps one in `fixtures/m10-golden.json`: a seed, a scripted list of inputs and the scene of the last drawing of the finished game. When `main` grows in Module 10 (it will also load the stored best score), `play` grows one parameter, the number that answers that load, exactly like `seed` answers `RandomSeed`.
|
||||
|
||||
> **From everyday programming:** this is an end-to-end test that needs no browser automation. Tools such as Playwright or Selenium start a real browser, click, wait and take screenshots, which is slow and sometimes flaky because real time passes. Here time is just an input value, so the "recording" of a whole game session is a list of events, the "screenshot" is a list of shapes, and the test runs in milliseconds and gives the same answer every time.
|
||||
|
||||
## Exercises
|
||||
|
||||
> **About test names.** The test titles quoted under "Proved by" are the JavaScript ones. The Scala and F# suites test the same things one-to-one, but their titles use each track's vocabulary: "events" instead of "inputs", `None` instead of `null`, `()` instead of `undefined`, `bind`/`choose` instead of `flatMap`/`filterMap`, `IO.map throwingF io` instead of `map(io, throwingF)`, and so on. Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M09*.test.scala` or `fsharp/tests/M09*.fs`. Fixture-based tests have the same case names in every track.
|
||||
@@ -379,7 +410,36 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit = failwith "TODO m0
|
||||
export const browserPlatform = (canvas, win) => /* Platform */
|
||||
```
|
||||
|
||||
Build a `Platform` from a canvas and a window: `fillRect`/`fillText` use `canvas.getContext('2d')` (font `` `${size}px monospace` ``, `textAlign` from `align`, `fillStyle` from `color`); `startClock` calls `onTime(t)` on every `win.requestAnimationFrame`, with `t` = milliseconds since the first frame; `onKeyDown`/`onKeyUp` listen on `win` and call `preventDefault()` for arrow keys and space so the page doesn't scroll; `randomSeed` returns `crypto.getRandomValues(new Uint32Array(1))[0]`. There is no automated test for this file; the test is that the game runs. Scala and F# students: read the given `BrowserPlatform` in your track and compare it with this description.
|
||||
Build a `Platform` from a canvas and a window: `fillRect`/`fillText` use `canvas.getContext('2d')` (font `` `${size}px monospace` ``, `textAlign` from `align`, `fillStyle` from `color`); `startClock` calls `onTime(t)` on every `win.requestAnimationFrame`, with `t` = milliseconds since the first frame; `onKeyDown`/`onKeyUp` listen on `win` and call `preventDefault()` for arrow keys and space so the page doesn't scroll; `randomSeed` returns `crypto.getRandomValues(new Uint32Array(1))[0]`. There is no automated test for this file in this module; the test is that the game runs. (Module 10 adds two methods for a stored best score, and tests for those two with a fake window.) Scala and F# students: read the given `BrowserPlatform` in your track and compare it with this description.
|
||||
|
||||
### Exercise 9.5 — `play`: the whole program in one equality
|
||||
|
||||
**JavaScript** (`js/src/app.js`)
|
||||
|
||||
```js
|
||||
export const play = (main, seed, events) => /* one list of ops per drawing */
|
||||
// Module 10 adds a fourth parameter: (main, seed, events, best = 0)
|
||||
```
|
||||
|
||||
**Scala** (`scala/src/App.scala`)
|
||||
|
||||
```scala
|
||||
def play(main: IO[Stream[IO[Unit]]], seed: Long, events: List[Event]): List[List[Op]] = ???
|
||||
// Module 10 adds a fourth parameter: best: Int = 0
|
||||
```
|
||||
|
||||
**F#** (`fsharp/src/App.fs`)
|
||||
|
||||
```fsharp
|
||||
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list = failwith "TODO m09"
|
||||
// Module 10 adds playWith seed best events main, and play becomes playWith seed 0 events main
|
||||
```
|
||||
|
||||
`play` is a pure helper: it takes the program as an argument, so it works for `main`, for `classicMain` in the tests, and for Pong's program in Module 11.
|
||||
|
||||
**Proved by:** `play on the classic program equals the scenes of the expected states`, `play with no events gives no drawings`, `play uses the seed for the program's RandomSeed`.
|
||||
|
||||
**Watch out for:** `trace` returns `{result, ops}`; the stream is the `result` of tracing `main`, but for each emitted IO you want its `ops`. Use the same `seed` for both traces.
|
||||
|
||||
**Run:**
|
||||
- JS: `cd js && node --test "test/m09-*.test.js"`, then `node scripts/serve.mjs` (from the repo root) and open <http://localhost:8000/js/>
|
||||
@@ -506,6 +566,42 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 9.5 — nudge</summary>
|
||||
|
||||
Three steps, each one a function you have: get the stream out of `main`, feed it the events, and turn each IO it emitted into its list of operations. Which function feeds a stream a whole list of inputs and collects the outputs?
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 9.5 — stronger hint</summary>
|
||||
|
||||
`trace(main, seed).result` is the stream. `run(stream, events)` is the list of IOs emitted, in order. `map` over that list with `io => trace(io, seed).ops`.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 9.5 — near-solution (try the others first)</summary>
|
||||
|
||||
**JavaScript**
|
||||
|
||||
```js
|
||||
export const play = (main, seed, events) => run(trace(main, ___).result, ___).map((io) => ___)
|
||||
```
|
||||
|
||||
**Scala**
|
||||
|
||||
```scala
|
||||
def play(main: IO[Stream[IO[Unit]]], seed: Long, events: List[Event]): List[List[Op]] =
|
||||
IO.trace(main, seed).result.run(___).map(io => ___)
|
||||
```
|
||||
|
||||
**F#**
|
||||
|
||||
```fsharp
|
||||
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list =
|
||||
(IO.trace seed main).Result |> Stream.run ___ |> List.map (fun io -> ___)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Check your understanding
|
||||
|
||||
1. Read `IO<Stream<IO<unit>>>` aloud and explain each layer. Why can't the outer `IO` be removed? Why isn't the inner one a `Scene`?
|
||||
@@ -516,6 +612,8 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
|
||||
6. What makes the runtime testable without a browser? What would you have to do to test it if it called `canvas.getContext('2d')` directly?
|
||||
7. Count the impure lines in your track (runtime + browser glue + main). Compare with the number of pure lines. What does that ratio tell you?
|
||||
8. `main` is defined at module load time. Prove to yourself that defining it performs no effect. (Hint: what does `trace(main, 1).ops` contain, and when does the platform's `randomSeed` get called?)
|
||||
9. `play(classicMain, 42, events)` equals the scenes of `history(newGame(42), actions)`. Which part of the program does the left side exercise that the right side does not? Name a bug in `classicActions` or `states` that this equality would catch and the Module 5 fixtures would not.
|
||||
10. A golden test fails after you change the colour of the apple. Is that a bug in the program or in the test? What should you do, and what does that tell you about when golden tests are useful?
|
||||
|
||||
## In production
|
||||
|
||||
@@ -528,6 +626,7 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
|
||||
|
||||
- [The Elm Architecture](https://guide.elm-lang.org/architecture/) — compare `init`/`update`/`view`/`subscriptions` with `newGame`/`update`/`view`/`classicActions`.
|
||||
- [Gary Bernhardt, "Functional Core, Imperative Shell"](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell) — the architecture you just built, explained in 13 minutes.
|
||||
- [Gary Bernhardt, "Boundaries"](https://www.destroyallsoftware.com/talks/boundaries) — the longer talk on the same idea: values as the boundaries between parts of a system.
|
||||
- [MDN: `requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) — where the browser platform's clock comes from.
|
||||
- [MDN: `Crypto.getRandomValues`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) — where the seed comes from.
|
||||
- [Martin Fowler, "Test Double"](https://martinfowler.com/bliki/TestDouble.html) — the vocabulary for fakes, stubs and mocks; our fake platform is a "fake".
|
||||
|
||||
Reference in new issue
Block a user