packages feed

tramaj-hs-0.4.0.0: test/unit/Tramaj/NodeJsonSpec.hs

-- | Round-trip and shape checks for the normative Node JSON representation
-- specified in @../specs/node-json.md@. The round-trip property is the one
-- that matters: it is what lets an independent implementation decode what
-- this one encodes, so it is checked over trees covering all three node
-- constructors, both attribute kinds, non-empty annotations, and scalar
-- values of every JSON type.
module Tramaj.NodeJsonSpec (spec) where

import qualified Data.Map.Strict as Map
import Data.Text (Text)
import Test.Hspec
import Tramaj.Json (Json (..))
import Tramaj.Node
import Tramaj.TestJson (ToJ, object, (.=))

spec :: Spec
spec = describe "Tramaj.Node JSON representation" $ do
  roundTripSpec
  shapeSpec
  rejectionSpec
  mapActionsSpec

-- | Every sample below must survive @nodeFromJson . nodeToJson@ unchanged.
samples :: [(String, Node)]
samples =
  [ ("text holding a string", NText (JString "hello") noAnnotations)
  , ("text holding a number", NText (JInt 3) noAnnotations)
  , ("text holding null", NText JNull noAnnotations)
  , ("text holding a boolean", NText (JBool True) noAnnotations)
  , ("text holding an object", NText (object ["a" .= (1 :: Int)]) noAnnotations)
  , ("empty element", NElement "div" [] JNull [] noAnnotations)
  , ("element with an ordinary attribute", NElement "div" [NAttr "class" (JString "panel")] JNull [] noAnnotations)
  ,
    ( "element with several actions and attributes interleaved"
    , NElement
        "button"
        [ NAttr "class" (JString "primary")
        , NAction "on-click" "save" (object ["id" .= (1 :: Int)])
        , NAttr "data-n" (JInt 2)
        , NAction "on-double-click" "open" JNull
        ]
        JNull
        [NText (JString "Save") noAnnotations]
        noAnnotations
    )
  , ("element with a value slot", NElement "Replicas" [] (JInt 3) [] noAnnotations)
  , ("fragment", NFragment [NText (JString "one") noAnnotations, NText (JString "two") noAnnotations] noAnnotations)
  , ("empty fragment", NFragment [] noAnnotations)
  ,
    ( "annotations at every level, including ones the core does not understand"
    , NElement
        "section"
        [NAttr "id" (JString "x")]
        JNull
        [NFragment [NText (JString "deep") (Map.fromList [("origin", JString "lib")])] (Map.fromList [("flattenable", JBool True)])]
        (Map.fromList [("type", JString "Section"), ("domain", object ["min" .= (1 :: Int)])])
    )
  , ("repeated attribute names are preserved, not deduplicated", NElement "div" [NAttr "class" (JString "a"), NAttr "class" (JString "b")] JNull [] noAnnotations)
  ]

roundTripSpec :: Spec
roundTripSpec = describe "round-trips through the normative representation" $
  mapM_ (\(name, n) -> it name (nodeFromJson (nodeToJson n) `shouldBe` Right n)) samples

-- | The exact bytes matter as much as the round-trip: another implementation
-- reads these keys, so a rename would be a silent wire-format break that a
-- round-trip test alone would not catch.
shapeSpec :: Spec
shapeSpec = describe "encodes the shape specs/node-json.md documents" $ do
  it "a text node carries its value unconverted, plus annotations" $
    nodeToJson (NText (JInt 3) noAnnotations)
      `shouldBe` object ["type" .= ("text" :: Text), "value" .= (3 :: Int), "annotations" .= object []]

  it "an element always emits tag, attributes, value, children and annotations" $
    nodeToJson (NElement "p" [] JNull [] noAnnotations)
      `shouldBe` object
        [ "type" .= ("element" :: Text)
        , "tag" .= ("p" :: Text)
        , "attributes" .= ([] :: [Json])
        , "value" .= JNull
        , "children" .= ([] :: [Json])
        , "annotations" .= object []
        ]

  it "attributes and actions are discriminated by \"kind\" in one ordered list" $
    nodeToJson (NElement "b" [NAttr "class" (JString "c"), NAction "on-click" "save" JNull] JNull [] noAnnotations)
      `shouldBe` object
        [ "type" .= ("element" :: Text)
        , "tag" .= ("b" :: Text)
        , "attributes"
            .= [ object ["kind" .= ("attribute" :: Text), "name" .= ("class" :: Text), "value" .= ("c" :: Text)]
               , object ["kind" .= ("action" :: Text), "event" .= ("on-click" :: Text), "key" .= ("save" :: Text), "payload" .= JNull]
               ]
        , "value" .= JNull
        , "children" .= ([] :: [Json])
        , "annotations" .= object []
        ]

  it "a fragment emits no tag, attributes or value" $
    nodeToJson (NFragment [] noAnnotations)
      `shouldBe` object ["type" .= ("fragment" :: Text), "children" .= ([] :: [Json]), "annotations" .= object []]

