packages feed

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 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+![Histogram](https://github.com/mchav/granite/blob/main/static/histogram.png)++```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