granite 0.3.0.0 → 0.3.0.1
raw patch · 7 files changed
+352/−313 lines, 7 filesdep −randomPVP ok
version bump matches the API change (PVP)
Dependencies removed: random
API changes (from Hackage documentation)
Files
- CHANGELOG.md +4/−0
- README.md +32/−5
- app/Main.hs +6/−9
- granite.cabal +1/−2
- src/Granite.hs +31/−23
- test/GraniteSpec.hs +278/−274
- test/Spec.hs +0/−0
CHANGELOG.md view
@@ -1,5 +1,9 @@ # Revision history for granite +## 0.3.0.1 -- 2025-09-17+* Left edge of plots now has an elbow instead of disjoint bars.+* Remove dependency on random.+ ## 0.3.0.0 -- 2025-08-31 * Export a `LabelFormatter` function along with AxisEnv to define smarter labels. * Change API of formatter to use AxisEnv and slot budget.
README.md view
@@ -1,5 +1,5 @@ # Granite-A library for producing terminal plots. It depends only on Haskell's base and text (only for efficieny but it could use `String`. In fact, we expose an API that uses `String` for easier use in GHCi) packages so it should be easy to use and install.+A library for producing terminal plots. It depends only on Haskell's base and text (only for efficiency but it could use `String`. In fact, we expose an API that uses `String` for easier use in GHCi) packages so it should be easy to use and install. # Supported graph types @@ -89,10 +89,10 @@ main :: IO () main = T.putStrLn $ boxPlot [ ("Class A", [78, 82, 85, 88, 90, 92, 85, 87, 89, 91, 76, 94, 88])- , ("Class B", [70, 75, 72, 80, 85, 78, 82, 77, 79, 81, 74, 83])- , ("Class C", [88, 92, 95, 90, 93, 89, 91, 94, 96, 87, 90, 92])- , ("Class D", [65, 70, 72, 68, 75, 80, 73, 71, 69, 74, 77, 76])- ] defPlot {plotTitle="Test Score Distribution by Class"}+ , ("Class B", [70, 75, 72, 80, 85, 78, 82, 77, 79, 81, 74, 83])+ , ("Class C", [88, 92, 95, 90, 93, 89, 91, 94, 96, 87, 90, 92])+ , ("Class D", [65, 70, 72, 68, 75, 80, 73, 71, 69, 74, 77, 76])+ ] defPlot {plotTitle="Test Score Distribution by Class"} ``` ### Line graph@@ -129,4 +129,31 @@ , [0.1, 0.2, 0.4, 0.7, 1.0] ] T.putStrLn $ heatmap matrix defPlot {plotTitle="Correlation Matrix"}+```++### Histogram+++```haskell+{-# LANGUAGE OverloadedStrings #-}+import qualified Data.Text.IO as T++import Granite++main :: IO ()+main = do+ heights <- replicateM 5000 (uniformRM (160 :: Double, 190 :: Double) g)+ Text.putStrLn $+ histogram+ (bins 30 155 195)+ heights+ defPlot+ { widthChars = 68+ , heightChars = 18+ , legendPos = LegendBottom+ , xFormatter = \_ _ v -> Text.pack (show (round v :: Int))+ , xNumTicks = 10+ , yNumTicks = 5+ , plotTitle = "Heights (cm)"+ } ```
app/Main.hs view
@@ -5,23 +5,20 @@ import Control.Monad import Data.Text qualified as Text import Data.Text.IO qualified as Text-import System.Random.Stateful import Granite main :: IO () main = do -- scatter- g <- newIOGenM =<< newStdGen- let range = (0 :: Double, 1 :: Double)- ptsA_x <- replicateM 600 (uniformRM range g)- ptsA_y <- replicateM 600 (uniformRM range g)- ptsB_x <- replicateM 600 (uniformRM range g)- ptsB_y <- replicateM 600 (uniformRM range g)+ let ptsA_x = [0..599]+ let ptsA_y = [0..599]+ let ptsB_x = [0..599]+ let ptsB_y = [599,598..0] Text.putStrLn $ scatter [series "A" (zip ptsA_x ptsA_y), series "B" (zip ptsB_x ptsB_y)] defPlot{widthChars = 68, heightChars = 22, plotTitle = "Random points"} -- histogram- heights <- replicateM 5000 (uniformRM (160 :: Double, 190 :: Double) g)+ let heights = [100..200] Text.putStrLn $ histogram (bins 30 155 195)@@ -32,7 +29,7 @@ , legendPos = LegendBottom , xFormatter = \_ _ v -> Text.pack (show (round v :: Int)) , xNumTicks = 10- , yNumTicks = 10+ , yNumTicks = 5 , plotTitle = "Heights (cm)" }
granite.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: granite-version: 0.3.0.0+version: 0.3.0.1 synopsis: Easy terminal plotting. description: A terminal plotting library for quick and easy visualisation. license: MIT@@ -49,7 +49,6 @@ build-depends: base >=4 && <5, granite ^>= 0.3,- random >= 1.2 && < 1.3, text >= 1 && < 3 hs-source-dirs: app default-language: GHC2021
src/Granite.hs view
@@ -392,7 +392,7 @@ | y <- [0 .. hC - 1] ] - ax = axisifyGrid cfg grid (0, fromIntegral (max 1 nCats)) (0, vmax)+ ax = axisifyGrid cfg grid (0, fromIntegral (max 1 nCats)) (0, vmax) (map fst kvs) (fmap (+ 1) (safeHead widths)) legendWidth = leftMargin cfg + 1 + gridWidth grid legend = legendBlock@@ -465,7 +465,7 @@ grid = [[col !! y | col <- columns] | y <- [0 .. hC - 1]] ax :: Text- ax = axisifyGrid cfg grid (0, fromIntegral (max 1 nCats)) (0, maxHeight)+ ax = axisifyGrid cfg grid (0, fromIntegral (max 1 nCats)) (0, maxHeight) (map fst categories) (fmap (+ 1) (safeHead widths)) legend :: Text legend = legendBlock@@ -547,7 +547,7 @@ | y <- [0 .. hC - 1] ] - ax = axisifyGrid cfg grid (a, b) (0, fromIntegral (maximum (1 : counts)))+ ax = axisifyGrid cfg grid (a, b) (0, fromIntegral (maximum (1 : counts))) [] Nothing legendWidth = leftMargin cfg + 1 + gridWidth grid legend = legendBlock (legendPos cfg) legendWidth [("count", Solid, BrightCyan)] in drawFrame cfg ax legend@@ -672,7 +672,7 @@ | i <- [0 .. plotH - 1] ] - ax = axisifyGrid cfg displayGrid (0, fromIntegral cols - 1) (0, fromIntegral rows - 1)+ ax = axisifyGrid cfg displayGrid (0, fromIntegral cols - 1) (0, fromIntegral rows - 1) [] (Just (plotW `div` cols)) gradientLegend = Text.pack (printf "%.2f " vmin)@@ -752,7 +752,7 @@ finalGrid = List.foldl' drawBox emptyGrid (zip [0 ..] stats) - ax = axisifyGrid cfg finalGrid (0, fromIntegral nBoxes) (ymin, ymax)+ ax = axisifyGrid cfg finalGrid (0, fromIntegral nBoxes) (ymin, ymax) (map fst datasets) (Just (boxWidth + spacing)) legend = legendBlock (legendPos cfg)@@ -935,14 +935,11 @@ | otherwise = Text.pack (showFFloat (Just 1) v "") drawFrame :: Plot -> Text -> Text -> Text-drawFrame _cfg contentWithAxes legendBlockStr =+drawFrame cfg contentWithAxes legendBlockStr = Text.unlines $ filter (not . Text.null)- ( [plotTitle _cfg | not (Text.null (plotTitle _cfg))]- <> [contentWithAxes]- <> [legendBlockStr | not (Text.null legendBlockStr)]- )+ [plotTitle cfg, contentWithAxes, legendBlockStr] {- | Evenly spaced tick positions in screen space paired with data values. If invertY = True, 0 maps to ymax (top row) and 1 maps to ymin (bottom).@@ -1008,7 +1005,7 @@ canvasLines = Text.lines (renderCanvas c) attachY = zipWith (\lbl line -> lbl <> "│" <> line) yLabels canvasLines - xBar = pad <> "│" <> Text.replicate plotW "─"+ xBar = pad <> "└" <> Text.replicate plotW "─" xTicks :: [(Int, Double)] xTicks = ticks1D plotW (xNumTicks cfg) (xmin, xmax) False@@ -1022,8 +1019,8 @@ [(x, xFormatter cfg (xEnv x) slotW v) | (x, v) <- xTicks] in Text.unlines (attachY <> [xBar, xLine]) -axisifyGrid :: Plot -> [[(Char, Maybe Color)]] -> (Double, Double) -> (Double, Double) -> Text-axisifyGrid cfg grid (xmin, xmax) (ymin, ymax) =+axisifyGrid :: Plot -> [[(Char, Maybe Color)]] -> (Double, Double) -> (Double, Double) -> [Text] -> Maybe Int -> Text+axisifyGrid cfg grid (xmin, xmax) (ymin, ymax) categories w = let plotH = length grid plotW = gridWidth grid left = leftMargin cfg@@ -1046,21 +1043,22 @@ yTicks renderRow :: [(Char, Maybe Color)] -> Text- renderRow cells =- Text.concat (fmap (\(ch, mc) -> maybe (Text.singleton ch) (`paint` ch) mc) cells)+ renderRow cells = Text.concat (fmap (\(ch, mc) -> maybe (Text.singleton ch) (`paint` ch) mc) cells) attachY = zipWith (\lbl cells -> lbl <> "│" <> renderRow cells) yLabels grid - xBar = pad <> "│" <> Text.replicate plotW "─"+ xBar = pad <> "└" <> Text.replicate plotW "─" - xTicks = ticks1D plotW (xNumTicks cfg) (xmin, xmax) False- xEnv n = AxisEnv (xmin, xmax) n 3- slotW = slotBudget plotW (max 1 (length xTicks))+ slotW = fromMaybe (slotBudget plotW (max 1 ((if hasCategories then length categories else xNumTicks cfg)))) w+ nSlots = plotW `div` slotW+ hasCategories = not (null (filter (not . Text.null) categories))+ xTicks = ticks1D plotW nSlots (xmin, xmax) False+ xEnv n = AxisEnv (xmin, xmax) n nSlots xLine =- placeLabels- (Text.replicate (left + 1 + plotW) " ")- (left + 1)- [(x, xFormatter cfg (xEnv x) slotW v) | (x, v) <- xTicks]+ placeGridLabels+ (Text.replicate (left + 1) " ")+ slotW+ (if hasCategories then categories else [xFormatter cfg (xEnv i) slotW v | (i, (_, v)) <- zip [0 ..] xTicks]) in Text.unlines (attachY <> [xBar, xLine]) placeLabels :: Text -> Int -> [(Int, Text)] -> Text@@ -1071,6 +1069,12 @@ let i = off + x in Text.take i acc <> s <> Text.drop (i + wcswidth s) acc +placeGridLabels :: Text -> Int -> [Text] -> Text+placeGridLabels base slotW = List.foldl' place base+ where+ place :: Text -> Text -> Text+ place acc s = acc <> Text.take slotW (s <> (Text.replicate slotW " "))+ legendBlock :: LegendPos -> Int -> [(Text, Pat, Color)] -> Text legendBlock LegendBottom width entries = let cells = [sample pat col <> " " <> name | (name, pat, col) <- entries]@@ -1212,6 +1216,10 @@ minimum' xs = minimum xs maximum' [] = 1 maximum' xs = maximum xs++safeHead :: [a] -> Maybe a+safeHead [] = Nothing+safeHead (x : _) = Just x -- AVL Tree we'll use as an array. -- This improves upon the previous implementation that relies
test/GraniteSpec.hs view
@@ -3,317 +3,324 @@ module GraniteSpec (spec) where -import Test.Hspec-import Test.QuickCheck import Data.Text (Text)-import qualified Data.Text as Text-import Numeric (showEFloat, showFFloat)+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 "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 "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 - 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+ 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 "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+ 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 "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 "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) - 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)+ it "should handle empty data gracefully" $ do+ let chart = scatter [] 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"+ 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" - 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)+ 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 "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 "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) - 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)+ 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 "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)+ 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 "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" - 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)+ 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 = Text.lines chartNarrow- wideLines = Text.lines chartWide- if not (null narrowLines) && not (null wideLines)- then Text.length (head wideLines) `shouldSatisfy` (> Text.length (head 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))+ 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 "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) - 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"+ 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 "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) - 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)+ 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 "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 "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) - 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)+ it "should handle very large numbers" $ do+ let large = [(1e10, 1e10), (2e10, 2e10)]+ chart = scatter [series "large" large] defPlot+ Text.length chart `shouldSatisfy` (> 0) -propTests :: Spec + 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'+ 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@@ -346,6 +353,3 @@ fmt _ _ v | abs v >= 10000 || abs v < 0.01 && v /= 0 = Text.pack (showEFloat (Just 1) v "") | otherwise = Text.pack (showFFloat (Just 1) v "")--runTests :: IO ()-runTests = hspec spec
test/Spec.hs view