packages feed

restman-0.7.2.2: test/Props/LibProps.hs

{-# language OverloadedStrings #-}
module Props.LibProps (tests) where

-- bytestring
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS

-- case-insensitive
import qualified Data.CaseInsensitive as CI

-- hedgehog
import Hedgehog
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range

-- http-client
import qualified Network.HTTP.Client as HC

-- tasty / tasty-hedgehog
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.Hedgehog (testProperty)

-- text
import qualified Data.Text as Text
import Data.Text.Encoding (encodeUtf8)

-- vty
import Graphics.Vty.Attributes (Style, reverseVideo)
import qualified Graphics.Vty.Attributes as VA
import Graphics.Vty.Image (charFill, imageHeight, imageWidth)

-- restman
import HTTP.Client (Header, UseDefaultHeaders(..))
import Lib
  ( cleanPayload
  , focusStyle
  , growWith
  , lbsToText
  , moveFocusNext
  , moveFocusPrev
  , splitExtraSpace
  )
import Types (AppR(..), AppS(..), RangeInAlign(..))
import UI (mkInitialState)

tests :: TestTree
tests = testGroup "Props.Lib"
  [ splitExtraSpaceProps
  , cleanPayloadProps
  , lbsToTextProps
  , focusStyleProps
  , focusCycleProps
  , growSpacesProps
  ]

-- ---------------------------------------------------------------------------
-- splitExtraSpace properties
--
-- splitExtraSpace :: RangeInAlign -> Int -> (Int, Int)
-- Precondition: second argument is positive (per documentation).
-- ---------------------------------------------------------------------------

genAlign :: Gen RangeInAlign
genAlign = Gen.element [Min, MidL, MidG, Max]

-- The two halves always sum to the input space.
prop_splitExtraSpace_sum :: Property
prop_splitExtraSpace_sum = property $ do
  align <- forAll genAlign
  n     <- forAll $ Gen.int (Range.linear 0 1000)
  let (l, r) = splitExtraSpace align n
  l + r === n

-- Min alignment: all space goes to the right (left is always 0).
prop_splitExtraSpace_min_left_zero :: Property
prop_splitExtraSpace_min_left_zero = property $ do
  n <- forAll $ Gen.int (Range.linear 0 1000)
  fst (splitExtraSpace Min n) === 0

-- Max alignment: all space goes to the left (right is always 0).
prop_splitExtraSpace_max_right_zero :: Property
prop_splitExtraSpace_max_right_zero = property $ do
  n <- forAll $ Gen.int (Range.linear 0 1000)
  snd (splitExtraSpace Max n) === 0

-- Both halves are non-negative for any alignment.
prop_splitExtraSpace_nonneg :: Property
prop_splitExtraSpace_nonneg = property $ do
  align <- forAll genAlign
  n     <- forAll $ Gen.int (Range.linear 0 1000)
  let (l, r) = splitExtraSpace align n
  assert (l >= 0)
  assert (r >= 0)

-- MidL and MidG are mirror images: swapping the pair converts one to the other.
prop_splitExtraSpace_midl_midg_swap :: Property
prop_splitExtraSpace_midl_midg_swap = property $ do
  n <- forAll $ Gen.int (Range.linear 0 1000)
  let (lL, rL) = splitExtraSpace MidL n
      (lG, rG) = splitExtraSpace MidG n
  lL === rG
  rL === lG

-- MidL gives the smaller (or equal) half to the left.
prop_splitExtraSpace_midl_left_le_right :: Property
prop_splitExtraSpace_midl_left_le_right = property $ do
  n <- forAll $ Gen.int (Range.linear 0 1000)
  let (l, r) = splitExtraSpace MidL n
  assert (l <= r)

-- MidG gives the larger (or equal) half to the left.
prop_splitExtraSpace_midg_left_ge_right :: Property
prop_splitExtraSpace_midg_left_ge_right = property $ do
  n <- forAll $ Gen.int (Range.linear 0 1000)
  let (l, r) = splitExtraSpace MidG n
  assert (l >= r)

splitExtraSpaceProps :: TestTree
splitExtraSpaceProps = testGroup "splitExtraSpace"
  [ testProperty "halves sum to n"               prop_splitExtraSpace_sum
  , testProperty "Min: left half is always 0"    prop_splitExtraSpace_min_left_zero
  , testProperty "Max: right half is always 0"   prop_splitExtraSpace_max_right_zero
  , testProperty "both halves are non-negative"  prop_splitExtraSpace_nonneg
  , testProperty "MidL and MidG are mirrors"     prop_splitExtraSpace_midl_midg_swap
  , testProperty "MidL: left <= right"           prop_splitExtraSpace_midl_left_le_right
  , testProperty "MidG: left >= right"           prop_splitExtraSpace_midg_left_ge_right
  ]

-- ---------------------------------------------------------------------------
-- cleanPayload properties
--
-- cleanPayload :: LBS.ByteString -> Text
-- Strips \ESC and \v; expands \t to four spaces.
-- ---------------------------------------------------------------------------

-- Generate arbitrary byte strings (arbitrary UTF-8 or binary).
genBytes :: Gen LBS.ByteString
genBytes = LBS.fromStrict <$> Gen.bytes (Range.linear 0 256)

-- ESC characters are always removed from the output.
prop_cleanPayload_no_esc :: Property
prop_cleanPayload_no_esc = property $ do
  bs <- forAll genBytes
  assert . not . Text.any (== '\ESC') $ cleanPayload bs

-- Vertical tab characters are always removed from the output.
prop_cleanPayload_no_vtab :: Property
prop_cleanPayload_no_vtab = property $ do
  bs <- forAll genBytes
  assert . not . Text.any (== '\v') $ cleanPayload bs

-- Tab characters are expanded and therefore absent from the output.
prop_cleanPayload_no_tab :: Property
prop_cleanPayload_no_tab = property $ do
  bs <- forAll genBytes
  assert . not . Text.any (== '\t') $ cleanPayload bs

-- Plain ASCII text that has no ESC/vtab/tab passes through unchanged.
prop_cleanPayload_plain_ascii_unchanged :: Property
prop_cleanPayload_plain_ascii_unchanged = property $ do
  t <- forAll $ Gen.text (Range.linear 0 100)
                  (Gen.filterT (`notElem` ['\ESC', '\v', '\t']) Gen.ascii)
  cleanPayload (LBS.fromStrict $ encodeUtf8 t) === t

cleanPayloadProps :: TestTree
cleanPayloadProps = testGroup "cleanPayload"
  [ testProperty "no ESC in output"            prop_cleanPayload_no_esc
  , testProperty "no vertical tab in output"   prop_cleanPayload_no_vtab
  , testProperty "no tab in output"            prop_cleanPayload_no_tab
  , testProperty "plain ASCII passes through"  prop_cleanPayload_plain_ascii_unchanged
  ]

-- ---------------------------------------------------------------------------
-- lbsToText properties
--
-- lbsToText :: LBS.ByteString -> Text
-- Decodes as UTF-8 with lenient error recovery.
-- ---------------------------------------------------------------------------

-- Encoding ASCII text and decoding it back yields the original.
prop_lbsToText_ascii_roundtrip :: Property
prop_lbsToText_ascii_roundtrip = property $ do
  t <- forAll $ Gen.text (Range.linear 0 200) Gen.ascii
  lbsToText (LBS.fromStrict $ encodeUtf8 t) === t

-- Decoding never throws — lenient mode replaces bad bytes with U+FFFD.
prop_lbsToText_never_throws :: Property
prop_lbsToText_never_throws = property $ do
  bs <- forAll genBytes
  _ <- evalNF (lbsToText bs)
  success

-- Valid Unicode text encoded as UTF-8 decodes back to itself.
prop_lbsToText_unicode_roundtrip :: Property
prop_lbsToText_unicode_roundtrip = property $ do
  t <- forAll $ Gen.text (Range.linear 0 100) Gen.unicode
  lbsToText (LBS.fromStrict $ encodeUtf8 t) === t

lbsToTextProps :: TestTree
lbsToTextProps = testGroup "lbsToText"
  [ testProperty "ASCII encode/decode roundtrip" prop_lbsToText_ascii_roundtrip
  , testProperty "never throws on arbitrary bytes" prop_lbsToText_never_throws
  , testProperty "valid Unicode encode/decode roundtrip" prop_lbsToText_unicode_roundtrip
  ]

-- ---------------------------------------------------------------------------
-- focusStyle properties
--
-- focusStyle :: Style -> Style  (Style = Word)
-- Defined as `x `xor` reverseVideo`, so applying it twice is the identity.
-- ---------------------------------------------------------------------------

genStyle :: Gen Style
genStyle = Gen.integral (Range.linearBounded :: Range.Range Style)

-- Applying focusStyle twice is the identity (involution).
prop_focusStyle_involution :: Property
prop_focusStyle_involution = property $ do
  s <- forAll genStyle
  focusStyle (focusStyle s) === s

-- focusStyle on the zero style gives reverseVideo.
prop_focusStyle_zero_gives_reverseVideo :: Property
prop_focusStyle_zero_gives_reverseVideo = property $ do
  focusStyle 0 === reverseVideo

-- focusStyle on reverseVideo gives zero.
prop_focusStyle_reverseVideo_gives_zero :: Property
prop_focusStyle_reverseVideo_gives_zero = property $ do
  focusStyle reverseVideo === 0

focusStyleProps :: TestTree
focusStyleProps = testGroup "focusStyle"
  [ testProperty "applying twice is identity"       prop_focusStyle_involution
  , testProperty "0 maps to reverseVideo"           prop_focusStyle_zero_gives_reverseVideo
  , testProperty "reverseVideo maps to 0"           prop_focusStyle_reverseVideo_gives_zero
  ]

-- ---------------------------------------------------------------------------
-- moveFocusNext / moveFocusPrev cycle properties
--
-- With no custom headers the focus visits 5 positions in a fixed cycle:
--   UrlEditor -> DefaultHeadersToggle -> AddCustomHeader
--     -> ResponseBodyView -> MethodEditor -> UrlEditor
-- ---------------------------------------------------------------------------

-- A plain initial state (no custom headers, starts at UrlEditor).
baseState :: IO AppS
baseState = do
  mgr <- HC.newManager HC.defaultManagerSettings
  pure $ mkInitialState mgr "GET" ReplaceDefaultHeaders [] Nothing Nothing

-- Applying moveFocusNext 5 times returns focus to its starting position.
prop_moveFocusNext_cycle5 :: Property
prop_moveFocusNext_cycle5 = property $ do
  s0 <- evalIO baseState
  let sN = foldl' (flip ($)) s0 (replicate 5 moveFocusNext)
  focus sN === focus s0

-- Applying moveFocusPrev 5 times returns focus to its starting position.
prop_moveFocusPrev_cycle5 :: Property
prop_moveFocusPrev_cycle5 = property $ do
  s0 <- evalIO baseState
  let sN = foldl' (flip ($)) s0 (replicate 5 moveFocusPrev)
  focus sN === focus s0

-- moveFocusNext then moveFocusPrev is the identity on focus (no custom headers,
-- starting focus is not inside a custom-header row).
prop_moveFocusNext_then_prev_identity :: Property
prop_moveFocusNext_then_prev_identity = property $ do
  s0 <- evalIO baseState
  -- choose a simple starting focus (not ExistingCustomHeader or MethodSelector)
  startFocus <- forAll $ Gen.element
    [ MethodEditor, UrlEditor, DefaultHeadersToggle
    , AddCustomHeader, ResponseBodyView
    ]
  let s1 = s0 { focus = startFocus }
  focus (moveFocusPrev (moveFocusNext s1)) === startFocus

-- Applying moveFocusNext 8 times with one custom header returns focus to its
-- starting position (5 non-header stops + 3 per header = 8 total).
prop_moveFocusNext_cycle8_one_header :: Property
prop_moveFocusNext_cycle8_one_header = property $ do
  mgr <- evalIO $ HC.newManager HC.defaultManagerSettings
  let oneHeader :: [Header]
      oneHeader = [(CI.mk ("X-Test" :: BS.ByteString), "value")]
      s0 = mkInitialState mgr "GET" ReplaceDefaultHeaders oneHeader Nothing Nothing
      sN = foldl' (flip ($)) s0 (replicate 8 moveFocusNext)
  focus sN === focus s0

focusCycleProps :: TestTree
focusCycleProps = testGroup "focus cycle"
  [ testProperty "moveFocusNext: 5 steps is full cycle"              prop_moveFocusNext_cycle5
  , testProperty "moveFocusPrev: 5 steps is full cycle"              prop_moveFocusPrev_cycle5
  , testProperty "moveFocusNext then moveFocusPrev restores focus"   prop_moveFocusNext_then_prev_identity
  , testProperty "moveFocusNext: 8 steps with 1 header is full cycle" prop_moveFocusNext_cycle8_one_header
  ]

-- ---------------------------------------------------------------------------
-- growWith / growSpaces properties
--
-- growWith fillImg minW wa minH va img
-- Post-conditions: output width >= max(minW, imageWidth img)
--                  output height >= max(minH, imageHeight img)
-- ---------------------------------------------------------------------------

-- Output width is always at least the requested minimum.
prop_growSpaces_width_ge_min :: Property
prop_growSpaces_width_ge_min = property $ do
  minW <- forAll $ Gen.int (Range.linear 0 50)
  minH <- forAll $ Gen.int (Range.linear 1 50)
  srcW <- forAll $ Gen.int (Range.linear 1 50)
  srcH <- forAll $ Gen.int (Range.linear 1 50)
  align <- forAll genAlign
  let src = charFill VA.currentAttr ' ' srcW srcH
      img = growWith (charFill VA.currentAttr ' ') minW align minH align src
  assert (imageWidth img >= minW)

-- Output height is always at least the requested minimum.
prop_growSpaces_height_ge_min :: Property
prop_growSpaces_height_ge_min = property $ do
  minW <- forAll $ Gen.int (Range.linear 1 50)
  minH <- forAll $ Gen.int (Range.linear 0 50)
  srcW <- forAll $ Gen.int (Range.linear 1 50)
  srcH <- forAll $ Gen.int (Range.linear 1 50)
  align <- forAll genAlign
  let src = charFill VA.currentAttr ' ' srcW srcH
      img = growWith (charFill VA.currentAttr ' ') minW align minH align src
  assert (imageHeight img >= minH)

-- Output width is always at least the source image width.
prop_growSpaces_width_ge_src :: Property
prop_growSpaces_width_ge_src = property $ do
  minW <- forAll $ Gen.int (Range.linear 0 50)
  minH <- forAll $ Gen.int (Range.linear 1 50)
  srcW <- forAll $ Gen.int (Range.linear 1 50)
  srcH <- forAll $ Gen.int (Range.linear 1 50)
  align <- forAll genAlign
  let src = charFill VA.currentAttr ' ' srcW srcH
      img = growWith (charFill VA.currentAttr ' ') minW align minH align src
  assert (imageWidth img >= imageWidth src)

-- Output height is always at least the source image height.
prop_growSpaces_height_ge_src :: Property
prop_growSpaces_height_ge_src = property $ do
  minW <- forAll $ Gen.int (Range.linear 1 50)
  minH <- forAll $ Gen.int (Range.linear 0 50)
  srcW <- forAll $ Gen.int (Range.linear 1 50)
  srcH <- forAll $ Gen.int (Range.linear 1 50)
  align <- forAll genAlign
  let src = charFill VA.currentAttr ' ' srcW srcH
      img = growWith (charFill VA.currentAttr ' ') minW align minH align src
  assert (imageHeight img >= imageHeight src)

growSpacesProps :: TestTree
growSpacesProps = testGroup "growSpaces"
  [ testProperty "output width >= requested minimum"  prop_growSpaces_width_ge_min
  , testProperty "output height >= requested minimum" prop_growSpaces_height_ge_min
  , testProperty "output width >= source width"       prop_growSpaces_width_ge_src
  , testProperty "output height >= source height"     prop_growSpaces_height_ge_src
  ]