Merge pull request #3 from svenvanheugten/make-fake-home-independent

Make FakeHome independent of NightLightStateMachine
This commit is contained in:
Sven van Heugten 2026-01-04 20:23:55 +01:00 committed by GitHub
commit 2fb5bde636
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 99 additions and 68 deletions

View file

@ -1,22 +1,29 @@
namespace NightLight.Core.Tests namespace NightLight.Core.Tests
open System open System
open FsCheck
open FsCheck.FSharp open FsCheck.FSharp
open NightLight.Core.Models open NightLight.Core.Models
type Arbitraries = type Arbitraries =
static member Interaction() = static member Interactions() : Arbitrary<Interaction list> =
let genTimeChangedInteraction = gen {
gen { let genTimeChangedInteraction =
let! time = ArbMap.defaults |> ArbMap.generate<DateTime> gen {
return Interaction.TimeChanged time let! time = ArbMap.defaults |> ArbMap.generate<DateTime>
} return Interaction.TimeChanged time
}
let genHumanInteraction = let genHumanInteraction =
gen { gen {
let! light = Gen.elements lights let! light = Gen.elements lights
let! humanInteraction = Gen.elements [ LightTurnedOn light; LightTurnedOff light ] let! humanInteraction = Gen.elements [ LightTurnedOn light; LightTurnedOff light ]
return Interaction.HumanInteraction humanInteraction return Interaction.HumanInteraction humanInteraction
} }
Gen.oneof [ genTimeChangedInteraction; genHumanInteraction ] |> Arb.fromGen let! initialTimeChangedInteraction = genTimeChangedInteraction
let! remainingInteractions = Gen.oneof [ genTimeChangedInteraction; genHumanInteraction ] |> Gen.listOf
return initialTimeChangedInteraction :: remainingInteractions
}
|> Arb.fromGen

View file

@ -3,7 +3,6 @@ namespace NightLight.Core.Tests
open System open System
open System.Text.RegularExpressions open System.Text.RegularExpressions
open NightLight.Core.Models open NightLight.Core.Models
open NightLight.Core.Core
open FsToolkit.ErrorHandling open FsToolkit.ErrorHandling
open FSharp.Data open FSharp.Data
@ -43,22 +42,20 @@ type FakeLight(light: Light) =
if hasPower then if hasPower then
color <- newColor color <- newColor
type FakeHome(now: DateTime) = type FakeHome() =
let mutable time = now
let mutable nightLightStateMachine = NightLightStateMachine now
let assertIsOkAndGet result =
match result with
| Ok value -> value
| Error error -> failwith $"Expected Ok, got Error {error}"
let friendlyNameToFakeLight = let friendlyNameToFakeLight =
lights lights
|> Seq.map (fun light -> light.FriendlyName, FakeLight light) |> Seq.map (fun light -> light.FriendlyName, FakeLight light)
|> Map.ofSeq |> Map.ofSeq
let processCommand command = let onEventPublished = new Event<Event>()
member _.LightStates = friendlyNameToFakeLight.Values |> Seq.map _.LightWithState
[<CLIEvent>]
member _.OnEventPublished = onEventPublished.Publish
member _.ProcessCommand(command: Message) =
option { option {
let! friendlyName = let! friendlyName =
let m = Regex.Match(command.Topic, "^zigbee2mqtt/(.+)/set$") let m = Regex.Match(command.Topic, "^zigbee2mqtt/(.+)/set$")
@ -88,17 +85,6 @@ type FakeHome(now: DateTime) =
} }
|> ignore |> ignore
let sendEvent event =
let newState, commands =
event |> nightLightStateMachine.OnEventReceived |> assertIsOkAndGet
commands |> Seq.iter processCommand
nightLightStateMachine <- newState
member _.Time = time
member _.LightStates = friendlyNameToFakeLight.Values |> Seq.map _.LightWithState
member _.Interact(interaction: Interaction) = member _.Interact(interaction: Interaction) =
match interaction with match interaction with
| HumanInteraction(LightTurnedOn light) -> | HumanInteraction(LightTurnedOn light) ->
@ -111,11 +97,9 @@ type FakeHome(now: DateTime) =
""data"": {{ ""friendly_name"": ""{light.FriendlyName}"" }} ""data"": {{ ""friendly_name"": ""{light.FriendlyName}"" }}
}}" } }}" }
|> ReceivedZigbeeEvent |> ReceivedZigbeeEvent
|> sendEvent |> onEventPublished.Trigger
| HumanInteraction(LightTurnedOff light) -> friendlyNameToFakeLight[light.FriendlyName].TurnOff() | HumanInteraction(LightTurnedOff light) -> friendlyNameToFakeLight[light.FriendlyName].TurnOff()
| TimeChanged newTime -> | TimeChanged newTime -> newTime |> Event.TimeChanged |> onEventPublished.Trigger
time <- newTime
newTime |> Event.TimeChanged |> sendEvent
type FakeHome with type FakeHome with
member this.Interact(interactions: Interaction seq) = interactions |> Seq.iter this.Interact member this.Interact(interactions: Interaction seq) = interactions |> Seq.iter this.Interact
@ -127,9 +111,3 @@ type FakeHome with
| On(brightness, color) -> Some(light, brightness, color) | On(brightness, color) -> Some(light, brightness, color)
| Off -> None) | Off -> None)
|> Seq.forall condition |> Seq.forall condition
member this.IsDay() =
this.Time.TimeOfDay >= TimeSpan.FromHours 5.5
&& this.Time.TimeOfDay < TimeSpan.FromHours 20.5
member this.IsNight() = not (this.IsDay())

