packages feed

reflex-vty-1.3.0.0: test/Reflex/Vty/Test/Snapshot.hs

{-# LANGUAGE ScopedTypeVariables #-}

-- |
--
-- Renders a 'V.Picture' (or 'V.Image') through Vty's own renderer
-- ("Graphics.Vty.PictureToSpans" 'displayOpsForPic') and decodes the
-- resulting spans into a test-friendly 'Grid'. The renderer produces
-- the spans, so continuation columns and zero-width marks need no
-- reconstruction. A span keeps its complete text and its reported
-- column width.
--
-- Each row is a list of 'RenderSpan's covering the full row width.
-- Trailing blank columns ('RowEnd') become default-attribute blank
-- spans. Effective (fully resolved) attributes are computed per row,
-- starting from terminal defaults, exactly as Vty's output stage does.
--
-- Golden file format (produced by 'renderGrid'):
--
-- > 4x1
-- > "a\30028b"
-- > --- spans ---
-- > 0:0+4 text="a\30028b" attr=FixedAttr {fixedStyle = 0, ...}
--
-- The first line is @WxH@. Then comes one Haskell-escaped row string
-- per row (@show@ of the row's 'String'). Then comes @--- spans ---@
-- and one line per canonical span:
-- @row:column+width text=<quoted> attr=<show FixedAttr>@.
-- Default attributes are included, as well as explicit colors,
-- styles, and URLs.
module Reflex.Vty.Test.Snapshot
  ( RenderSpan (..)
  , Grid (..)
  , pictureToGrid
  , imageToGrid
  , imageText
  , renderGrid
  , gridEquals
  , attrAt
  , GoldenMode (..)
  , assertGolden
  , goldenUpdateCommand
  ) where

import Control.Exception (IOException, try)
import qualified Data.ByteString as BS
import Data.List (foldl')
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TEE
import qualified Data.Text.Lazy as TL
import qualified Data.Vector as Vec
import qualified Graphics.Vty as V
import Graphics.Vty.DisplayAttributes (fixDisplayAttr)
import qualified Graphics.Vty.PictureToSpans as PTS
import Graphics.Vty.Span (SpanOp (RowEnd, Skip, TextSpan))
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.FilePath ((<.>), (</>))
import Test.Hspec (Expectation, expectationFailure, shouldBe)

-- | A rendered span: its total column width on screen, its complete
-- text (including any zero-width marks), and its fully resolved
-- attribute.
data RenderSpan = RenderSpan
  { renderSpanColumns :: !Int
  , renderSpanText :: !T.Text
  , renderSpanAttr :: !V.FixedAttr
  }
  deriving (Eq, Show)

-- | A rendered grid: requested dimensions plus one row of spans per
-- display row. Every row's spans cover exactly 'gridWidth' columns.
data Grid = Grid
  { gridWidth :: !Int
  , gridHeight :: !Int
  , gridRows :: ![[RenderSpan]]
  }
  deriving (Eq, Show)

-- | Terminal default attributes; the starting point of attribute
-- resolution on each row.
defaultFixedAttr :: V.FixedAttr
defaultFixedAttr = V.FixedAttr V.defaultStyleMask Nothing Nothing Nothing

-- | Render a picture into a 'Grid' of the requested size, using Vty's
-- own renderer. Requested negative dimensions are clamped to zero. A
-- zero-area region yields a grid with those (nonnegative) dimensions
-- and no rows. A picture with no layers is normalized to a single
-- 'V.emptyImage' layer. Vty rejects a nonzero-sized picture with no
-- layers.
pictureToGrid :: V.DisplayRegion -> V.Picture -> Grid
pictureToGrid (reqW, reqH) pic0
  | w == 0 || h == 0 = Grid w h []
  | otherwise = Grid w h padded
  where
    w = max 0 reqW
    h = max 0 reqH
    pic = case pic0 of
      V.Picture {V.picLayers = []} -> pic0 {V.picLayers = [V.emptyImage]}
      _ -> pic0
    rows = map (decodeRow w . Vec.toList) (Vec.toList (PTS.displayOpsForPic pic (w, h)))
    padded = rows ++ replicate (h - length rows) [blankSpan w]

-- | Decode one row of span operations into canonical 'RenderSpan's,
-- resolving attributes from terminal defaults and converting trailing
-- blank columns to default-attribute blanks. The final picture stage
-- must not produce 'Skip' operations. A 'Skip' is an oracle error,
-- not a rendering result.
decodeRow :: Int -> [SpanOp] -> [RenderSpan]
decodeRow width ops =
  padTo width . coalesceSpans . snd $ foldl' step (defaultFixedAttr, []) ops
  where
    step (cur, acc) op = case op of
      TextSpan attr w _ tl ->
        let eff = fixDisplayAttr cur attr
        in (eff, acc ++ [RenderSpan w (TL.toStrict tl) eff])
      RowEnd n -> (defaultFixedAttr, acc ++ [blankSpan n])
      Skip n ->
        error $
          "oracle error: unexpected Skip ("
            ++ show n
            ++ " columns) in final picture spans"

-- | A blank span of @n@ default-attribute columns.
blankSpan :: Int -> RenderSpan
blankSpan n = RenderSpan n (T.replicate n (T.singleton ' ')) defaultFixedAttr

-- | Merge adjacent spans with equal effective attributes without
-- changing their text or widths.
coalesceSpans :: [RenderSpan] -> [RenderSpan]
coalesceSpans = foldl' step []
  where
    step [] s = [s]
    step acc s
      | renderSpanAttr s == renderSpanAttr (last acc) =
          init acc
            ++ [ RenderSpan
                   (renderSpanColumns (last acc) + renderSpanColumns s)
                   (renderSpanText (last acc) <> renderSpanText s)
                   (renderSpanAttr s)
               ]
      | otherwise = acc ++ [s]

-- | Ensure a row's spans cover the full width by appending
-- default-attribute blanks if the renderer left the row short.
padTo :: Int -> [RenderSpan] -> [RenderSpan]
padTo width ss =
  let covered = sum (map renderSpanColumns ss)
  in if covered < width then ss ++ [blankSpan (width - covered)] else ss

-- | Render an image to a grid using the image's own dimensions.
imageToGrid :: V.Image -> Grid
imageToGrid img =
  pictureToGrid (V.imageWidth img, V.imageHeight img) (V.picForImage img)

-- | Extract the visible text of an image, one string per row. The
-- text includes actual trailing blank columns. It never invents a
-- space for a wide-glyph continuation column.
imageText :: V.Image -> [String]
imageText img =
  map (T.unpack . T.concat . map renderSpanText) (gridRows (imageToGrid img))

-- | Look up the effective attribute at a cell coordinate. A wide
-- glyph's continuation column reports the same attribute as its start
-- column. Both columns are covered by the same span. Out-of-bounds
-- coordinates return 'Nothing'.
attrAt :: Int -> Int -> Grid -> Maybe V.FixedAttr
attrAt x y (Grid w h rows)
  | x < 0 || y < 0 || x >= w || y >= h = Nothing
  | otherwise = case drop y rows of
      [] -> Nothing
      (r : _) -> go 0 r
  where
    go _ [] = Nothing
    go col (s : ss)
      | x < col + renderSpanColumns s = Just (renderSpanAttr s)
      | otherwise = go (col + renderSpanColumns s) ss

-- | Check whether two grids are equal. Returns 'Nothing' when they
-- match, or a description of the first difference in dimensions, text,
-- widths, or effective attributes (including URLs).
gridEquals :: Grid -> Grid -> Maybe String
gridEquals a b
  | gridWidth a /= gridWidth b =
      Just $ "width: " ++ show (gridWidth a) ++ " vs " ++ show (gridWidth b)
  | gridHeight a /= gridHeight b =
      Just $ "height: " ++ show (gridHeight a) ++ " vs " ++ show (gridHeight b)
  | otherwise = firstDiff 0 (gridRows a) (gridRows b)
  where
    firstDiff _ [] [] = Nothing
    firstDiff y (ra : ras) (rb : rbs) = case compareRows y ra rb of
      Just d -> Just d
      Nothing -> firstDiff (y + 1) ras rbs
    firstDiff y [] _ = Just ("row " ++ show y ++ ": missing")
    firstDiff y _ [] = Just ("row " ++ show y ++ ": extra")
    compareRows y ra rb
      | rowText ra /= rowText rb =
          Just $
            "row "
              ++ show y
              ++ " text: "
              ++ show (rowText ra)
              ++ " vs "
              ++ show (rowText rb)
      | ra /= rb =
          Just $
            "row "
              ++ show y
              ++ " spans: "
              ++ show ra
              ++ " vs "
              ++ show rb
      | otherwise = Nothing
    rowText = T.unpack . T.concat . map renderSpanText

-- | Pretty-print a grid for golden-file comparison and debugging.
-- See the module documentation for the exact format.
renderGrid :: Grid -> String
renderGrid grid =
  unlines (header : rowStrings)
    ++ unlines ("--- spans ---" : spanLines)
  where
    header = show (gridWidth grid) ++ "x" ++ show (gridHeight grid)
    rowStrings =
      [ show (T.unpack (T.concat (map renderSpanText row)))
      | row <- gridRows grid
      ]
    spanLines =
      [ show y
          ++ ":"
          ++ show col
          ++ "+"
          ++ show (renderSpanColumns s)
          ++ " text="
          ++ show (T.unpack (renderSpanText s))
          ++ " attr="
          ++ show (renderSpanAttr s)
      | (y, row) <- zip [0 :: Int ..] (gridRows grid)
      , (col, s) <- zip (scanl (+) 0 (map renderSpanColumns row)) row
      ]

----------------------------------------------------------------------------
-- Golden file comparison
----------------------------------------------------------------------------

-- | Whether golden fixtures are being checked (the default, and the
-- only mode allowed in CI) or explicitly updated.
data GoldenMode = CheckGoldens | UpdateGoldens
  deriving (Eq, Show)

-- | The exact command that creates or updates golden fixtures.
goldenUpdateCommand :: String
goldenUpdateCommand =
  "REFLEX_VTY_GOLDEN_UPDATE=1 cabal test reflex-vty-test"
    ++ " --test-show-details=direct --test-option=--match=Golden"

-- | Compare the serialized actual output against the golden fixture
-- @\<dir>\</\<name>.txt@. In 'UpdateGoldens' mode, write the output
-- instead of comparing.
--
-- In 'CheckGoldens' mode, create nothing. A missing file fails with
-- the path and 'goldenUpdateCommand'. A mismatch fails through Hspec
-- equality, so the expected and actual contents are visible. IO,
-- permission, and encoding errors fail with the path in the message.
--
-- In 'UpdateGoldens' mode, create the directory. Write changed or
-- missing files in UTF-8. Leave equal files untouched.
assertGolden :: GoldenMode -> FilePath -> String -> String -> Expectation
assertGolden mode dir name actual = do
  let path = dir </> name <.> "txt"
  case mode of
    CheckGoldens -> do
      exists <- doesFileExist path
      if not exists
        then
          expectationFailure $
            "missing golden file "
              ++ path
              ++ "; to create or update it, run: "
              ++ goldenUpdateCommand
        else do
          er <- readGolden path
          case er of
            Left err -> expectationFailure err
            Right expected -> expected `shouldBe` actual
    UpdateGoldens -> do
      createDirectoryIfMissing True dir
      exists <- doesFileExist path
      if not exists
        then do
          merr <- writeGolden path actual
          maybe (pure ()) expectationFailure merr
        else do
          er <- readGolden path
          case er of
            Left err -> expectationFailure err
            Right expected
              | expected /= actual -> do
                  merr <- writeGolden path actual
                  maybe (pure ()) expectationFailure merr
              | otherwise -> pure ()

readGolden :: FilePath -> IO (Either String String)
readGolden path = do
  r <- try (TE.decodeUtf8With TEE.lenientDecode <$> BS.readFile path)
  pure $ case r of
    Left (e :: IOException) ->
      Left ("could not read golden file " ++ path ++ ": " ++ show e)
    Right txt -> Right (T.unpack txt)

writeGolden :: FilePath -> String -> IO (Maybe String)
writeGolden path contents = do
  r <- try (BS.writeFile path (TE.encodeUtf8 (T.pack contents)))
  pure $ case r of
    Left (e :: IOException) ->
      Just ("could not write golden file " ++ path ++ ": " ++ show e)
    Right () -> Nothing