-- | @specs/node-json.md@, "Decoding": a decoder must reject rather than
-- default, so that a malformed document is reported where it is read.
-- Malformed documents are built as 'Json's directly rather than parsed from
-- JSON text, so each one differs from a well-formed node in exactly the one
-- way its name describes.
rejectionSpec :: Spec
rejectionSpec = describe "rejects malformed documents rather than defaulting" $ do
  let rejects name js = it name (nodeFromJson js `shouldSatisfy` either (const True) (const False))

  rejects "a node with no type" $
    object ["value" .= (1 :: Int), "annotations" .= object []]
  rejects "an unknown node type" $
    object ["type" .= ("comment" :: Text), "annotations" .= object []]
  rejects "a text node with no value" $
    object ["type" .= ("text" :: Text), "annotations" .= object []]
  rejects "a node with no annotations" $
    object ["type" .= ("text" :: Text), "value" .= (1 :: Int)]
  rejects "an element with no value slot" $
    object
      [ "type" .= ("element" :: Text)
      , "tag" .= ("p" :: Text)
      , "attributes" .= ([] :: [Json])
      , "children" .= ([] :: [Json])
      , "annotations" .= object []
      ]
  rejects "an element with a non-string tag" $
    element (JInt 1) ([] :: [Json]) ([] :: [Json])
  rejects "an element whose children are not an array" $
    element (JString "p") ([] :: [Json]) (object [])
  rejects "an element whose attributes are not an array" $
    element (JString "p") (object []) ([] :: [Json])
  rejects "annotations that are not an object" $
    object ["type" .= ("fragment" :: Text), "children" .= ([] :: [Json]), "annotations" .= ([] :: [Json])]
  rejects "an attribute with no kind" $
    element (JString "p") [object ["name" .= ("a" :: Text), "value" .= (1 :: Int)]] ([] :: [Json])
  rejects "an unknown attribute kind" $
    element (JString "p") [object ["kind" .= ("listener" :: Text)]] ([] :: [Json])
  rejects "an action with no key" $
    element (JString "p") [object ["kind" .= ("action" :: Text), "event" .= ("e" :: Text), "payload" .= JNull]] ([] :: [Json])
  rejects "an attribute with a non-string name" $
    element (JString "p") [object ["kind" .= ("attribute" :: Text), "name" .= (1 :: Int), "value" .= JNull]] ([] :: [Json])
  rejects "a malformed node nested deep in a child" $
    object
      [ "type" .= ("fragment" :: Text)
      , "children" .= [object ["type" .= ("text" :: Text)]]
      , "annotations" .= object []
      ]
  rejects "a bare scalar where a node was expected" $ JInt 42
  rejects "an attribute that is not an object" $
    element (JString "p") [JString "class"] ([] :: [Json])
  where
    -- | A well-formed element apart from whichever of its three variable
    -- parts the caller deliberately breaks.
    element :: (ToJ a, ToJ b) => Json -> a -> b -> Json
    element tag attrs children =
      object
        [ "type" .= ("element" :: Text)
        , "tag" .= tag
        , "attributes" .= attrs
        , "value" .= JNull
        , "children" .= children
        , "annotations" .= object []
        ]

-- | @adapt-actions@ leans on this: it must reach actions at every depth and
-- leave everything else -- annotations especially -- exactly as it found it.
mapActionsSpec :: Spec
mapActionsSpec = describe "mapActions" $ do
  let prefixKey p (NAction e k pl) = NAction e (p <> k) pl
      prefixKey _ a = a
      prefix p = mapActions (\e k pl -> Right (prefixKey p (NAction e k pl))) :: Node -> Either () Node

  it "rewrites actions nested under children and fragments" $
    prefix "ns:"
      ( NElement
          "div"
          []
          JNull
          [NFragment [NElement "b" [NAction "on-click" "save" JNull] JNull [] noAnnotations] noAnnotations]
          noAnnotations
      )
      `shouldBe` Right
        ( NElement
            "div"
            []
            JNull
            [NFragment [NElement "b" [NAction "on-click" "ns:save" JNull] JNull [] noAnnotations] noAnnotations]
            noAnnotations
        )

  it "leaves ordinary attributes, value slots and annotations untouched" $
    let anns = Map.fromList [("origin", JString "lib")]
        n = NElement "div" [NAttr "class" (JString "c"), NAction "on-click" "save" JNull] (JInt 1) [] anns
     in prefix "ns:" n
          `shouldBe` Right (NElement "div" [NAttr "class" (JString "c"), NAction "on-click" "ns:save" JNull] (JInt 1) [] anns)

  it "propagates a failing rewrite instead of dropping it" $
    mapActions (\_ _ _ -> Left "boom") (NElement "b" [NAction "on-click" "save" JNull] JNull [] noAnnotations)
      `shouldBe` (Left "boom" :: Either String Node)