packages feed

granite-0.5.0.0: test/PositionSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module PositionSpec (spec) where

import Data.Text qualified as Text

import Granite.Data.Frame (
    Column (..),
    DataFrame (..),
    fromColumns,
    lookupColumn,
 )
import Granite.Position (applyPosition)
import Granite.Spec (
    ColumnRef (..),
    Mapping (..),
    Position (..),
    emptyMapping,
 )
import Test.Hspec

-- Long-format fixture: two X values, two series ("A", "B"), totals 30
-- at X=1 and 40 at X=2.
sampleFrame :: DataFrame
sampleFrame =
    fromColumns
        [ ("x", ColNum [1, 1, 2, 2])
        , ("y", ColNum [10, 20, 15, 25])
        , ("series", ColCat ["A", "B", "A", "B"])
        ]

sampleMapping :: Mapping
sampleMapping =
    emptyMapping
        { aesX = Just (ColumnRef "x")
        , aesY = Just (ColumnRef "y")
        , aesGroup = Just (ColumnRef "series")
        }

spec :: Spec
spec = describe "Granite.Position" $ do
    describe "PosIdentity" $ do
        it "leaves the frame unchanged" $
            applyPosition PosIdentity sampleMapping sampleFrame
                `shouldBe` sampleFrame

    describe "PosStack" $ do
        let stacked = applyPosition PosStack sampleMapping sampleFrame

        it "produces the same number of rows" $ do
            let DataFrame cols = stacked
            case lookup "x" cols of
                Just (ColNum xs) -> length xs `shouldBe` 4
                _ -> expectationFailure "x column missing"

        it "stacks y values cumulatively within each x" $ do
            case lookupColumn "y" stacked of
                Just (ColNum ys) -> ys `shouldBe` [10, 30, 15, 40]
                _ -> expectationFailure "y column missing"

        it "writes __ybase column with segment bottoms" $ do
            case lookupColumn "__ybase" stacked of
                Just (ColNum bs) -> bs `shouldBe` [0, 10, 0, 15]
                _ -> expectationFailure "__ybase column missing"

    describe "PosFill" $ do
        let filled = applyPosition PosFill sampleMapping sampleFrame

        it "normalises every x-group to sum to 1" $ do
            case lookupColumn "y" filled of
                Just (ColNum ys) ->
                    let near a b = abs (a - b) < 1e-9
                        -- (10/30, (10+20)/30, 15/40, (15+25)/40)
                        expected = [1 / 3, 1, 3 / 8, 1]
                     in and (zipWith near ys expected) `shouldBe` True
                _ -> expectationFailure "y column missing"

    describe "PosDodge" $ do
        let dodged = applyPosition (PosDodge 0.3) sampleMapping sampleFrame

        it "preserves row count" $ do
            case lookupColumn "x" dodged of
                Just (ColNum xs) -> length xs `shouldBe` 4
                _ -> expectationFailure "x column missing"

        it "spreads two series symmetrically around the original x" $ do
            -- nSeries=2, center=0.5, gap=0.3; series A gets -0.15, B gets +0.15
            case lookupColumn "x" dodged of
                Just (ColNum xs) -> xs `shouldBe` [0.85, 1.15, 1.85, 2.15]
                _ -> expectationFailure "x column missing"

    describe "PosJitter" $ do
        let jittered = applyPosition (PosJitter 0.2 0.5) sampleMapping sampleFrame

        it "preserves row count" $ do
            case lookupColumn "x" jittered of
                Just (ColNum xs) -> length xs `shouldBe` 4
                _ -> expectationFailure "x column missing"

        it "is deterministic across runs" $ do
            let again = applyPosition (PosJitter 0.2 0.5) sampleMapping sampleFrame
            jittered `shouldBe` again

        it "keeps offsets within the requested amplitude" $ do
            case (lookupColumn "x" jittered, lookupColumn "y" jittered) of
                (Just (ColNum xs), Just (ColNum ys)) -> do
                    -- amplitude is ±dx and ±dy; allow a tiny epsilon
                    all (\(a, b) -> abs (a - b) <= 0.2 + 1e-9) (zip xs [1, 1, 2, 2])
                        `shouldBe` True
                    all (\(a, b) -> abs (a - b) <= 0.5 + 1e-9) (zip ys [10, 20, 15, 25])
                        `shouldBe` True
                _ -> expectationFailure "x / y missing"

    describe "edge cases" $ do
        it "stack with no group column treats everything as one series" $ do
            let m = emptyMapping{aesX = Just (ColumnRef "x"), aesY = Just (ColumnRef "y")}
                df =
                    fromColumns
                        [ ("x", ColNum [1, 1, 2])
                        , ("y", ColNum [5, 10, 7])
                        ]
                stacked = applyPosition PosStack m df
            case lookupColumn "y" stacked of
                Just (ColNum ys) -> ys `shouldBe` [5, 15, 7]
                _ -> expectationFailure "y missing"

        it "frame without the mapped x/y is returned unchanged for stack" $ do
            let bare = fromColumns [("not_x", ColNum [1, 2, 3])]
                m = emptyMapping{aesX = Just (ColumnRef "x"), aesY = Just (ColumnRef "y")}
            applyPosition PosStack m bare `shouldBe` bare

        it "compiles against the Text import" $
            Text.length "ok" `shouldBe` 2