View file

@ -1,29 +1,63 @@
namespace NightLight.Core.Tests namespace NightLight.Core.Tests
open System open System
open NightLight.Core.Core
open FsCheck.Xunit open FsCheck.Xunit
open FsCheck.FSharp open FsCheck.FSharp
module InteractionsHelpers =
let getTimeAfter interactions =
interactions
|> Seq.choose (fun interaction ->
match interaction with
| TimeChanged time -> Some time
| _ -> None)
|> Seq.tryLast
|> function
| Some time -> time
| None -> failwith "Time wasn't changed"
let isDayAfter interactions =
let time = getTimeAfter interactions
time.TimeOfDay >= TimeSpan.FromHours 5.5
&& time.TimeOfDay < TimeSpan.FromHours 20.5
let isNightAfter = not << isDayAfter
[<Properties(Arbitrary = [| typeof<Arbitraries> |])>] [<Properties(Arbitrary = [| typeof<Arbitraries> |])>]
type NightLightTests() = type NightLightTests() =
[<Property>] let createFakeHomeWithNightLightAndInteract (interactions: Interaction list) =
let ``Brightness should always be under 255`` (now: DateTime) (interactions: Interaction list) = let mutable nightLightStateMachine = NightLightStateMachine()
let fakeHome = FakeHome now
let fakeHome = FakeHome()
fakeHome.OnEventPublished.Add(fun event ->
match event |> nightLightStateMachine.OnEventReceived with
| Ok(newState, commands) ->
commands |> Seq.iter fakeHome.ProcessCommand
nightLightStateMachine <- newState
| Error error -> failwith $"Unexpected error {error}")
fakeHome.Interact interactions fakeHome.Interact interactions
fakeHome
[<Property>]
let ``Brightness should always be under 255`` (interactions: Interaction list) =
let fakeHome = createFakeHomeWithNightLightAndInteract interactions
fakeHome.ForAllLightsThatAreOn(fun (_, brightness, _) -> brightness < 255uy) fakeHome.ForAllLightsThatAreOn(fun (_, brightness, _) -> brightness < 255uy)
[<Property>] [<Property>]
let ``Lights should be red during the night`` (now: DateTime) (interactions: Interaction list) = let ``Lights should be red during the night`` (interactions: Interaction list) =
let fakeHome = FakeHome now let fakeHome = createFakeHomeWithNightLightAndInteract interactions
fakeHome.Interact interactions
fakeHome.IsNight() InteractionsHelpers.isNightAfter interactions
==> fakeHome.ForAllLightsThatAreOn(fun (_, _, color) -> color = Red) ==> fakeHome.ForAllLightsThatAreOn(fun (_, _, color) -> color = Red)
[<Property>] [<Property>]
let ``Lights should be white or yellow during the day`` (now: DateTime) (interactions: Interaction list) = let ``Lights should be white or yellow during the day`` (interactions: Interaction list) =
let fakeHome = FakeHome now let fakeHome = createFakeHomeWithNightLightAndInteract interactions
fakeHome.Interact interactions
fakeHome.IsDay() InteractionsHelpers.isDayAfter interactions
==> fakeHome.ForAllLightsThatAreOn(fun (_, _, color) -> color = White || color = Yellow) ==> fakeHome.ForAllLightsThatAreOn(fun (_, _, color) -> color = White || color = Yellow)

