feat(fsharp): m09 input/app/runtime and m10 extensions with tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 00:56:27 +01:00
co-authored by Claude Opus 5.5
parent e27372f017
commit 55f8b0b30f
14 changed files with 435 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
module Snake.Tests.M09App
open Expecto
open Snake
open Snake.Model
open Snake.IO
open Snake.Stream
open Snake.Tests.M09Helpers
[<Tests>]
let tests =
testList "M09 App" [
testCase "the game ticks every 150 ms" (fun () -> Expect.equal App.TICK_MS 150L "TICK_MS")
testCase "classicActions turns the clock into Tick actions and keys into Turn actions" (fun () ->
Expect.equal
(Stream.run [ Time 150L; KeyDown "ArrowUp"; KeyDown "x"; Time 300L ] App.classicActions)
[ Tick; Turn Up; Tick ]
"actions")
testCase "states starts with the new game, then yields one state per action" (fun () ->
let out = App.states 42u App.classicActions |> Stream.run [ Time 150L; KeyDown "ArrowUp"; Time 300L ]
Expect.equal (List.length out) 4 "length"
Expect.equal (List.head out) (Spawn.newGame 42u) "first state"
Expect.equal
(out |> List.map (fun s -> List.head s.Snake))
[ { X = 10; Y = 10 }; { X = 11; Y = 10 }; { X = 11; Y = 10 }; { X = 11; Y = 9 } ]
"heads"
Expect.equal (out |> List.map (fun s -> s.NextDir)) [ Right; Right; Up; Up ] "nextDir"
Expect.equal (out |> List.map (fun s -> s.Dir)) [ Right; Right; Right; Up ] "dir")
testCase "render draws the whole view of the state" (fun () ->
Expect.equal
(IO.trace 0u (App.render (Spawn.newGame 42u))).Ops
(View.view (Spawn.newGame 42u) |> List.map Draw)
"ops")
testCase "main asks for a random seed, then builds the stream from it" (fun () ->
let traced = IO.trace 42u (App.main ())
Expect.equal traced.Ops [ RandomSeed ] "ops"
let outs, _ = Stream.step (Time 0L) traced.Result
Expect.equal (List.length outs) 1 "one drawing"
Expect.equal (drawn (List.head outs)) (View.view (Spawn.newGame 42u)) "drawing")
// `main` itself switches to speedActions in m10, so the 150 ms behaviour is checked on the same program built from classicActions.
testCase "the program built from classicActions draws the new game first, then the ticked game after 150 ms" (fun () ->
let stream = (IO.trace 42u (classicMain ())).Result
let first, next = Stream.step (Time 0L) stream
let second, _ = Stream.step (Time 150L) next
Expect.equal (drawn (List.head first)) (View.view (Spawn.newGame 42u)) "first drawing"
Expect.equal (List.length second) 1 "one more drawing"
Expect.notEqual (drawn (List.head second)) (drawn (List.head first)) "the game moved")
// Holds before and after m10: whatever `main` ticks with, the clock must be wired in, so time passing produces more drawings.
testCase "main draws again once time has passed" (fun () ->
let stream = (IO.trace 42u (App.main ())).Result
let first, next = Stream.step (Time 0L) stream
let later, _ = Stream.step (Time 150L) next
Expect.equal (List.length first) 1 "one drawing"
Expect.isNonEmpty later "more drawings")
]
+19
View File
@@ -0,0 +1,19 @@
/// Test helpers shared by the m09, m10 and m11 suites.
module Snake.Tests.M09Helpers
open Snake
open Snake.View
open Snake.IO
open Snake.Stream
/// The m09 program built from classicActions (ticks every 150 ms). The shipped `main` switches to speedActions in m10.
let classicMain () : IO<Stream<IO<unit>>> =
IO.randomSeed () |> IO.map (fun seed -> App.states seed App.classicActions |> Stream.map App.render)
/// The shapes an IO draws, in order (interpreted by `IO.trace`).
let drawn (io: IO<unit>) : Shape list =
(IO.trace 0u io).Ops
|> List.choose (fun op ->
match op with
| Draw shape -> Some shape
| RandomSeed -> None)
+12
View File
@@ -0,0 +1,12 @@
module Snake.Tests.M09Input
open Expecto
open Snake
open Snake.Tests.Fixtures
[<Tests>]
let tests =
testList "M09 Input" [
yield! fixtureTests "m09-input" "keyToAction" (fun c ->
Expect.equal (Input.keyToAction (str (arg 0 c))) (opt action (expected c)) "keyToAction")
]
+115
View File
@@ -0,0 +1,115 @@
module Snake.Tests.M09Runtime
open Expecto
open Snake
open Snake.Model
open Snake.View
open Snake.Runtime
open Snake.Tests.M09Helpers
/// A fake platform: records every call and lets the test fire events by hand. (Tests may mutate; src/ may not.)
type FakePlatform(seed: uint32) =
let calls = ResizeArray<obj list>()
let mutable onTime: int64 -> unit = ignore
let mutable onDown: string -> unit = ignore
let mutable onUp: string -> unit = ignore
member _.Calls: obj list list = List.ofSeq calls
member _.Clear() = calls.Clear()
member _.FireTime(t: int64) = onTime t
member _.FireKey(key: string) = onDown key
member _.FireKeyUp(key: string) = onUp key
member _.Platform: Platform =
{ FillRect = fun x y w h color -> calls.Add [ box "fillRect"; box x; box y; box w; box h; box color ]
FillText =
fun x y text color size align ->
calls.Add [ box "fillText"; box x; box y; box text; box color; box size; box align ]
RandomSeed =
fun () ->
calls.Add [ box "randomSeed" ]
seed
StartClock = fun cb -> onTime <- cb
OnKeyDown = fun cb -> onDown <- cb
OnKeyUp = fun cb -> onUp <- cb }
// What the platform should have been asked to draw for a scene.
let private expectedCalls (scene: Scene) : obj list list =
scene
|> List.map (fun shape ->
match shape with
| Rect(x, y, w, h, color) -> [ box "fillRect"; box x; box y; box w; box h; box color ]
| Text(x, y, text, color, size, align) -> [ box "fillText"; box x; box y; box text; box color; box size; box align ])
let private rect = Rect(1, 2, 3, 4, "#abcdef")
let private text = Text(5, 6, "hi", "#fff", 16, "center")
[<Tests>]
let tests =
testList "M09 Runtime" [
testCase "runIO of a pure value returns it without touching the platform" (fun () ->
let p = FakePlatform 42u
Expect.equal (Runtime.runIO p.Platform (IO.pure 7)) 7 "result"
Expect.equal p.Calls [] "calls")
testCase "runIO maps Draw Rect to FillRect x y w h color" (fun () ->
let p = FakePlatform 42u
Runtime.runIO p.Platform (IO.draw rect)
Expect.equal p.Calls [ [ box "fillRect"; box 1; box 2; box 3; box 4; box "#abcdef" ] ] "calls")
testCase "runIO maps Draw Text to FillText x y text color size align" (fun () ->
let p = FakePlatform 42u
Runtime.runIO p.Platform (IO.draw text)
Expect.equal p.Calls [ [ box "fillText"; box 5; box 6; box "hi"; box "#fff"; box 16; box "center" ] ] "calls")
testCase "runIO answers RandomSeed with platform.RandomSeed () and continues with it" (fun () ->
let p = FakePlatform 99u
Expect.equal (Runtime.runIO p.Platform (IO.randomSeed () |> IO.map (fun s -> s + 1u))) 100u "result"
Expect.equal p.Calls [ [ box "randomSeed" ] ] "calls")
testCase "runIO performs effects in order" (fun () ->
let p = FakePlatform 42u
Runtime.runIO p.Platform (IO.sequence [ IO.draw rect; IO.draw text; IO.draw rect ])
Expect.equal (p.Calls |> List.map List.head) [ box "fillRect"; box "fillText"; box "fillRect" ] "order")
testCase "runIO handles 100000 effects without overflowing the stack" (fun () ->
let p = FakePlatform 42u
let n = 100000
Runtime.runIO p.Platform (IO.sequence (List.replicate n (IO.draw rect)))
Expect.equal (List.length p.Calls) n "calls")
testCase "runApp asks the platform for a seed once, at start, and draws nothing before the first event" (fun () ->
let p = FakePlatform 42u
Runtime.runApp p.Platform (classicMain ())
Expect.equal p.Calls [ [ box "randomSeed" ] ] "calls")
testCase "runApp draws the initial scene on the first clock tick, then the ticked scene after 150 ms" (fun () ->
let p = FakePlatform 42u
Runtime.runApp p.Platform (classicMain ())
p.Clear()
p.FireTime 150L
Expect.equal
p.Calls
(expectedCalls (View.view (Spawn.newGame 42u)) @ expectedCalls (View.view (Spawn.tick (Spawn.newGame 42u))))
"calls")
testCase "runApp feeds key presses to the game" (fun () ->
let p = FakePlatform 42u
Runtime.runApp p.Platform (classicMain ())
p.FireTime 0L
p.Clear()
p.FireKey "ArrowUp"
p.FireTime 150L
let turned = { Spawn.newGame 42u with NextDir = Up }
Expect.equal p.Calls (expectedCalls (View.view turned) @ expectedCalls (View.view (Spawn.tick turned))) "calls")
testCase "runApp ignores key releases and unmapped keys (no redraw)" (fun () ->
let p = FakePlatform 42u
Runtime.runApp p.Platform (classicMain ())
p.FireTime 0L
p.Clear()
p.FireKeyUp "ArrowUp"
p.FireKey "x"
Expect.equal p.Calls [] "calls")
]
+51
View File
@@ -0,0 +1,51 @@
module Snake.Tests.M10Extensions
open Expecto
open Snake
open Snake.Model
open Snake.IO
open Snake.Stream
open Snake.Tests.Fixtures
open Snake.Tests.M09Helpers
let private frameTimes = [ 25L; 50L; 75L; 100L; 125L; 150L; 175L; 200L ]
[<Tests>]
let tests =
testList "M10 Extensions" [
yield! fixtureTests "m10-update" "update" (fun c ->
Expect.equal (Game.update (gameState (arg 0 c)) (action (arg 1 c))) (gameState (expected c)) "update")
yield! fixtureTests "m10-moves-every" "movesEvery" (fun c ->
Expect.equal (Game.movesEvery (num (arg 0 c))) (num (expected c)) "movesEvery")
yield! fixtureTests "m10-input" "keyToAction" (fun c ->
Expect.equal (Input.keyToAction (str (arg 0 c))) (opt action (expected c)) "keyToAction")
testCase "the animation clock runs every 25 ms" (fun () -> Expect.equal App.FRAME_MS 25L "FRAME_MS")
testCase "speedActions turns the clock into Frame actions every 25 ms" (fun () ->
Expect.equal (Stream.run (List.map Time frameTimes) App.speedActions) (List.replicate 8 Frame) "actions")
testCase "speedActions still turns keys into actions, including pause and restart" (fun () ->
Expect.equal
(Stream.run [ KeyDown " "; KeyDown "r"; KeyDown "ArrowUp" ] App.speedActions)
[ TogglePause; Restart; Turn Up ]
"actions")
testCase "main (seed 42) draws a moved snake after eight 25 ms frames" (fun () ->
let traced = IO.trace 42u (App.main ())
Expect.equal traced.Ops [ RandomSeed ] "ops"
let first, afterFirst = Stream.step (Time 0L) traced.Result
Expect.equal (drawn (List.head first)) (View.view (Spawn.newGame 42u)) "first drawing"
let drawings = Stream.run (List.map Time frameTimes) afterFirst
Expect.equal (List.length drawings) 8 "eight drawings"
let start = Spawn.newGame 42u
let moved =
{ start with
Snake = start.Snake |> List.map (fun p -> { p with X = p.X + 1 })
Frame = 8 }
Expect.equal (drawn drawings[7]) (View.view moved) "moved snake")
]
+5
View File
@@ -17,6 +17,11 @@
<Compile Include="M06View.fs" />
<Compile Include="M07IO.fs" />
<Compile Include="M08Stream.fs" />
<Compile Include="M09Helpers.fs" />
<Compile Include="M09Input.fs" />
<Compile Include="M09App.fs" />
<Compile Include="M09Runtime.fs" />
<Compile Include="M10Extensions.fs" />
<Compile Include="Purity.fs" />
<Compile Include="Main.fs" />
</ItemGroup>