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:
co-authored by
Claude Opus 5.5
parent
e27372f017
commit
55f8b0b30f
@@ -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")
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
]
|
||||
@@ -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")
|
||||
]
|
||||
@@ -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")
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user