granite-0.3.0.1: test/GraniteSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module GraniteSpec (spec) where
import Data.Text (Text)
import Data.Text qualified as Text
import Granite
import Numeric (showEFloat, showFFloat)
import Test.Hspec
import Test.QuickCheck
spec :: Spec
spec = do
describe "Data Types and Constructors" $ do
testDataTypes
describe "Core Plotting Functions" $ do
testPlottingFunctions
describe "Helper Functions" $ do
testHelperFunctions
describe "Canvas and Rendering" $ do
testCanvasOperations
describe "Formatting and Axis Handling" $ do
testFormatting
describe "Edge Cases and Error Handling" $ do
testEdgeCases
describe "Property tests" $ do
propTests
testDataTypes :: Spec
testDataTypes = do
describe "Plot record" $ do
it "defPlot should have correct default values" $ do
widthChars defPlot `shouldBe` 60
heightChars defPlot `shouldBe` 20
leftMargin defPlot `shouldBe` 6
bottomMargin defPlot `shouldBe` 2
titleMargin defPlot `shouldBe` 1
xBounds defPlot `shouldBe` (Nothing, Nothing)
yBounds defPlot `shouldBe` (Nothing, Nothing)
plotTitle defPlot `shouldBe` ""
legendPos defPlot `shouldBe` LegendRight
xNumTicks defPlot `shouldBe` 3
yNumTicks defPlot `shouldBe` 3
it "should allow customization via record update" $ do
let customPlot = defPlot{widthChars = 80, plotTitle = "Test Chart"}
widthChars customPlot `shouldBe` 80
plotTitle customPlot `shouldBe` "Test Chart"
heightChars customPlot `shouldBe` 20
describe "LegendPos enum" $ do
it "should have correct Show instances" $ do
show LegendRight `shouldBe` "LegendRight"
show LegendBottom `shouldBe` "LegendBottom"
it "should have correct Eq instances" $ do
LegendRight `shouldBe` LegendRight
LegendBottom `shouldBe` LegendBottom
LegendRight `shouldNotBe` LegendBottom
describe "Color enum" $ do
it "should convert to correct ANSI codes" $ do
ansiCode Black `shouldBe` 30
ansiCode Red `shouldBe` 31
ansiCode BrightBlue `shouldBe` 94
ansiCode Default `shouldBe` 39
describe "Bins constructor" $ do
it "should create bins with correct values" $ do
let b = bins 10 0 100
nBins b `shouldBe` 10
lo b `shouldBe` 0
hi b `shouldBe` 100
it "should handle reversed bounds" $ do
let b = bins 10 100 0
lo b `shouldBe` 0
hi b `shouldBe` 100
it "should enforce minimum of 1 bin" $ do
let b = bins (-5) 0 100
nBins b `shouldBe` 1
testPlottingFunctions :: Spec
testPlottingFunctions = do
describe "series function" $ do
it "should create a named data series" $ do
let s = series "test" [(1, 2), (3, 4)]
fst s `shouldBe` "test"
snd s `shouldBe` [(1, 2), (3, 4)]
describe "scatter plot" $ do
it "should generate non-empty output for valid data" $ do
let points = [(1, 1), (2, 2), (3, 3)]
chart = scatter [series "line" points] defPlot
Text.length chart `shouldSatisfy` (> 0)
it "should handle empty data gracefully" $ do
let chart = scatter [] defPlot
Text.length chart `shouldSatisfy` (> 0)
it "should include series name in legend" $ do
let points = [(1, 1), (2, 2)]
chart = scatter [series "test series" points] defPlot
chart `shouldSatisfy` Text.isInfixOf "test series"
it "should handle multiple series" $ do
let s1 = series "Series 1" [(1, 1), (2, 2)]
s2 = series "Series 2" [(1, 2), (2, 1)]
chart = scatter [s1, s2] defPlot
chart `shouldSatisfy` Text.isInfixOf "Series 1"
chart `shouldSatisfy` Text.isInfixOf "Series 2"
describe "line graph" $ do
it "should generate non-empty output" $ do
let points = [(1, sin 1), (2, sin 2), (3, sin 3)]
chart = lineGraph [series "sine" points] defPlot
Text.length chart `shouldSatisfy` (> 0)
it "should sort points by x-coordinate internally" $ do
let unsorted = [(3, 3), (1, 1), (2, 2)]
chart = lineGraph [series "unsorted" unsorted] defPlot
Text.length chart `shouldSatisfy` (> 0)
describe "bar chart" $ do
it "should handle categorical data" $ do
let data' = [("A", 10), ("B", 20), ("C", 15)]
chart = bars data' defPlot
Text.length chart `shouldSatisfy` (> 0)
it "should include category names" $ do
let data' = [("Apple", 10), ("Banana", 20)]
chart = bars data' defPlot
chart `shouldSatisfy` Text.isInfixOf "Apple"
chart `shouldSatisfy` Text.isInfixOf "Banana"
describe "histogram" $ do
it "should bin numerical data" $ do
let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
chart = histogram (bins 5 0 10) values defPlot
Text.length chart `shouldSatisfy` (> 0)
it "should handle empty data" $ do
let chart = histogram (bins 5 0 10) [] defPlot
Text.length chart `shouldSatisfy` (> 0)
describe "pie chart" $ do
it "should handle proportional data" $ do
let data' = [("A", 25), ("B", 50), ("C", 25)]
chart = pie data' defPlot
Text.length chart `shouldSatisfy` (> 0)
it "should normalize values to proportions" $ do
let data' = [("A", 1), ("B", 2), ("C", 1)]
chart = pie data' defPlot
Text.length chart `shouldSatisfy` (> 0)
describe "heatmap" $ do
it "should handle 2D matrix data" $ do
let matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
chart = heatmap matrix defPlot
Text.length chart `shouldSatisfy` (> 0)
describe "box plot" $ do
it "should calculate quartiles correctly" $ do
let data1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
data2 = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
chart = boxPlot [("Group1", data1), ("Group2", data2)] defPlot
Text.length chart `shouldSatisfy` (> 0)
it "should handle single data point" $ do
let chart = boxPlot [("Single", [5.0])] defPlot
Text.length chart `shouldSatisfy` (> 0)
testHelperFunctions :: Spec
testHelperFunctions = do
describe "ANSI color functions" $ do
it "ansiOn should generate correct escape sequence" $ do
let red = ansiOn Red
red `shouldSatisfy` Text.isPrefixOf "\ESC["
red `shouldSatisfy` Text.isSuffixOf "m"
it "ansiOff should generate reset sequence" $ do
ansiOff `shouldBe` "\ESC[0m"
it "paint should colorize characters correctly" $ do
let painted = paint Red 'X'
painted `shouldSatisfy` Text.isInfixOf "X"
painted `shouldSatisfy` Text.isPrefixOf "\ESC["
it "paint should handle space characters specially" $ do
paint Red ' ' `shouldBe` " "
describe "clamp function (internal)" $ do
it "should constrain values to range" $ property $ \(x :: Double) ->
let clamped = max 0 (min 10 x)
in clamped >= 0 && clamped <= 10
describe "bounds calculation" $ do
it "should handle single point" $ do
let chart = scatter [series "single" [(5, 10)]] defPlot
Text.length chart `shouldSatisfy` (> 0)
testCanvasOperations :: Spec
testCanvasOperations = do
describe "plot dimensions" $ do
it "should respect widthChars setting" $ do
let narrow = defPlot{widthChars = 20}
wide = defPlot{widthChars = 100}
chartNarrow = scatter [series "test" [(1, 1)]] narrow
chartWide = scatter [series "test" [(1, 1)]] wide
let narrowLines = case Text.lines chartNarrow of
[] -> ""
(t : _) -> t
wideLines = case Text.lines chartWide of
[] -> ""
(t : _) -> t
if not (Text.null narrowLines) && not (Text.null wideLines)
then Text.length wideLines `shouldSatisfy` (> Text.length narrowLines)
else pendingWith "Charts generated empty lines"
it "should respect heightChars setting" $ do
let short = defPlot{heightChars = 5}
tall = defPlot{heightChars = 30}
chartShort = scatter [series "test" [(1, 1)]] short
chartTall = scatter [series "test" [(1, 1)]] tall
length (Text.lines chartTall) `shouldSatisfy` (> length (Text.lines chartShort))
testFormatting :: Spec
testFormatting = do
describe "default formatter" $ do
it "should format small numbers appropriately" $ do
let env = AxisEnv (0, 10) 0 5
formatted = fmt env 10 5.5
Text.length formatted `shouldSatisfy` (> 0)
it "should use scientific notation for large numbers" $ do
let env = AxisEnv (0, 100000) 0 5
formatted = fmt env 10 50000
formatted `shouldSatisfy` Text.isInfixOf "e"
it "should handle zero correctly" $ do
let env = AxisEnv (-1, 1) 0 5
formatted = fmt env 10 0
formatted `shouldSatisfy` (== "0.0")
describe "plot titles" $ do
it "should include title when set" $ do
let titled = defPlot{plotTitle = "My Test Chart"}
chart = scatter [series "data" [(1, 1)]] titled
chart `shouldSatisfy` Text.isInfixOf "My Test Chart"
testEdgeCases :: Spec
testEdgeCases = do
describe "boundary conditions" $ do
it "should handle zero-width plots gracefully" $ do
let zeroWidth = defPlot{widthChars = 0}
chart = scatter [series "test" [(1, 1)]] zeroWidth
Text.length chart `shouldSatisfy` (>= 0)
it "should handle zero-height plots gracefully" $ do
let zeroHeight = defPlot{heightChars = 0}
chart = scatter [series "test" [(1, 1)]] zeroHeight
Text.length chart `shouldSatisfy` (>= 0)
describe "data edge cases" $ do
it "should handle identical points" $ do
let identical = replicate 10 (5.0, 5.0)
chart = scatter [series "identical" identical] defPlot
Text.length chart `shouldSatisfy` (> 0)
it "should handle very large numbers" $ do
let large = [(1e10, 1e10), (2e10, 2e10)]
chart = scatter [series "large" large] defPlot
Text.length chart `shouldSatisfy` (> 0)
it "should handle very small numbers" $ do
let small = [(1e-10, 1e-10), (2e-10, 2e-10)]
chart = scatter [series "small" small] defPlot
Text.length chart `shouldSatisfy` (> 0)
describe "custom bounds" $ do
it "should respect manual x bounds" $ do
let bounded = defPlot{xBounds = (Just 0, Just 100)}
chart = scatter [series "test" [(50, 50)]] bounded
Text.length chart `shouldSatisfy` (> 0)
it "should respect manual y bounds" $ do
let bounded = defPlot{yBounds = (Just (-10), Just 10)}
chart = scatter [series "test" [(0, 0)]] bounded
Text.length chart `shouldSatisfy` (> 0)
describe "legend positioning" $ do
it "should handle bottom legend" $ do
let bottomLegend = defPlot{legendPos = LegendBottom}
chart = scatter [series "test" [(1, 1)]] bottomLegend
Text.length chart `shouldSatisfy` (> 0)
it "should handle right legend" $ do
let rightLegend = defPlot{legendPos = LegendRight}
chart = scatter [series "test" [(1, 1)]] rightLegend
Text.length chart `shouldSatisfy` (> 0)
propTests :: Spec
propTests = describe "Property-based tests" $ do
describe "QuickCheck properties" $ do
it "scatter plots should always produce non-empty output for non-empty data" $
property $ \(NonEmpty points) ->
let chart = scatter [series "prop test" (points :: [(Double, Double)])] defPlot
in Text.length chart > 0
it "bins should always have positive count and valid bounds" $
property $ \(Positive n) (a :: Double) (b :: Double) ->
let b' = bins n a b
in nBins b' >= 1 && lo b' <= hi b'
ansiCode :: Color -> Int
ansiCode Black = 30
ansiCode Red = 31
ansiCode Green = 32
ansiCode Yellow = 33
ansiCode Blue = 34
ansiCode Magenta = 35
ansiCode Cyan = 36
ansiCode White = 37
ansiCode BrightBlack = 90
ansiCode BrightRed = 91
ansiCode BrightGreen = 92
ansiCode BrightYellow = 93
ansiCode BrightBlue = 94
ansiCode BrightMagenta = 95
ansiCode BrightCyan = 96
ansiCode BrightWhite = 97
ansiCode Default = 39
ansiOn :: Color -> Text
ansiOn c = "\ESC[" <> Text.pack (show (ansiCode c)) <> "m"
ansiOff :: Text
ansiOff = "\ESC[0m"
paint :: Color -> Char -> Text
paint c ch = if ch == ' ' then " " else ansiOn c <> Text.singleton ch <> ansiOff
fmt :: AxisEnv -> Int -> Double -> Text
fmt _ _ v
| abs v >= 10000 || abs v < 0.01 && v /= 0 = Text.pack (showEFloat (Just 1) v "")
| otherwise = Text.pack (showFFloat (Just 1) v "")