7 Commits
Author SHA1 Message Date
MasseR a0030bf9c0 Test the bedroom spec 2026-08-25 11:54:38 +03:00
MasseR 7051eaf244 Test rest of the AFRP 2026-08-25 11:41:12 +03:00
MasseR 7a9f30e1be Tests with timers 2026-08-25 11:36:13 +03:00
MasseR aa8ea07249 Tests 2026-08-25 11:27:18 +03:00
MasseR 2664ca67c0 Debounce instead of delay 2026-08-25 10:47:38 +03:00
MasseR d341270ed1 rollup, sliding and fixed windows 2026-08-25 10:11:44 +03:00
MasseR dc55834507 Outdated comment 2026-08-21 16:21:08 +03:00
11 changed files with 836 additions and 29 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ mkDerivation {
executableHaskellDepends = [ base ]; executableHaskellDepends = [ base ];
testHaskellDepends = [ testHaskellDepends = [
aeson annotated-exception async base hedgehog hspec hspec-hedgehog aeson annotated-exception async base hedgehog hspec hspec-hedgehog
stm text time katip stm text time uuid
]; ];
license = lib.meta.getLicenseFromSpdxId "BSD-3-Clause"; license = lib.meta.getLicenseFromSpdxId "BSD-3-Clause";
mainProgram = "home-assistant-controller"; mainProgram = "home-assistant-controller";
+6 -2
View File
@@ -62,6 +62,7 @@ library
exposed-modules: AFRP exposed-modules: AFRP
, HomeAssistant.Controller , HomeAssistant.Controller
, HomeAssistant.Controller.Bedroom , HomeAssistant.Controller.Bedroom
, HomeAssistant.Controller.Ruuvi
, HomeAssistant.Runtime , HomeAssistant.Runtime
, HomeAssistant.Runtime.Bus , HomeAssistant.Runtime.Bus
, HomeAssistant.Runtime.Connection , HomeAssistant.Runtime.Connection
@@ -128,11 +129,14 @@ test-suite home-assistant-controller-test
default-language: GHC2024 default-language: GHC2024
-- Modules included in this executable, other than Main. -- Modules included in this executable, other than Main.
other-modules: BusSpec other-modules: AFRPSpec
, BackoffProp
, BedroomSpec
, BusSpec
, ConnectionSpec , ConnectionSpec
, RuntimeSpec , RuntimeSpec
, SupervisorSpec , SupervisorSpec
, BackoffProp , Support
-- LANGUAGE extensions used by modules in this package. -- LANGUAGE extensions used by modules in this package.
-- other-extensions: -- other-extensions:
+71 -5
View File
@@ -25,6 +25,10 @@ module AFRP
, isEvent , isEvent
, delayEvent , delayEvent
, sample , sample
, rollup
, sliding
, fixed
, debounce
) where ) where
import Control.Category (Category(..), (>>>)) import Control.Category (Category(..), (>>>))
@@ -34,6 +38,7 @@ import Data.Time (UTCTime, NominalDiffTime, diffUTCTime, addUTCTime)
import Control.Monad.Fix (MonadFix (mfix)) import Control.Monad.Fix (MonadFix (mfix))
import Data.Either (fromLeft) import Data.Either (fromLeft)
import Data.Bool (bool) import Data.Bool (bool)
import Data.Monoid (Endo(..))
import Data.UUID (UUID) import Data.UUID (UUID)
data Request = Request data Request = Request
@@ -89,7 +94,7 @@ instance Applicative (Mealy eff a) where
data Event a data Event a
= Tick = Tick
| Event a | Event a
deriving (Show, Functor, Foldable, Traversable) deriving (Show, Eq, Functor, Foldable, Traversable)
hold :: a -> Mealy eff (Event a) a hold :: a -> Mealy eff (Event a) a
hold a = Mealy $ \_ _ -> \case hold a = Mealy $ \_ _ -> \case
@@ -146,9 +151,9 @@ mapAccumRequest f x extract = go x
let next = f t b a let next = f t b a
in pure (extract next, go next) in pure (extract next, go next)
data DelayState a = DelayState data DelayState x a = DelayState
{ pending :: [(UTCTime, a)] { pending :: x
, output :: Event a , output :: !(Event a)
} }
@@ -174,6 +179,21 @@ delayEvent delay =
_ -> _ ->
DelayState queued Tick DelayState queued Tick
debounce :: NominalDiffTime -> Mealy eff (Event a) (Event a)
debounce delay =
mapAccumRequest step initial output
where
initial = DelayState Nothing Tick
step req st input =
let now = requestTime req
held = case input of
Tick -> pending st
Event x -> Just (delay `addUTCTime` now, x)
in case held of
Just (due, x)
| due <= now -> DelayState Nothing (Event x)
_ -> DelayState held Tick
changes :: Eq a => Mealy eff a (Event a) changes :: Eq a => Mealy eff a (Event a)
changes = mapAccum go Nothing (maybe Tick snd) changes = mapAccum go Nothing (maybe Tick snd)
where where
@@ -221,5 +241,51 @@ duration :: forall eff a. Mealy eff a NominalDiffTime
duration = mapAccumRequest go (Nothing @(UTCTime, NominalDiffTime)) (maybe 0 snd) duration = mapAccumRequest go (Nothing @(UTCTime, NominalDiffTime)) (maybe 0 snd)
where where
go :: Request -> Maybe (UTCTime, NominalDiffTime) -> a -> Maybe (UTCTime, NominalDiffTime) go :: Request -> Maybe (UTCTime, NominalDiffTime) -> a -> Maybe (UTCTime, NominalDiffTime)
go req Nothing _ = Just $ (requestTime req, requestTime req `diffUTCTime` requestTime req) go req Nothing _ = Just (requestTime req, requestTime req `diffUTCTime` requestTime req)
go req (Just (startTime, _)) _ = Just (startTime, requestTime req `diffUTCTime` startTime) go req (Just (startTime, _)) _ = Just (startTime, requestTime req `diffUTCTime` startTime)
-- | Rollup, hold back bursty messages
--
-- Consider a case where you have a bursty set of data. You care to get an immediate response,
-- but don't want to spam the output
rollup
:: Int -- ^ How many items to pass through before burst protection
-> Int -- ` How many seconds to collect the bursty data
-> Mealy eff (Event a) (Event [a])
rollup limit seconds = mapAccumRequest go (Left Tick) (either id (\(_, _, _, ev) -> ev))
where
e a = Endo ([a] ++)
go :: Request -> Either (Event [a]) (UTCTime, Int, Endo [a], Event [a]) -> Event a -> Either (Event [a]) (UTCTime, Int, Endo [a], Event [a])
go _ (Left _) Tick = Left Tick
go req (Left _) (Event a) = Right (addUTCTime (fromIntegral seconds) (requestTime req), 1, mempty, Event [a])
go req (Right (end, n, acc, _)) Tick
| requestTime req >= end = Left (Event $ appEndo acc [])
| otherwise = Right (end, n, acc, Tick)
go req (Right (end, n, acc, _)) (Event a)
| requestTime req >= end = Left (Event $ appEndo acc [a])
| n < limit = Right (end, n+1, acc, Event [a])
| otherwise = Right (end, n+1, acc <> e a, Tick)
-- Sliding window into the events
sliding :: Int -> Mealy eff (Event a) [a]
sliding size = mapAccum go [] id
where
go :: [a] -> Event a -> [a]
go acc Tick = acc
go acc (Event a) = let xs = acc ++ [a] in drop (max 0 (length xs - size)) xs
fixed :: Int -> Mealy eff (Event a) [a]
fixed seconds = mapAccumRequest go Nothing (maybe [] ((`appEndo` []) . snd))
where
e a = Endo ([a] ++)
go :: Request -> Maybe (UTCTime, Endo [a]) -> Event a -> Maybe (UTCTime, Endo [a])
go req Nothing Tick = Just (addUTCTime (fromIntegral seconds) (requestTime req), mempty)
go req Nothing (Event a) = Just (addUTCTime (fromIntegral seconds) (requestTime req), e a)
go req (Just (end, acc)) ev =
case ev of
Tick | requestTime req >= end -> Just (addUTCTime (fromIntegral seconds) end, mempty)
| otherwise -> Just (end, acc)
Event a | requestTime req >= end -> Just (addUTCTime (fromIntegral seconds) end, e a)
| otherwise -> Just (end, acc <> e a)
+1 -17
View File
@@ -14,10 +14,6 @@ module HomeAssistant.Controller
, entityRead' , entityRead'
, entityBool , entityBool
, entityBool' , entityBool'
, Ruuvi(..)
, ruuvi
, ruuviTemperatures
, ruuviPressures
, DoorState(..) , DoorState(..)
, light , light
, Presence(..) , Presence(..)
@@ -29,7 +25,7 @@ module HomeAssistant.Controller
, Target(..) , Target(..)
) where ) where
import AFRP (Mealy (..), eff, Event(..), hold, events, changes, filterA, (>>|), toEvent, Request) import AFRP (Mealy (..), eff, Event(..), events, filterA, (>>|), toEvent, Request)
import Control.Arrow (Arrow(..), returnA) import Control.Arrow (Arrow(..), returnA)
import Control.Category ((>>>)) import Control.Category ((>>>))
import Data.Aeson (Value) import Data.Aeson (Value)
@@ -75,18 +71,6 @@ traceValue = proc x -> do
eff Trace -< x eff Trace -< x
returnA -< x returnA -< x
ruuviTemperatures :: Mealy eff (Event Value) Double
ruuviTemperatures = entityRead @Double "sensor.ruuvitag_b168_temperature" >>> hold 0
ruuviPressures :: Mealy eff (Event Value) Double
ruuviPressures = entityRead "sensor.ruuvitag_b168_pressure" >>> hold 0
data Ruuvi = Ruuvi { ruuviTemperature :: Double, ruuviPressure :: Double }
deriving (Show, Eq)
ruuvi :: Mealy eff (Event Value) (Event Ruuvi)
ruuvi = (Ruuvi <$> ruuviTemperatures <*> ruuviPressures) >>> changes
data DoorState = Open | Closed data DoorState = Open | Closed
deriving (Show, Eq) deriving (Show, Eq)
+4 -3
View File
@@ -98,7 +98,7 @@ bedroomButtonController :: HASS (Event Value) ()
bedroomButtonController = proc x -> do bedroomButtonController = proc x -> do
ev <- bedroomButton >>> traceEvent -< x ev <- bedroomButton >>> traceEvent -< x
case ev of case ev of
Event (Masse (OnButton ShortRelease)) -> callService (activateScene "scene.makuuhuone_masse") -< () -- this should be on release Event (Masse (OnButton ShortRelease)) -> callService (activateScene "scene.makuuhuone_masse") -< ()
Event (Masse (OnButton DoubleClick)) -> callService (activateScene "scene.makuuhuone_keski") -< () Event (Masse (OnButton DoubleClick)) -> callService (activateScene "scene.makuuhuone_keski") -< ()
Event (Masse (OnButton LongClick)) -> callService (activateScene "scene.makuuhuone_kirkas") -< () Event (Masse (OnButton LongClick)) -> callService (activateScene "scene.makuuhuone_kirkas") -< ()
Event (Masse (OffButton _)) -> callService (light [AreaId "makuuhuone"] False) -< () Event (Masse (OffButton _)) -> callService (light [AreaId "makuuhuone"] False) -< ()
@@ -133,8 +133,9 @@ waitFor n = duration >>> arr (> n) >>> edge
delayedDoor :: HASS (Event Value) (Event DoorState) delayedDoor :: HASS (Event Value) (Event DoorState)
delayedDoor = door delayedDoor = door
>>> (AFRP.hold Open &&& AFRP.delayEvent 15) >>> AFRP.debounce 15
>>> AFRP.sample >>> AFRP.hold Open
>>> AFRP.changes
>>> traceEvent >>> traceEvent
humidifierController :: HASS (Event Value) () humidifierController :: HASS (Event Value) ()
+31
View File
@@ -0,0 +1,31 @@
{-# LANGUAGE Arrows #-}
{-# LANGUAGE OverloadedStrings #-}
module HomeAssistant.Controller.Ruuvi
( Ruuvi(..)
, ruuvi
, ruuviTemperatures
, ruuviPressures
, ruuviController
) where
import AFRP (Mealy, Event, hold, changes, rollup)
import Control.Category ((>>>))
import Data.Aeson (Value)
import HomeAssistant.Controller (entityRead, traceEvent, HASS)
import Control.Arrow (Arrow(..))
ruuviTemperatures :: Mealy eff (Event Value) Double
ruuviTemperatures = entityRead @Double "sensor.ruuvitag_b168_temperature" >>> hold 0
ruuviPressures :: Mealy eff (Event Value) Double
ruuviPressures = entityRead "sensor.ruuvitag_b168_pressure" >>> hold 0
data Ruuvi = Ruuvi { ruuviTemperature :: Double, ruuviPressure :: Double }
deriving (Show, Eq)
ruuvi :: Mealy eff (Event Value) (Event Ruuvi)
ruuvi = (Ruuvi <$> ruuviTemperatures <*> ruuviPressures) >>> changes
ruuviController :: HASS (Event Value) ()
ruuviController = ruuvi >>> rollup 1 30 >>> traceEvent >>> arr (const ())
+3 -1
View File
@@ -32,6 +32,7 @@ import qualified Data.UUID.V4 as UUID.V4
import Katip (runKatipT, logF, sl, Severity (..), ls, Namespace (Namespace), runKatipContextT) import Katip (runKatipT, logF, sl, Severity (..), ls, Namespace (Namespace), runKatipContextT)
import Control.Monad.IO.Class (liftIO, MonadIO) import Control.Monad.IO.Class (liftIO, MonadIO)
import Control.Monad.Fix (MonadFix) import Control.Monad.Fix (MonadFix)
import HomeAssistant.Controller.Ruuvi (ruuviController)
step :: (MonadFix m, MonadIO m) => (forall x. eff x -> m x) -> UUID -> Mealy eff a b -> a -> m (b, Mealy eff a b) step :: (MonadFix m, MonadIO m) => (forall x. eff x -> m x) -> UUID -> Mealy eff a b -> a -> m (b, Mealy eff a b)
step nt trace (Mealy f) a = do step nt trace (Mealy f) a = do
@@ -45,7 +46,8 @@ controllers =
[ Controller "bedroom-presence" bedroomPresenceController False [ Controller "bedroom-presence" bedroomPresenceController False
, Controller "bedroom-button" bedroomButtonController False -- This works but leaving for vacation , Controller "bedroom-button" bedroomButtonController False -- This works but leaving for vacation
, Controller "bedroom-drawer" bedroomDrawerController True , Controller "bedroom-drawer" bedroomDrawerController True
, Controller "bedroom-humidifier" humidifierController True , Controller "bedroom-humidifier" humidifierController False
, Controller "ruuvi-controller" ruuviController False
] ]
-- | Steps the machine for every inbound message; service calls go to the -- | Steps the machine for every inbound message; service calls go to the
+531
View File
@@ -0,0 +1,531 @@
module AFRPSpec (spec) where
import Control.Arrow (arr)
import Control.Category ((>>>))
import Control.Monad.Fix (MonadFix (..))
import Data.Foldable (for_)
import AFRP
import Data.Functor.Identity (Identity (..))
import Data.List (sort)
import Data.Time (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime)
import Data.UUID (nil)
import Hedgehog
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import Test.Hspec
import Test.Hspec.Hedgehog
fakeRequest :: Request
fakeRequest = Request (sec 0) nil
sec :: Integer -> UTCTime
sec n = UTCTime (toEnum 0) (fromIntegral n)
runPure :: Mealy Identity a b -> [a] -> [b]
runPure _ [] = []
runPure m (a : as) = case runIdentity (AFRP.runMealy m id fakeRequest a) of
(b, m') -> b : runPure m' as
-- | Run a Mealy with a per-step wall clock (seconds since the day-0 epoch).
runTimed :: Mealy Identity a b -> [(Integer, a)] -> [b]
runTimed _ [] = []
runTimed m ((s, a) : as) =
case runIdentity (AFRP.runMealy m id (Request (sec s) nil) a) of
(b, m') -> b : runTimed m' as
-- | A minimal State monad for observing effectful arrows (e.g. whenA gating).
newtype St a = St { unSt :: Int -> (a, Int) }
instance Functor St where
fmap f (St g) = St $ \s -> let (a, s') = g s in (f a, s')
instance Applicative St where
pure a = St (\s -> (a, s))
St f <*> St x = St $ \s -> let (f', s') = f s; (a, s'') = x s' in (f' a, s'')
instance Monad St where
St m >>= k = St $ \s -> let (a, s') = m s; (b, s'') = unSt (k a) s' in (b, s'')
instance MonadFix St where
mfix f = St $ \s -> let (a, s') = unSt (f a) s in (a, s')
runStEff :: Mealy St a b -> Int -> [a] -> ([b], Int)
runStEff m s0 as = go m s0 as
where
go _ s [] = ([], s)
go m' s (a : rest) =
case unSt (AFRP.runMealy m' id fakeRequest a) s of
((b, m''), s') -> let (bs, s'') = go m'' s' rest in (b : bs, s'')
spec :: Spec
spec = describe "AFRP" $ do
holdSpec
eventsSpec
isEventSpec
tagSpec
toEventSpec
lMergeSpec
changesSpec
edgeSpec
filterASpec
slidingSpec
mapAccumSpec
preMapAccumSpec
durationSpec
delayEventSpec
debounceSpec
rollupSpec
fixedSpec
effSpec
switchSpec
mapAccumRequestSpec
preMapAccumRequestSpec
whenASpec
thenASpec
sampleSpec
holdSpec :: Spec
holdSpec = describe "hold" $ do
it "holds initial value until an Event arrives" $
runPure (hold 'a') [Tick, Event 'b', Tick, Event 'c']
`shouldBe` ['a', 'b', 'b', 'c']
it "never changes on Tick" $
runPure (hold (0 :: Int)) (replicate 5 Tick) `shouldBe` replicate 5 (0 :: Int)
eventsSpec :: Spec
eventsSpec = describe "events" $ do
it "converts Tick to Left () and Event a to Right a" $
runPure events [Tick, Event 'a', Tick, Event 'b']
`shouldBe` [Left (), Right 'a', Left (), Right 'b']
isEventSpec :: Spec
isEventSpec = describe "isEvent" $ do
it "returns False for Tick" $
isEvent Tick `shouldBe` False
it "returns True for Event x" $
isEvent (Event ()) `shouldBe` True
tagSpec :: Spec
tagSpec = describe "tag" $ do
it "replaces value preserving structure" $ do
tag 'b' Tick `shouldBe` Tick
tag 'b' (Event 'a') `shouldBe` Event 'b'
toEventSpec :: Spec
toEventSpec = describe "toEvent" $ do
it "round-trips through events" $
runPure toEvent [Left (), Right 'a', Left ()]
`shouldBe` [Tick, Event 'a', Tick]
it "is inverse of events modulo Event/Either" $
runPure (events >>> toEvent) [Tick, Event 'a', Event 'b']
`shouldBe` [Tick, Event 'a', Event 'b']
lMergeSpec :: Spec
lMergeSpec = describe "lMerge" $ do
it "both Tick gives Tick" $
lMerge (Tick :: Event Int) (Tick :: Event Int) `shouldBe` Tick
it "prefers left Event" $
lMerge (Event (1 :: Int)) (Event (2 :: Int)) `shouldBe` Event (1 :: Int)
it "prefers right Event if left is Tick" $
lMerge Tick (Event (2 :: Int)) `shouldBe` Event (2 :: Int)
changesSpec :: Spec
changesSpec = describe "changes" $ do
it "first output is always Tick" $
runPure changes "hello" !! 0 `shouldBe` Tick
it "outputs Event only on value change" $
runPure changes "aaaabbbcca"
`shouldBe` [Tick, Tick, Tick, Tick
, Event 'b', Tick, Tick
, Event 'c', Tick
, Event 'a'
]
it "first output is Tick, subsequent outputs are Event iff value changed" $
hedgehog $ do
xs <- forAll $ Gen.list (Range.linear 0 100) Gen.alpha
let out = runPure changes xs
length out === length xs
case out of
[] -> pure ()
(Tick : rest) -> do
let triples = zip3 xs (drop 1 xs) rest
for_ triples $ \(prev, curr, o) ->
if prev /= curr
then o === Event curr
else o === Tick
_ -> failure
edgeSpec :: Spec
edgeSpec = describe "edge" $ do
it "emits Event () only on rising edge" $
runPure edge [False, True, True, False, True]
`shouldBe` [Tick, Event (), Tick, Tick, Event ()]
it "starts from False, so first True is a rising edge" $
runPure edge [True, False, True]
`shouldBe` [Event (), Tick, Event ()]
it "Event () only on False -> True transition" $
hedgehog $ do
bs <- forAll $ Gen.list (Range.linear 0 50) Gen.bool
let out = runPure edge bs
length out === length bs
case (bs, out) of
([], []) -> pure ()
(b : _, o : _) -> do
if b then o === Event () else o === Tick
let triples = zip3 bs (drop 1 bs) (drop 1 out)
for_ triples $ \(prev, curr, o') ->
if not prev && curr
then o' === Event ()
else o' === Tick
_ -> failure
filterASpec :: Spec
filterASpec = describe "filterA" $ do
it "lets through values matching predicate" $
runPure (filterA (even @Int)) [1, 2, 3, 4]
`shouldBe` [Left (), Right 2, Left (), Right 4]
it "output is Right a iff predicate holds" $
hedgehog $ do
threshold <- forAll $ Gen.int (Range.linear (-10) 10)
let p = (> threshold)
xs <- forAll $ Gen.list (Range.linear 0 50) (Gen.int (Range.linear (-20) 20))
let out = runPure (filterA p) xs
length out === length xs
for_ (zip xs out) $ \(x, o) ->
if p x
then o === Right x
else o === Left ()
slidingSpec :: Spec
slidingSpec = describe "sliding" $ do
it "accumulates events up to the window size" $
runPure (sliding (3 :: Int)) [Event (1 :: Int), Event 2, Event 3, Event 4]
`shouldBe` [[1], [1, 2], [1, 2, 3], [2, 3, 4]]
it "Ticks don't change the accumulator" $
runPure (sliding 2) [Event (1 :: Int), Tick, Event 2]
`shouldBe` [[1], [1], [1, 2]]
it "empty list stays empty" $
runPure (sliding (5 :: Int)) ([] :: [Event Int]) `shouldBe` []
it "output length never exceeds window size" $
hedgehog $ do
n <- forAll $ Gen.int (Range.constant 1 10)
evs <- forAll $ Gen.list (Range.linear 0 20) (Gen.frequency
[(3, Event <$> Gen.alpha), (1, pure Tick)])
let out = runPure (sliding n) evs
for_ out $ \xs -> assert (length xs <= n)
mapAccumSpec :: Spec
mapAccumSpec = describe "mapAccum" $ do
it "running sum" $
runPure (mapAccum (+) (0 :: Int) id) [1, 2, 3]
`shouldBe` [1, 3, 6]
it "post-state extraction: output uses state after applying f" $
runPure (mapAccum (\s x -> s ++ [x]) ([] :: [Int]) id) [1, 2, 3]
`shouldBe` [[1], [1, 2], [1, 2, 3]]
it "output equals running sum of all inputs so far" $
hedgehog $ do
xs <- forAll $ Gen.list (Range.linear 0 50) (Gen.int (Range.linear (-100) 100))
let out = runPure (mapAccum (+) (0 :: Int) id) xs
length out === length xs
for_ (zip3 [0 ..] xs out) $ \(i, _x, cur) ->
cur === sum (take (i + 1) xs)
preMapAccumSpec :: Spec
preMapAccumSpec = describe "preMapAccum" $ do
it "running sum with pre-state extraction" $
runPure (preMapAccum (+) (0 :: Int) id) [1, 2, 3]
`shouldBe` [0, 1, 3]
it "pre-state extraction: output uses state before applying f" $
runPure (preMapAccum (\s x -> s ++ [x]) ([] :: [Int]) id) [1, 2, 3]
`shouldBe` [[], [1], [1, 2]]
it "output equals running sum before current input" $
hedgehog $ do
xs <- forAll $ Gen.list (Range.linear 0 50) (Gen.int (Range.linear (-100) 100))
let out = runPure (preMapAccum (+) (0 :: Int) id) xs
length out === length xs
for_ (zip3 [0 ..] xs out) $ \(i, _x, cur) ->
cur === sum (take i xs)
durationSpec :: Spec
durationSpec = describe "duration" $ do
it "first sample is 0, then elapsed time since first sample" $
runTimed duration [(0, 'a'), (5, 'b'), (10, 'c')]
`shouldBe` [0, 5, 10]
it "measures from the first observation, not the most recent" $
runTimed duration [(2, 'a'), (3, 'b'), (7, 'c')]
`shouldBe` [0, 1, 5]
it "output i equals times[i] - times[0]" $
hedgehog $ do
secs <- forAll $ Gen.list (Range.linear 0 50) (Gen.int (Range.linear 0 1000))
let out = runTimed duration [(fromIntegral s, ()) | s <- secs]
length out === length secs
case secs of
[] -> pure ()
(t0 : _) -> for_ (zip secs out) $ \(s, d) ->
d === fromIntegral (s - t0)
delayEventSpec :: Spec
delayEventSpec = describe "delayEvent" $ do
let delay = 5 :: NominalDiffTime
it "emits a queued event once the delay has elapsed" $
runTimed (delayEvent delay)
[(0, Event 'a'), (3, Tick), (6, Tick)]
`shouldBe` [Tick, Tick, Event 'a']
it "preserves order when multiple events are queued" $
runTimed (delayEvent delay)
[(0, Event 'a'), (1, Event 'b'), (10, Tick), (12, Tick)]
`shouldBe` [Tick, Tick, Event 'a', Event 'b']
it "emits nothing on pure Tick input" $
runTimed (delayEvent delay) [(0, Tick :: Event Char), (10, Tick)]
`shouldBe` [Tick, Tick]
it "emits exactly one output Event per input Event" $
hedgehog $ do
evs <- forAll $ Gen.list (Range.linear 0 30) eventGen
let evs' = evs ++ replicate 3 Tick
n = length [() | Event _ <- evs]
out = runTimed (delayEvent (1 :: NominalDiffTime))
(zip [0, 2 ..] evs')
length [() | Event _ <- out] === n
debounceSpec :: Spec
debounceSpec = describe "debounce" $ do
let delay = 5 :: NominalDiffTime
it "fires the last event after the quiet period" $
runTimed (debounce delay)
[(0, Event 'a'), (3, Tick), (6, Tick)]
`shouldBe` [Tick, Tick, Event 'a']
it "a newer event before firing resets the timer" $
runTimed (debounce delay)
[(0, Event 'a'), (3, Event 'b'), (6, Tick), (8, Tick)]
`shouldBe` [Tick, Tick, Tick, Event 'b']
it "collapses a burst into a single emission" $
runTimed (debounce delay)
[(0, Event 'a'), (1, Event 'b'), (2, Event 'c'), (10, Tick)]
`shouldBe` [Tick, Tick, Tick, Event 'c']
it "emits no more output Events than input Events" $
hedgehog $ do
evs <- forAll $ Gen.list (Range.linear 0 30) eventGen
let nIn = length [() | Event _ <- evs]
out = runTimed (debounce (1 :: NominalDiffTime))
(zip [0, 2 ..] evs)
nOut = length [() | Event _ <- out]
assert (nOut <= nIn)
rollupSpec :: Spec
rollupSpec = describe "rollup" $ do
it "passes the first `limit` events through immediately, then bursts" $
runTimed (rollup 2 10)
[ (0, Event 'a'), (1, Event 'b')
, (2, Event 'c'), (3, Event 'd')
, (12, Tick)
]
`shouldBe` [ Event ['a'], Event ['b']
, Tick, Tick
, Event ['c', 'd']
]
it "flushes the accumulator when the window ends on a Tick" $
runTimed (rollup 1 10)
[(0, Event 'a'), (1, Event 'b'), (2, Tick), (12, Tick)]
`shouldBe` [Event ['a'], Tick, Tick, Event ['b']]
it "is idle (Tick) until the first Event" $
runTimed (rollup 2 10) [(0, Tick :: Event Char), (1, Tick)]
`shouldBe` [Tick, Tick]
it "every input Event appears exactly once across the outputs" $
hedgehog $ do
limit <- forAll $ Gen.int (Range.constant 1 5)
evs <- forAll $ Gen.list (Range.linear 0 30) eventGen
let evs' = evs ++ replicate 10 Tick
times = [0 ..]
out = runTimed (rollup limit 5) (zip times evs')
emitted = concat [xs | Event xs <- out]
sort emitted === sort [x | Event x <- evs]
fixedSpec :: Spec
fixedSpec = describe "fixed" $ do
it "accumulates events within a window and rolls over on expiry" $
runTimed (fixed 10)
[ (0, Event 'a'), (1, Event 'b'), (2, Tick)
, (12, Event 'c'), (13, Tick)
]
`shouldBe` [ ['a'], ['a', 'b'], ['a', 'b']
, ['c'], ['c']
]
it "starts a window even on a leading Tick" $
runTimed (fixed 10)
[ (0, Tick), (1, Event 'a')
, (12, Tick), (13, Tick)
, (25, Event 'b')
]
`shouldBe` [ [], ['a'], [], [], ['b'] ]
it "output is always the current window's accumulated list" $
hedgehog $ do
w <- forAll $ Gen.int (Range.constant 1 10)
evs <- forAll $ Gen.list (Range.linear 0 30) eventGen
let out = runTimed (fixed w) (zip [0 ..] evs)
expected =
[ [ x | Event x <- take (i - lo + 1) (drop lo evs) ]
| i <- [0 .. length evs - 1]
, let lo = (i `div` w) * w
]
out === expected
eventGen :: Gen (Event Char)
eventGen = Gen.frequency
[ (3, Event <$> Gen.alpha)
, (1, pure Tick)
]
effSpec :: Spec
effSpec = describe "eff" $ do
it "lifts a pure effect function into a stateless Mealy" $
runPure (eff (\_ x -> Identity (x + 1))) [1, 2, 3]
`shouldBe` [2, 3, 4]
it "output equals f(input) for every step" $
hedgehog $ do
xs <- forAll $ Gen.list (Range.linear 0 50) (Gen.int (Range.linear (-100) 100))
let out = runPure (eff (\_ x -> Identity (x * 2))) xs
out === map (* 2) xs
switchSpec :: Spec
switchSpec = describe "switch" $ do
it "switches to the continuation at the first Event" $
runPure (switch (arr (\x -> (x, if x >= 3 then Event () else Tick)))
(const (arr (const 99))))
[1, 2, 3, 4, 5]
`shouldBe` [1, 2, 99, 99, 99]
it "never switches if no Event is emitted" $
runPure (switch (arr (\x -> (x, Tick :: Event ())))
(const (arr (const 99))))
[1, 2, 3]
`shouldBe` [1, 2, 3]
it "prefix outputs come from the first arrow, suffix from the continuation" $
hedgehog $ do
threshold <- forAll $ Gen.int (Range.linear (-20) 20)
xs <- forAll $ Gen.list (Range.linear 0 30) (Gen.int (Range.linear (-20) 20))
let first = arr (\x -> (x, if x >= threshold then Event () else Tick))
out = runPure (switch first (const (arr (const 99)))) xs
(pre, _post) = break (>= threshold) xs
take (length pre) out === pre
drop (length pre) out === replicate (length xs - length pre) 99
mapAccumRequestSpec :: Spec
mapAccumRequestSpec = describe "mapAccumRequest" $ do
it "accumulates request times, post-state extraction" $
runTimed (mapAccumRequest (\req s _ -> s ++ [requestTime req]) [] id)
[(0, 'a'), (5, 'b'), (10, 'c')]
`shouldBe` [ [sec 0], [sec 0, sec 5], [sec 0, sec 5, sec 10] ]
it "output i is every request time seen so far" $
hedgehog $ do
secs' <- forAll $ Gen.list (Range.linear 0 30) (Gen.int (Range.linear 0 100))
let out = runTimed (mapAccumRequest (\req s _ -> s ++ [requestTime req]) [] id)
[(fromIntegral s, ()) | s <- secs']
expected = [ map (sec . fromIntegral) (take (i + 1) secs') | i <- [0 .. length secs' - 1] ]
out === expected
preMapAccumRequestSpec :: Spec
preMapAccumRequestSpec = describe "preMapAccumRequest" $ do
it "accumulates request times, pre-state extraction" $
runTimed (preMapAccumRequest (\req s _ -> s ++ [requestTime req]) [] id)
[(0, 'a'), (5, 'b'), (10, 'c')]
`shouldBe` [ [], [sec 0], [sec 0, sec 5] ]
it "output i is every request time before the current step" $
hedgehog $ do
secs' <- forAll $ Gen.list (Range.linear 0 30) (Gen.int (Range.linear 0 100))
let out = runTimed (preMapAccumRequest (\req s _ -> s ++ [requestTime req]) [] id)
[(fromIntegral s, ()) | s <- secs']
expected = [ map (sec . fromIntegral) (take i secs') | i <- [0 .. length secs' - 1] ]
out === expected
whenASpec :: Spec
whenASpec = describe "whenA" $ do
let counter = eff (\_ (_ :: Int) -> St (\s -> ((), s + 1)))
it "output is always () regardless of the predicate" $
fst (runStEff (whenA (> 5) counter) 0 [1, 6, 2, 7])
`shouldBe` [(), (), (), ()]
it "runs the inner arrow only when the predicate holds" $
snd (runStEff (whenA (> 5) counter) 0 [1, 6, 2, 7])
`shouldBe` 2
it "never runs the inner arrow when the predicate is always false" $
snd (runStEff (whenA (const False) counter) 0 [1, 6, 2, 7])
`shouldBe` 0
it "runs the inner arrow on every input when the predicate is always true" $
snd (runStEff (whenA (const True) counter) 0 [1, 6, 2, 7])
`shouldBe` 4
thenASpec :: Spec
thenASpec = describe "thenA" $ do
it "short-circuits on Left and continues on Right" $
runPure (filterA (even @Int) `thenA` filterA (> 3)) [1..6]
`shouldBe` [Left (), Left (), Left (), Right 4, Left (), Right 6]
it "(>>|) is an infix alias for thenA" $
runPure (filterA (even @Int) >>| filterA (> 3)) [1..6]
`shouldBe` [Left (), Left (), Left (), Right 4, Left (), Right 6]
it "second stage runs only when the first produces Right" $
hedgehog $ do
xs <- forAll $ Gen.list (Range.linear 0 30) (Gen.int (Range.linear (-20) 20))
let out = runPure (filterA (even @Int) >>| filterA (> 0)) xs
expected =
[ if not (even x) then Left ()
else if x > 0 then Right x
else Left ()
| x <- xs ]
out === expected
sampleSpec :: Spec
sampleSpec = describe "sample" $ do
it "tags the current value onto the Event structure" $
runPure sample [(1, Tick), (2, Event 'a'), (3, Tick)]
`shouldBe` [Tick, Event 2, Tick]
it "output is Event a iff the input event is present" $
hedgehog $ do
vals <- forAll $ Gen.list (Range.linear 0 30) (Gen.int (Range.linear 0 100))
evs <- forAll $ Gen.list (Range.linear 0 30) eventGen
let n = min (length vals) (length evs)
ps = zip (take n vals) (take n evs)
out = runPure sample ps
for_ (zip ps out) $ \((v, ev), o) ->
o === tag v ev
+103
View File
@@ -0,0 +1,103 @@
{-# LANGUAGE OverloadedStrings #-}
module BedroomSpec (spec) where
import AFRP (Event (..))
import qualified Data.Text as T
import Data.Aeson (Value)
import HomeAssistant.Controller
import HomeAssistant.Controller.Bedroom
import Support
import Test.Hspec
masseButton :: T.Text -> Event Value
masseButton = Event . buttonEvent "event.bedroom_quick_remote_masse_action"
enishenButton :: T.Text -> Event Value
enishenButton = Event . buttonEvent "event.bedroom_quick_jemina_action"
drawerState :: T.Text -> Event Value
drawerState = Event . stateEvent "binary_sensor.bedroom_nightstand_drawer_sensor_masse_contact"
spec :: Spec
spec = describe "Bedroom" $ do
drawerSpec
buttonSpec
drawerSpec :: Spec
drawerSpec = describe "bedroomDrawerController" $ do
let entity = EntityId "switch.bedroom_drawer_light_masse"
it "turns the drawer light on when the drawer opens" $
services (runHASS bedroomDrawerController [drawerState "on"])
`shouldBe` [[switch [entity] True]]
it "turns the drawer light off when the drawer closes" $
services (runHASS bedroomDrawerController [drawerState "off"])
`shouldBe` [[switch [entity] False]]
it "toggles the light as the drawer opens and closes" $
services (runHASS bedroomDrawerController
[ drawerState "on", drawerState "off", drawerState "on" ])
`shouldBe` [ [switch [entity] True]
, [switch [entity] False]
, [switch [entity] True]
]
it "ignores state events for other entities" $
services (runHASS bedroomDrawerController
[Event (stateEvent "binary_sensor.some_other_contact" "on")])
`shouldBe` [[]]
it "does nothing on Tick" $
services (runHASS bedroomDrawerController [Tick])
`shouldBe` [[]]
buttonSpec :: Spec
buttonSpec = describe "bedroomButtonController" $ do
let area = AreaId "makuuhuone"
it "Masse single click turns on his nightstand scene (lowest)" $
services (runHASS bedroomButtonController [masseButton "1_short_release"])
`shouldBe` [[activateScene "scene.makuuhuone_masse"]]
it "Masse double click turns on the middle scene" $
services (runHASS bedroomButtonController [masseButton "1_double_press"])
`shouldBe` [[activateScene "scene.makuuhuone_keski"]]
it "Masse long click turns on the bright scene" $
services (runHASS bedroomButtonController [masseButton "1_long_press"])
`shouldBe` [[activateScene "scene.makuuhuone_kirkas"]]
it "Masse off click turns the bedroom lights off" $
services (runHASS bedroomButtonController [masseButton "2_short_release"])
`shouldBe` [[light [area] False]]
it "Enishen single click turns on her nightstand scene (lowest)" $
services (runHASS bedroomButtonController [enishenButton "1_short_release"])
`shouldBe` [[activateScene "scene.makuuhuone_jemina"]]
it "Enishen double click turns on the middle scene" $
services (runHASS bedroomButtonController [enishenButton "1_double_press"])
`shouldBe` [[activateScene "scene.makuuhuone_keski"]]
it "Enishen long click turns on the bright scene" $
services (runHASS bedroomButtonController [enishenButton "1_long_press"])
`shouldBe` [[activateScene "scene.makuuhuone_kirkas"]]
it "Enishen off click turns the bedroom lights off" $
services (runHASS bedroomButtonController [enishenButton "2_short_release"])
`shouldBe` [[light [area] False]]
it "ignores the initial press (scene only fires on release)" $
services (runHASS bedroomButtonController [masseButton "1_initial_press"])
`shouldBe` [[]]
it "ignores button events for other entities" $
services (runHASS bedroomButtonController
[Event (buttonEvent "event.some_other_action" "1_short_release")])
`shouldBe` [[]]
it "does nothing on Tick" $
services (runHASS bedroomButtonController [Tick])
`shouldBe` [[]]
+4
View File
@@ -1,7 +1,9 @@
module Main (main) where module Main (main) where
import Test.Hspec (hspec) import Test.Hspec (hspec)
import qualified AFRPSpec
import qualified BackoffProp import qualified BackoffProp
import qualified BedroomSpec
import qualified BusSpec import qualified BusSpec
import qualified ConnectionSpec import qualified ConnectionSpec
import qualified RuntimeSpec import qualified RuntimeSpec
@@ -9,6 +11,8 @@ import qualified SupervisorSpec
main :: IO () main :: IO ()
main = hspec $ do main = hspec $ do
AFRPSpec.spec
BedroomSpec.spec
BusSpec.spec BusSpec.spec
ConnectionSpec.spec ConnectionSpec.spec
RuntimeSpec.spec RuntimeSpec.spec
+81
View File
@@ -0,0 +1,81 @@
{-# LANGUAGE OverloadedStrings #-}
module Support
( Acc(..)
, interp
, runHASS
, services
, fakeRequest
, sec
, stateEvent
, buttonEvent
) where
import Control.Monad.Fix (MonadFix (..))
import Data.Aeson (Value, object, (.=))
import qualified Data.Text as T
import Data.Time (UTCTime (..))
import Data.UUID (nil)
import AFRP (Mealy (..), Request (..))
import HomeAssistant.Controller (HASSEff (..), Service)
fakeRequest :: Request
fakeRequest = Request (sec 0) nil
sec :: Integer -> UTCTime
sec n = UTCTime (toEnum 0) (fromIntegral n)
-- | A pure Writer-like monad accumulating `Service` calls per step.
newtype Acc a = Acc { runAcc :: [Service] -> (a, [Service]) }
instance Functor Acc where
fmap f (Acc g) = Acc $ \s -> let (a, s') = g s in (f a, s')
instance Applicative Acc where
pure a = Acc (\s -> (a, s))
Acc f <*> Acc x = Acc $ \s -> let (f', s') = f s; (a, s'') = x s' in (f' a, s'')
instance Monad Acc where
Acc m >>= k = Acc $ \s -> let (a, s') = m s; (b, s'') = runAcc (k a) s' in (b, s'')
instance MonadFix Acc where
mfix f = Acc $ \s -> let (a, s') = runAcc (f a) s in (a, s')
-- | Interpret `HASSEff` in `Acc`: record `CallService`, drop tracing/debug.
interp :: HASSEff a -> Acc a
interp (CallService _ svc) = Acc $ \s -> ((), s ++ [svc])
interp (Debug _) = pure ()
interp (Trace _ _) = pure ()
-- | Run a HASS arrow over a list of inputs, collecting per-step emitted services.
runHASS :: Mealy HASSEff a b -> [a] -> [(b, [Service])]
runHASS _ [] = []
runHASS m (a : as) =
case runAcc (runMealy m interp fakeRequest a) [] of
((b, m'), svcs) -> (b, svcs) : runHASS m' as
services :: [(b, [Service])] -> [[Service]]
services = map snd
-- | Build a state-change event payload matching `entityChangeEvent'` / `entityBool'` lenses.
stateEvent :: T.Text -> T.Text -> Value
stateEvent entityId state = object
[ "event" .= object
[ "data" .= object
[ "entity_id" .= entityId
, "new_state" .= object [ "state" .= state ]
]
]
]
-- | Build an Ikea button event payload matching `ikeaQuickButton` lenses.
buttonEvent :: T.Text -> T.Text -> Value
buttonEvent entityId eventType = object
[ "event" .= object
[ "data" .= object
[ "entity_id" .= entityId
, "new_state" .= object
[ "attributes" .= object [ "event_type" .= eventType ] ]
]
]
]