packages feed

exchangealgebra-0.5.3.0: test/Posting/SettleSpec.hs

{-# LANGUAGE ScopedTypeVariables #-}

-- | Ordered settlement pairs preserve source nets and exact accounting balance.
module Posting.SettleSpec (runTests) where

import Control.Monad (forM_, unless)
import Data.List (foldl', sort)
import qualified Data.Map.Strict as Map
import System.Exit (exitFailure)
import Test.QuickCheck hiding (label)

import ExchangeAlgebra.Algebra hiding (filter, map)
import ExchangeAlgebra.Algebra.Transfer.Rule

-- | Two coordinates exercise complete-base ordering and axis preservation.
type TestBase = HatBase (CountUnit, AccountTitles)

-- | Signed input amounts indexed by complete base coordinates.
type Nets = Map.Map (CountUnit, AccountTitles) Double

-- | Cover zero, both signs, fractions, subnormals, and amounts above Posted.
genNet :: Gen Double
genNet = do
    magnitude <- frequency
        [ (3, elements [0, 0.1, 0.2, 1, 2 ^ (53 :: Int), 2 ^ (950 :: Int)
                       , encodeFloat 1 (-1074), encodeFloat (2 ^ (53 :: Int) - 1) 971])
        , (2, do
              significand <- chooseInteger (1, 2 ^ (53 :: Int) - 1)
              exponent <- chooseInt (-1074, 971)
              pure (encodeFloat significand exponent))
        ]
    sign <- elements [1, -1]
    pure (sign * magnitude)

-- | Include closing, non-closing, and destination accounts on several units.
genNets :: Gen Nets
genNets = do
    count <- chooseInt (0, 30)
    entries <- vectorOf count $ do
        unit <- elements [Yen, Dollar, Amount]
        title <- elements [Sales, Purchases, Depreciation, Cash, RetainedEarnings, NetIncome]
        amount <- genNet
        pure ((unit, title), amount)
    pure (Map.fromList entries)

-- | Read scalar entries without cancellation or approximate comparisons.
scalars :: Alg Double TestBase -> [(Double, TestBase)]
scalars = foldEntries (\previous value postingBase -> (value, postingBase) : previous) []

-- | The exact signed net at one complete base.
netAt :: (CountUnit, AccountTitles) -> Alg Double TestBase -> Rational
netAt coordinates = foldEntries add 0
  where
    add total value postingBase
        | base postingBase /= coordinates = total
        | hat postingBase == Not = total + toRational value
        | otherwise = total - toRational value

-- | Sum magnitudes as rationals rather than through floating-point norm.
exactMagnitude :: Alg Double TestBase -> Rational
exactMagnitude = foldEntries (\total value _ -> total + toRational value) 0

-- | The key set, pair structure, signs, and debit/credit equality hold exactly.
propSettlement :: Property
propSettlement = forAll genNets $ \amounts ->
    case settleEntries retainedEarningsRule amounts of
      Left failure -> counterexample (show failure) False
      Right batch -> checkSettlement amounts (settlementSteps batch)

-- | Check each generated pair with exact Rational accounting readouts.
checkSettlement :: Nets -> [((CountUnit, AccountTitles), Alg Double TestBase)] -> Property
checkSettlement amounts steps =
    let expectedKeys = Map.keys (Map.filterWithKey eligible amounts)
        keys = map fst steps
        checkPair (coordinates@(unit, title), algebra) =
            let amount = Map.findWithDefault 0 coordinates amounts
                sourceSide = sideFor amount
                reverseSide = revHat sourceSide
                targetSide = case closingSide title of
                    Just ClosingKeep -> sourceSide
                    _                -> reverseSide
                expected = sort
                    [ (abs amount, reverseSide :< coordinates)
                    , (abs amount, targetSide :< (unit, RetainedEarnings))
                    ]
                valid (value, postingBase) =
                    value >= 0 && not (isNaN value || isInfinite value)
                    && hat postingBase /= HatNot
            in conjoin
                [ sort (scalars algebra) === expected
                , length (scalars algebra) === 2
                , netAt coordinates algebra + toRational amount === 0
                , exactMagnitude (decL algebra) === exactMagnitude (decR algebra)
                , property (all valid (scalars algebra))
                ]
    in conjoin
        [ keys === expectedKeys
        , property (and (zipWith (<) keys (drop 1 keys)))
        , conjoin (map checkPair steps)
        ]
  where
    eligible (_, title) amount = amount /= 0 && title /= RetainedEarnings
        && closingSide title /= Nothing
    sideFor amount
        | amount > 0 = Not
        | otherwise  = Hat

-- | Report fixed regression failures through the ordinary test executable.
assertTest :: String -> Bool -> IO ()
assertTest label success = unless success $ do
    putStrLn ("[FAIL] settlement: " ++ label)
    exitFailure

-- | Exclusion, both directions, and large finite magnitudes are deterministic.
testFixed :: IO ()
testFixed = do
    let steps amounts = fmap settlementSteps
            (settleEntries retainedEarningsRule amounts ::
                Either (SettleError TestBase) (SettlementBatch TestBase))
        excluded = Map.fromList
            [ ((Yen, Sales), 0)
            , ((Dollar, Purchases), -0.0)
            , ((Yen, Cash), 10)
            , ((Yen, RetainedEarnings), 12)
            , ((Yen, NetIncome), 5)
            ]
    assertTest "empty input" (fmap null (steps Map.empty) == Right True)
    assertTest "only excluded keys" (fmap null (steps excluded) == Right True)
    assertTest "known keep and flip accounts" $
        closingSide Sales == Just ClosingKeep && closingSide Purchases == Just ClosingFlip
    forM_ [Sales, Purchases] $ \title ->
        forM_ [1, -1] $ \sign -> do
            let magnitude = 2 ^ (950 :: Int)
                amount = sign * magnitude
                coordinates = (Yen, title)
                result = steps (Map.singleton coordinates amount)
            assertTest "finite 2^950 accepted without Posted validation" $
                case result of
                    Right [(source, algebra)] -> source == coordinates
                        && length (scalars algebra) == 2
                        && all ((== magnitude) . fst) (scalars algebra)
                        && netAt source algebra == negate (toRational amount)
                        && exactMagnitude (decL algebra) == exactMagnitude (decR algebra)
                    _                         -> False
    -- Select three ClosingKeep accounts by their actual key order so the
    -- destination increments are T, 1, -T, independent of enum ordering.
    let titles = take 3 [title | title <- [minBound .. maxBound]
                             , closingSide title == Just ClosingKeep]
        sourceKeys = sort [(Yen, title) | title <- titles]
        large = 2 ^ (53 :: Int)
        result = steps (Map.fromList (zip sourceKeys [large, 1, -large]))
        increments = fmap
            (map (\(_, algebra) ->
                fromRational (netAt (Yen, RetainedEarnings) algebra) :: Double))
            result
    assertTest "three distinct keep accounts in fixture" (length sourceKeys == 3)
    assertTest "sequential destination increments preserve source order" $
        increments == Right [large, 1, -large]
        && fmap (foldl' (+) 0) increments == Right 0
        && foldl' (+) 0 [large, -large, 1] == 1

-- | All input keys obey the finite-input contract, even excluded accounts.
testNonFinite :: IO ()
testNonFinite = forM_ [0 / 0, 1 / 0, -1 / 0] $ \amount ->
    forM_ [Sales, Cash, RetainedEarnings] $ \title -> do
        let first = (Dollar, title)
            second = (Yen, Sales)
            result = settleEntries retainedEarningsRule
                (Map.fromList [(second, amount), (first, amount)])
                :: Either (SettleError TestBase) (SettlementBatch TestBase)
        assertTest "first non-finite key, including excluded keys" $ case result of
            Left (NonFiniteNet coordinates) -> coordinates == min first second
            Right _                          -> False
        assertTest "excluded non-finite key alone is rejected" $ case
            (settleEntries retainedEarningsRule (Map.singleton (Dollar, Cash) amount)
                :: Either (SettleError TestBase) (SettlementBatch TestBase)) of
            Left (NonFiniteNet coordinates) -> coordinates == (Dollar, Cash)
            Right _                          -> False

-- | Run generated laws and boundary regressions with the main test suite.
runTests :: IO ()
runTests = do
    testFixed
    testNonFinite
    result <- quickCheckWithResult stdArgs { maxSuccess = 500, chatty = False } propSettlement
    unless (isSuccess result) $ do
        putStrLn ("[FAIL] settlement properties: " ++ output result)
        exitFailure
    putStrLn "[PASS] settlement (order, keys, exact pairs, sides, axes, bounds, and errors)"