Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0030bf9c0 | ||
|
|
7051eaf244 | ||
|
|
7a9f30e1be | ||
|
|
aa8ea07249 | ||
|
|
2664ca67c0 | ||
|
|
d341270ed1 | ||
|
|
dc55834507 |
+1
-1
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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) ()
|
||||||
|
|||||||
@@ -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 ())
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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` [[]]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ] ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user