module Snake.Tests.M13Life open System.Diagnostics open System.Text.Json open Expecto open Snake open Snake.Model open Snake.Stream open Snake.Life.Model open Snake.Life.Rules open Snake.Life.View open Snake.Life.App open Snake.Tests.Fixtures open Snake.Tests.M09Helpers open Snake.Tests.M09Runtime let private lifeState (v: JsonElement) : LifeState = { Cells = list point (prop "cells" v) Running = bool (prop "running" v) Seed = u32 (prop "seed" v) Generation = num (prop "generation" v) } let private lifeAction (v: JsonElement) : LifeAction = match str (prop "tag" v) with | "Frame" -> LifeAction.Frame | "Toggle" -> LifeAction.Toggle(point (prop "cell" v)) | "TogglePause" -> LifeAction.TogglePause | "Step" -> LifeAction.Step | "Clear" -> LifeAction.Clear | "Randomize" -> LifeAction.Randomize | other -> failwith $"unknown life action tag: {other}" let private cell (x: int) (y: int) : Point = { X = x; Y = y } let private rowMajor (cells: Point list) : bool = cells |> List.pairwise |> List.forall (fun (a, b) -> a.Y < b.Y || (a.Y = b.Y && a.X < b.X)) // n generations after `cells`, the start included: n + 1 boards. let private generations (n: int) (cells: Point list) : Point list list = List.scan (fun cs _ -> lifeStep cs) cells [ 1..n ] [] let tests = testList "M13 Life" [ yield! fixtureTests "m13-life-step" "lifeStep" (fun c -> Expect.equal (lifeStep (list point (arg 0 c))) (list point (expected c)) "lifeStep") yield! fixtureTests "m13-life-update" "lifeUpdate" (fun c -> Expect.equal (lifeUpdate (lifeState (arg 0 c)) (lifeAction (arg 1 c))) (lifeState (expected c)) "lifeUpdate") yield! fixtureTests "m13-life-view" "lifeView" (fun c -> Expect.equal (lifeView (lifeState (arg 0 c))) (list shape (expected c)) "lifeView") yield! fixtureTests "m13-life-key" "lifeKey" (fun c -> Expect.equal (lifeKey (str (arg 0 c))) (opt lifeAction (expected c)) "lifeKey") yield! fixtureTests "m13-life-click" "lifeClick" (fun c -> Expect.equal (lifeClick (num (arg 0 c)) (num (arg 1 c))) (opt lifeAction (expected c)) "lifeClick") yield! [ // ---- the model and the rules testCase "the board is 40 by 30 cells of 16 pixels, with a 24-pixel status strip below" (fun () -> Expect.equal [ LW; LH; LCELL; STRIP ] [ 40; 30; 16; 24 ] "constants") testCase "initialLife is an empty, paused world at generation 0 with the given seed" (fun () -> Expect.equal (initialLife 9u) { Cells = [] Running = false Seed = 9u Generation = 0 } "initialLife") testCase "lifeUpdate does not modify the state it is given" (fun () -> let make () = { Cells = [ cell 4 5; cell 5 5; cell 6 5 ] Running = true Seed = 3u Generation = 0 } let s = make () [ LifeAction.Frame LifeAction.Toggle(cell 0 0) LifeAction.Toggle(cell 5 5) LifeAction.Randomize LifeAction.Clear ] |> List.iter (fun a -> lifeUpdate s a |> ignore) Expect.equal s (make ()) "unchanged") testCase "a glider moves one cell diagonally every four generations" (fun () -> let glider = [ cell 1 0; cell 2 1; cell 0 2; cell 1 2; cell 2 2 ] Expect.equal (List.last (generations 4 glider)) [ cell 2 1; cell 3 2; cell 1 3; cell 2 3; cell 3 3 ] "after four generations") testCase "the next generation is computed from the whole old board: a blinker blinks forever" (fun () -> let horizontal = [ cell 9 10; cell 10 10; cell 11 10 ] let gens = generations 6 horizontal Expect.equal gens[2] horizontal "two generations" Expect.equal gens[6] horizontal "six generations" Expect.equal gens[1] [ cell 10 9; cell 10 10; cell 10 11 ] "vertical in between") testCase "Randomize draws one intBelow 4 per cell in row-major order, alive when the draw is 0" (fun () -> let seed = 2024u // The same draws, written the long way round with the Module 4 generator. let draws, next = List.fold (fun (ds, s) _ -> let d, s2 = Random.run s (Random.intBelow 4) ds @ [ d ], s2) ([], seed) [ 1 .. LW * LH ] let expectedCells = draws |> List.indexed |> List.choose (fun (i, d) -> if d = 0 then Some(cell (i % LW) (i / LW)) else None) let result = lifeUpdate (initialLife seed) LifeAction.Randomize Expect.equal result.Cells expectedCells "cells" Expect.equal result.Seed next "seed" let n = List.length result.Cells Expect.isTrue (n > 200 && n < 400) $"{n} live cells") testCase "every generation of a random board stays sorted row-major, on the board and without duplicates" (fun () -> let start = (lifeUpdate (initialLife 5u) LifeAction.Randomize).Cells generations 20 start |> List.iter (fun cells -> Expect.isTrue (rowMajor cells) "sorted row-major, no duplicates" Expect.all cells (fun c -> c.X >= 0 && c.X < LW && c.Y >= 0 && c.Y < LH) "on the board")) // Generous on purpose (and more so than in JS, for an unoptimised Debug build): a straightforward // lifeStep takes a few milliseconds per generation. This only catches a generation that is far slower // than the 150 ms between two of them. testCase "lifeStep keeps up: 50 generations of a quarter-full board take well under 50 x 150 ms" (fun () -> let start = (lifeUpdate (initialLife 11u) LifeAction.Randomize).Cells let watch = Stopwatch.StartNew() generations 50 start |> ignore let ms = watch.ElapsedMilliseconds Expect.isLessThan ms 5000L $"{ms} ms") // ---- the fourth kind of event testCase "clicks emits the position of every Click event and ignores everything else" (fun () -> Expect.equal (Stream.clicks |> Stream.run [ Time 10L; Click(3, 4); KeyDown "a"; Click(0, 479); KeyUp "a" ]) [ (3, 4); (0, 479) ] "positions") testCase "the other sources ignore clicks" (fun () -> let events = [ Click(5, 5); Time 100L; Click(6, 6) ] Expect.equal (Stream.run events Stream.keyDowns) [] "keyDowns" Expect.equal (Stream.run events Stream.keyUps) [] "keyUps" Expect.equal (Stream.run events (Stream.interval 50L)) [ 50L; 100L ] "interval" Expect.equal (Stream.run events (Stream.clampTime 250L Stream.clicks)) [ (5, 5); (6, 6) ] "clampTime passes clicks through") testCase "a generation every 150 ms" (fun () -> Expect.equal GEN_MS 150L "GEN_MS") testCase "lifeActions turns the clock into Frames, keys into actions and clicks on the board into Toggles" (fun () -> Expect.equal (lifeActions |> Stream.run [ Time 150L; KeyDown " "; Click(20, 40); KeyDown "x"; Click(700, 10); KeyDown "n"; Time 450L ]) [ LifeAction.Frame LifeAction.TogglePause LifeAction.Toggle(cell 1 2) LifeAction.Step LifeAction.Frame LifeAction.Frame ] "actions") testCase "lifeStates starts with the new world, then yields one state per action" (fun () -> let out = lifeStates 7u lifeActions |> Stream.run [ Time 0L; Click(16, 16); Click(32, 16); Click(48, 16); KeyDown " "; Time 150L ] Expect.equal (List.head out) (initialLife 7u) "first state" Expect.equal (out |> List.map (fun s -> List.length s.Cells)) [ 0; 1; 2; 3; 3; 3 ] "live cells" Expect.equal (List.last out) { Cells = [ cell 2 0; cell 2 1; cell 2 2 ] Running = true Seed = 7u Generation = 1 } "last state") testCase "lifeMain asks for a seed, draws the empty world first, and then a click as a live cell" (fun () -> let traced = IO.trace 42u (lifeMain ()) Expect.equal traced.Ops [ IO.RandomSeed ] "ops" let first, next = Stream.step (Time 0L) traced.Result let second, _ = Stream.step (Click(17, 33)) next Expect.equal (drawn (List.head first)) (lifeView (initialLife 42u)) "first drawing" Expect.equal (drawn (List.head second)) (lifeView { initialLife 42u with Cells = [ cell 1 2 ] }) "second drawing") testCase "lifeMain: after ten seconds in a background tab the world advances by 250 ms, not by ten seconds" (fun () -> let _, afterFirst = Stream.step (Time 0L) (IO.trace 42u (lifeMain ())).Result Expect.equal (List.length (fst (Stream.step (Time 10000L) afterFirst))) 1 "one drawing") // ---- the runtime (step 7 of the module); the browser glue in web/ is given testCase "runApp registers a click handler with the platform" (fun () -> let p = FakePlatform 42u Runtime.runApp p.Platform (IO.pure (Stream.clicks |> Stream.map (fun _ -> IO.pure ()))) Expect.equal p.Registered [ "click"; "down"; "time"; "up" ] "registered") testCase "runApp feeds clicks to the stream" (fun () -> let p = FakePlatform 42u let program = IO.pure (Stream.clicks |> Stream.map (fun (x, y) -> IO.draw (View.Rect(x, y, 1, 1, "#ffffff")))) Runtime.runApp p.Platform program p.FireClick(33, 47) p.FireClick(0, 2) Expect.equal p.Calls [ [ box "fillRect"; box 33; box 47; box 1; box 1; box "#ffffff" ] [ box "fillRect"; box 0; box 2; box 1; box 1; box "#ffffff" ] ] "calls") testCase "runApp runs Life: a click on the canvas draws the world with that cell alive" (fun () -> let p = FakePlatform 42u Runtime.runApp p.Platform (lifeMain ()) p.FireTime 0L p.Clear() p.FireClick(100, 250) Expect.contains p.Calls [ box "fillRect"; box (6 * 16 + 1); box (15 * 16 + 1); box 14; box 14; box "#7cb342" ] "the live cell") testCase "Snake and Pong ignore clicks: their actions and drawings are the same with and without them" (fun () -> let withClicks = [ Time 0L; Click(5, 5); Time 25L; KeyDown "ArrowUp"; Click(300, 300); Time 200L ] let without = withClicks |> List.filter (fun e -> match e with | Click _ -> false | _ -> true) Expect.equal (Stream.run withClicks App.speedActions) (Stream.run without App.speedActions) "speedActions" Expect.equal (Stream.run withClicks Pong.App.pongActions) (Stream.run without Pong.App.pongActions) "pongActions" Expect.equal (App.play 42u withClicks (App.main ())) (App.play 42u without (App.main ())) "play main") ] ]