reflex-vty-1.3.0.0: test/Reflex/Vty/Widget/LayoutSpec.hs
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Description: Pure layout-solver allocation semantics.
--
-- Expectations are independent literals and invariant properties; the
-- production allocation algorithm is never re-implemented here.
module Reflex.Vty.Widget.LayoutSpec (spec) where
import Control.Monad (forM_, replicateM)
import qualified Data.Map.Ordered as OM
import Test.Hspec
import Test.QuickCheck (Gen, choose, conjoin, counterexample, forAll, ioProperty, listOf, oneof, scale)
import Control.Monad.NodeId (NodeId, getNextNodeId, runNodeIdT)
import Reflex.Vty.Test.Generators (runPlanProperty)
import Reflex.Vty.Widget (Region (..))
import Reflex.Vty.Widget.Layout
-- | A row/column forest from main-axis constraints, using fresh node
-- identifiers from a real allocator. Returns the identifiers in
-- insertion order so tests can extract chunks by key (the underlying
-- map's traversal order is construction-dependent and must not be
-- relied on).
mkForest :: [Constraint] -> IO ([NodeId], LayoutForest (Constraint, Orientation))
mkForest cs = do
ids <- runNodeIdT (replicateM (length cs) getNextNodeId)
pure
( ids
, fromListLF
[ (nid, LayoutTree (c, Orientation_Row) mempty)
| (nid, c) <- zip ids cs
]
)
-- | The solved (offset, size) pairs of the direct children, in the
-- given key order.
childChunksIn
:: Orientation
-> Region
-> [NodeId]
-> LayoutTree (Region, Orientation)
-> [(Int, Int)]
childChunksIn o parent ids = chunksInForest o parent ids . childrenLT
chunksInForest
:: Orientation
-> Region
-> [NodeId]
-> LayoutForest (Region, Orientation)
-> [(Int, Int)]
chunksInForest o parent ids f =
[ mainAxis o (fst (rootLT child))
| i <- ids
, Just child <- [lookupLF i f]
]
where
mainAxis orient r = case orient of
Orientation_Row -> (_region_left r - _region_left parent, _region_width r)
Orientation_Column -> (_region_top r - _region_top parent, _region_height r)
sizesOf :: [(Int, Int)] -> [Int]
sizesOf = map snd
spec :: Spec
spec = describe "Reflex.Vty.Widget.Layout (pure solver)" $ do
describe "solve" $ do
it "row width 12 with fixed 5, min 3, min 3 allocates 5/4/3" $ do
(ids, forest) <- mkForest [Constraint_Fixed 5, Constraint_Min 3, Constraint_Min 3]
let tree = solve Orientation_Row (Region 0 0 12 1) forest
childChunksIn Orientation_Row (Region 0 0 12 1) ids tree `shouldBe` [(0, 5), (5, 4), (9, 3)]
it "fixed-only children keep their sizes and ignore leftover space" $ do
(ids, forest) <- mkForest [Constraint_Fixed 2, Constraint_Fixed 7]
let tree = solve Orientation_Row (Region 0 0 20 1) forest
childChunksIn Orientation_Row (Region 0 0 20 1) ids tree `shouldBe` [(0, 2), (2, 7)]
it "an empty forest yields the root region with no children" $ do
(_, forest) <- mkForest []
let r = Region 3 4 10 10
tree = solve Orientation_Column r forest
rootLT tree `shouldBe` (r, Orientation_Column)
length (OM.assocs (unLayoutForest (childrenLT tree))) `shouldBe` 0
it "zero extent overflows deliberately with minimum sizes intact" $ do
(ids, forest) <- mkForest [Constraint_Fixed 2, Constraint_Min 1]
let tree = solve Orientation_Row (Region 0 0 0 1) forest
sizesOf (childChunksIn Orientation_Row (Region 0 0 0 1) ids tree) `shouldBe` [2, 1]
it "distributes remainders to the earliest stretchable children" $ do
(ids, forest) <- mkForest [Constraint_Min 0, Constraint_Min 0, Constraint_Min 0]
let tree = solve Orientation_Row (Region 0 0 10 1) forest
childChunksIn Orientation_Row (Region 0 0 10 1) ids tree `shouldBe` [(0, 4), (4, 3), (7, 3)]
it "nests orientations: a row child's column chunks its width" $ do
(innerIds, innerForest) <- mkForest [Constraint_Min 1, Constraint_Min 1]
[innerKey, lastKey] <- runNodeIdT (replicateM 2 getNextNodeId)
let inner = LayoutTree (Constraint_Fixed 6, Orientation_Column) innerForest
forest = fromListLF [(innerKey, inner), (lastKey, LayoutTree (Constraint_Min 0, Orientation_Row) mempty)]
tree = solve Orientation_Row (Region 0 0 10 5) forest
[rowChild0, rowChild1] = OM.assocs (unLayoutForest (childrenLT tree))
-- The fixed 6-wide first child gets the column layout inside it.
case lookupLF innerKey (childrenLT tree) of
Just (LayoutTree (r0, _) innerSolved) -> do
_region_left r0 `shouldBe` 0
_region_width r0 `shouldBe` 6
chunksInForest Orientation_Column r0 innerIds innerSolved
`shouldBe` [(0, 3), (3, 2)]
_ -> expectationFailure "expected the fixed column child"
case lookupLF lastKey (childrenLT tree) of
Just (LayoutTree (r1, _) _) -> do
_region_left r1 `shouldBe` 6
_region_width r1 `shouldBe` 4
_ -> expectationFailure "expected the stretchable row child"
it "preserves solver invariants on random constraint forests" $
runPlanProperty $
conjoin $
map
( \o ->
forAll ((,) <$> choose (0, 40) <*> scale (min 6) constraintList) $ \(extent, cs) -> ioProperty $ do
(ids, forest) <- mkForest cs
let region = case o of
Orientation_Row -> Region 0 0 extent 1
Orientation_Column -> Region 0 0 1 extent
tree = solve o region forest
chunks = childChunksIn o region ids tree
sizes = sizesOf chunks
offsets = map fst chunks
minTotal = sum [n | c <- cs, n <- [constraintMin c]]
numStretch = length [() | Constraint_Min _ <- cs]
fits = minTotal <= extent
expectedTotal
| not fits = minTotal -- deliberate overflow
| numStretch > 0 = extent -- stretch absorbs everything
| otherwise = minTotal -- fixed-only leaves leftover
pure $
counterexample
( show o
++ " extent "
++ show extent
++ " constraints "
++ show cs
++ " -> chunks "
++ show chunks
)
( length sizes == length cs
&& and (zipWith (\c s -> s >= constraintMin c) cs sizes)
&& and (zipWith (<=) offsets (drop 1 offsets))
&& and (zipWith (\(_, s) next -> next - s >= 0) chunks (drop 1 offsets))
&& sum sizes == expectedTotal
)
)
orientations
it "column chunks advance top, row chunks advance left" $ do
(ids, forest) <- mkForest [Constraint_Fixed 4, Constraint_Fixed 5]
let parent = Region 2 3 10 9
colTree = solve Orientation_Column parent forest
childChunksIn Orientation_Column parent ids colTree `shouldBe` [(0, 4), (4, 5)]
case map (\node -> lookupLF node (childrenLT colTree)) (take 1 ids) of
[Just (LayoutTree (r0, _) _)] -> (_region_left r0, _region_top r0, _region_height r0) `shouldBe` (2, 3, 4)
_ -> expectationFailure "expected the first column child"
where
orientations = [Orientation_Row, Orientation_Column]
offsetPlus s next = next - s >= 0
constraintMin c = case c of
Constraint_Fixed n -> n
Constraint_Min n -> n
-- | Bounded valid constraint lists (up to 6 children).
constraintList :: Gen [Constraint]
constraintList =
scale (min 6) $
listOf $
oneof
[ Constraint_Fixed <$> choose (0, 12)
, Constraint_Min <$> choose (0, 12)
]