View file

@ -17,7 +17,9 @@ type ParseZigbeeEventError =
| InvalidFriendlyNameField | InvalidFriendlyNameField
| UnknownType | UnknownType
type ParseEventError = ParseZigbeeEventError of ParseZigbeeEventError type OnEventReceivedError =
| ParseZigbeeEventError of ParseZigbeeEventError
| TimeIsUnknown
type Room = type Room =
| Bathroom | Bathroom

View file

@ -17,13 +17,15 @@ let internal generateZigbeeCommandToFixLight partOfDay light =
generateZigbeeCommand light.FriendlyName color brightness generateZigbeeCommand light.FriendlyName color brightness
type NightLightStateMachine(time: DateTime) = type NightLightStateMachine private (maybeTime: DateTime option) =
member this.OnEventReceived(event: Event) : Result<NightLightStateMachine * Message seq, ParseEventError> = new() = NightLightStateMachine None
result {
let partOfDay = getPartOfDay time
match event with member this.OnEventReceived(event: Event) : Result<NightLightStateMachine * Message seq, OnEventReceivedError> =
| ReceivedZigbeeEvent payload -> result {
let maybePartOfDay = maybeTime |> Option.map getPartOfDay
match event, maybePartOfDay with
| ReceivedZigbeeEvent payload, Some partOfDay ->
let! zigbeeEvent = parseZigbeeEvent payload |> Result.mapError ParseZigbeeEventError let! zigbeeEvent = parseZigbeeEvent payload |> Result.mapError ParseZigbeeEventError
return return
@ -35,14 +37,15 @@ type NightLightStateMachine(time: DateTime) =
match maybeLight with match maybeLight with
| Some light -> generateZigbeeCommandToFixLight partOfDay light |> Seq.singleton | Some light -> generateZigbeeCommandToFixLight partOfDay light |> Seq.singleton
| None -> Seq.empty | None -> Seq.empty
| TimeChanged newTime -> | TimeChanged newTime, maybePartOfDay ->
let newState = NightLightStateMachine newTime let newState = NightLightStateMachine(Some newTime)
let newPartOfDay = getPartOfDay newTime let newPartOfDay = getPartOfDay newTime
return return
newState, newState,
if partOfDay <> newPartOfDay then if maybePartOfDay <> Some newPartOfDay then
lights |> Seq.map (generateZigbeeCommandToFixLight newPartOfDay) lights |> Seq.map (generateZigbeeCommandToFixLight newPartOfDay)
else else
Seq.empty Seq.empty
| _, None -> return! Error TimeIsUnknown
} }

View file

@ -77,7 +77,14 @@ let mainAsync _ =
let mqttClientOptions = MqttClientOptionsBuilder().WithTcpServer(server).Build() let mqttClientOptions = MqttClientOptionsBuilder().WithTcpServer(server).Build()
let stateLock = new SemaphoreSlim(1, 1) let stateLock = new SemaphoreSlim(1, 1)
let mutable state = NightLightStateMachine DateTime.Now
let! initialState =
let emptyNightLightStateMachine = NightLightStateMachine()
TimeChanged DateTime.Now
|> handleEvent mqttClient logger emptyNightLightStateMachine
let mutable state = initialState
mqttClient.add_ApplicationMessageReceivedAsync (fun e -> mqttClient.add_ApplicationMessageReceivedAsync (fun e ->
async { async {