exchangealgebra-0.5.3.0: test/Posting/PostingSpec.hs
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}
-- | Validation, serialization, and algebraic laws for checked ledger postings.
module Posting.PostingSpec (runTests) where
import Control.DeepSeq (force, rnf)
import Control.Exception (TypeError, evaluate, try)
import Control.Monad (unless)
import qualified Data.Binary as Binary
import Data.Hashable (hash)
import System.Exit (exitFailure)
import Test.QuickCheck hiding (label)
import ExchangeAlgebra.Algebra hiding (map, filter)
import qualified ExchangeAlgebra.Posting as Posting
import ExchangeAlgebra.Posting
( Posted
, PostSide(..)
, Posting
, entry
, posted
, postedUpperBound
, postingAlg
, sideHat
, unPosted
)
import qualified Posting.NoNumPosted as NoNum
-- | Account and unit coordinates used for every algebraic property.
type TestBase = HatBase (AccountTitles, CountUnit)
-- | Values stay exact after all generated postings are added.
type TestEntry = (PostSide, Posted, BasePart TestBase)
-- | Generate accepted magnitudes across normal and subnormal exponents.
genAccepted :: Gen Double
genAccepted = frequency
[ (1, pure 0)
, (2, do
significand <- chooseInteger (1, 2 ^ (52 :: Int) - 1)
pure (encodeFloat significand (-1074)))
, (7, do
exponent <- chooseInt (-1022, 899)
significand <- chooseInteger (2 ^ (52 :: Int), 2 ^ (53 :: Int) - 1)
pure (encodeFloat significand (exponent - 52)))
, (1, pure postedUpperBound)
]
-- | Smart-constructor identity throughout its accepted domain.
propAcceptedIdentity :: Property
propAcceptedIdentity = forAll genAccepted $ \value ->
case posted value of
Left failure -> counterexample (show (value, failure)) False
Right checked -> counterexample (show value) (unPosted checked == value)
-- | Every validated posting survives reading and revalidation.
propPostedRoundTrip :: Property
propPostedRoundTrip = forAll genAccepted $ \value ->
case posted value of
Left failure -> counterexample (show failure) False
Right checked -> posted (unPosted checked) === Right checked
-- | Generate checked integer amounts without floating-point rounding in sums.
genExactPosted :: Gen Posted
genExactPosted = do
value <- chooseInteger (0, 1000)
case posted (fromInteger value) of
Right checked -> pure checked
Left failure -> error ("integer test amount rejected: " ++ show failure)
-- | Generate every concrete side and occasional wildcard coordinates.
genEntry :: Gen TestEntry
genEntry = do
side <- elements [HatSide, NotSide]
value <- genExactPosted
title <- elements [Cash, Products, Sales, wildcard]
unit <- elements [Yen, Amount, Dollar, wildcard]
pure (side, value, (title, unit))
-- | Generate queries with empty, duplicate, overlapping, partial, and full cases.
genQueries :: Gen [TestBase]
genQueries = frequency
[ (1, pure [])
, (2, (: []) <$> genQuery)
, (2, do
query <- genQuery
pure [query, query])
, (2, do
title <- elements [Cash, Products, Sales]
pure [Hat :< (title, Yen), HatNot :< (title, wildcard)])
, (2, do
title <- elements [Cash, Products, Sales]
unit <- elements [Yen, Amount, Dollar]
pure [Not :< (title, unit)])
, (1, pure [HatNot :< (wildcard, wildcard)])
, (2, do
count <- chooseInt (1, 4)
vectorOf count genQuery)
]
-- | Sample every query hat and wildcard positions independently.
genQuery :: Gen TestBase
genQuery = do
queryHat <- elements [Hat, Not, HatNot]
title <- elements [Cash, Products, Sales, wildcard]
unit <- elements [Yen, Amount, Dollar, wildcard]
pure (queryHat :< (title, unit))
-- | Generate posting lists and queries without forcing every projection to match.
genAlgebraCase :: Gen ([TestEntry], [TestBase])
genAlgebraCase = do
count <- chooseInt (0, 20)
entries <- vectorOf count genEntry
queries <- genQueries
pure (entries, queries)
-- | Convert one checked entry through the public constructor.
single :: TestEntry -> Posting TestBase
single (side, value, part) = entry side value part
-- | Ignore only the internal sequence order, preserving each posting's value and base.
sameMultiset :: Alg Double TestBase -> Alg Double TestBase -> Bool
sameMultiset left right = toASCList left == toASCList right
-- | IX-8a: projection distributes across the checked posting list.
propProjection :: Property
propProjection = forAll genAlgebraCase $ \(entries, queries) ->
let checked = postingAlg (foldMap single entries)
raw = foldr (.+) Zero [postingAlg (single item) | item <- entries]
projected = foldr (.+) Zero
[proj queries (postingAlg (single item)) | item <- entries]
in counterexample (show (entries, queries)) $
sameMultiset checked raw && sameMultiset (proj queries checked) projected
-- | Checked concatenation has both identities and is associative in the algebra.
propMonoid :: Property
propMonoid = forAll genAlgebraCase $ \(entries, _) ->
let (first, rest) = splitAt (length entries `div` 3) entries
(second, third) = splitAt (length rest `div` 2) rest
x = foldMap single first
y = foldMap single second
z = foldMap single third
zero = mempty :: Posting TestBase
in counterexample (show entries) $
sameMultiset (postingAlg (zero <> x)) (postingAlg x)
&& sameMultiset (postingAlg (x <> zero)) (postingAlg x)
&& sameMultiset (postingAlg ((x <> y) <> z)) (postingAlg (x <> (y <> z)))
-- | Conversion preserves a single append with the algebra's structural equality.
propConversion :: Property
propConversion = forAll genAlgebraCase $ \(entries, _) ->
let (leftEntries, rightEntries) = splitAt (length entries `div` 2) entries
left = foldMap single leftEntries
right = foldMap single rightEntries
in counterexample (show entries) $
postingAlg (left <> right) == (postingAlg left .+ postingAlg right)
-- | Fail an ordinary Boolean assertion through the same test harness.
assertTest :: String -> Bool -> IO ()
assertTest label success = unless success $ do
putStrLn ("[FAIL] ledger posting: " ++ label)
exitFailure
-- | Run a named QuickCheck property and fail the executable on a counterexample.
quickProperty :: Testable property => String -> property -> IO ()
quickProperty label proposition = do
result <- quickCheckWithResult stdArgs { maxSuccess = 200, chatty = False } proposition
unless (isSuccess result) $ do
putStrLn ("[FAIL] " ++ label ++ ": " ++ output result)
exitFailure
putStrLn ("[PASS] " ++ label)
-- | Check every validation boundary, including negative zero normalization.
testValidation :: IO ()
testValidation = do
let positiveInfinity = 1 / 0 :: Double
negativeInfinity = -1 / 0 :: Double
nanValue = 0 / 0 :: Double
nextAboveBound = postedUpperBound * (1 + 2 ** (-52))
smallestSubnormal = encodeFloat 1 (-1074) :: Double
assertTest "NaN rejected" (posted nanValue == Left Posting.NonFinite)
assertTest "positive Infinity rejected" $
posted positiveInfinity == Left Posting.NonFinite
assertTest "negative Infinity rejected" $
posted negativeInfinity == Left Posting.NonFinite
assertTest "negative finite value rejected" $
posted (-1e-300) == Left Posting.Negative
assertTest "next representable value above bound rejected" $
posted nextAboveBound == Left Posting.AboveBound
assertTest "2^1000 rejected" (posted (2 ** 1000) == Left Posting.AboveBound)
assertTest "zero accepted" (fmap unPosted (posted 0) == Right 0)
assertTest "negative zero normalized" $ case posted (-0.0) of
Right checked -> isPositiveZero (unPosted checked)
Left _ -> False
assertTest "smallest subnormal accepted" $
fmap unPosted (posted smallestSubnormal) == Right smallestSubnormal
assertTest "one accepted" (fmap unPosted (posted 1) == Right 1)
assertTest "upper bound accepted" $
fmap unPosted (posted postedUpperBound) == Right postedUpperBound
where
isPositiveZero value = value == 0 && isInfinite (1 / value) && 1 / value > 0
-- | Binary instances round-trip valid values and reject invalid Posted bytes.
testBinary :: IO ()
testBinary = do
let smallestSubnormal = encodeFloat 1 (-1074) :: Double
checkedValues =
[value | Right value <- map posted [0, smallestSubnormal, 1, postedUpperBound]]
sides = [HatSide, NotSide]
rejects value = case Binary.decodeOrFail (Binary.encode (value :: Double)) of
Left _ -> True
Right (_, _, (_ :: Posted)) -> False
decodedNegativeZero = Binary.decodeOrFail (Binary.encode (-0.0 :: Double))
assertTest "Posted Binary round trip" $
all (\value -> Binary.decode (Binary.encode value) == value) checkedValues
assertTest "PostSide Binary round trip" $
all (\side -> Binary.decode (Binary.encode side) == side) sides
assertTest "Posted Binary decoder validates" $
all rejects
[ 0 / 0
, 1 / 0
, -1 / 0
, -1e-300
, postedUpperBound * (1 + 2 ** (-52))
, 2 ** 1000
]
assertTest "Posted Binary normalizes negative zero" $ case decodedNegativeZero of
Left _ -> False
Right (_, _, (value :: Posted)) ->
unPosted value == 0 && 1 / unPosted value > 0
assertTest "NFData and Hashable instances" $
force checkedValues `seq` force sides `seq`
sum (map hash checkedValues) `seq` sum (map hash sides) `seq` True
assertTest "base component NFData instances" $
rnf (Cash :: AccountTitles) `seq`
rnf (Yen :: CountUnit) `seq`
rnf (AxisKey Cash) `seq`
rnf (Hat :: Hat) `seq`
rnf (BaseForSingleHat :: BaseForSingleHat) `seq`
rnf (PS :: PIMO) `seq`
rnf (Assets :: AccountDivision) `seq`
rnf (Credit :: Side) `seq`
rnf (Current :: FixedCurrent) `seq`
rnf (CloseByDivision :: ClosingRule) `seq`
rnf (ContraAccount :: AccountRole) `seq`
rnf (OrdinaryPosting :: PostingCapability) `seq`
rnf (StatementDivision Assets :: DivisionSemantics) `seq`
rnf (FixedHomeSide Credit :: HomeSideSemantics) `seq`
rnf (StatementEligible :: ReportingEligibility) `seq`
rnf (Not :< (Yen, Cash) :: HatBase (CountUnit, AccountTitles)) `seq`
True
-- | Both posting sides map to concrete Hat values only.
testSides :: IO ()
testSides = do
assertTest "HatSide maps to Hat" (sideHat HatSide == Hat)
assertTest "NotSide maps to Not" (sideHat NotSide == Not)
assertTest "PostSide excludes HatNot" $
all ((/= HatNot) . sideHat) [minBound .. maxBound]
-- | Three equal-base entries expose structural ordering without breaking the multiset law.
testAssociativityRegression :: IO ()
testAssociativityRegression = case traverse posted [1, 2, 3] of
Left failure -> do
putStrLn ("[FAIL] ledger posting fixture: " ++ show failure)
exitFailure
Right [first, second, third] -> do
let part = (Cash, Yen)
x = entry HatSide first part :: Posting TestBase
y = entry HatSide second part :: Posting TestBase
z = entry HatSide third part :: Posting TestBase
left = postingAlg ((x <> y) <> z)
right = postingAlg (x <> (y <> z))
assertTest "equal-base grouping changes structural order" (left /= right)
assertTest "equal-base grouping preserves the multiset" (sameMultiset left right)
Right _ -> assertTest "three checked fixture values" False
-- | Fixed queries cover each Hat value and both coordinate wildcard positions.
testQueryCoverage :: IO ()
testQueryCoverage = case posted 3 of
Left failure -> do
putStrLn ("[FAIL] ledger posting fixture: " ++ show failure)
exitFailure
Right value -> do
let entries =
[ (HatSide, value, (Cash, Yen))
, (NotSide, value, (Products, Amount))
, (HatSide, value, (Sales, Dollar))
]
queries =
[ Hat :< (Cash, wildcard)
, Not :< (wildcard, Amount)
, HatNot :< (Sales, wildcard)
]
checked = postingAlg (foldMap single entries)
projected = foldr (.+) Zero
[proj queries (postingAlg (single item)) | item <- entries]
assertTest "three query hats and coordinate wildcards" $
sameMultiset (proj queries checked) projected
-- | Deferred errors prove that clients cannot request Num for Posted.
testNoNum :: IO ()
testNoNum = do
literal <- try (evaluate NoNum.literalPosted) :: IO (Either TypeError Posted)
assertTest "Posted numeric literal is rejected" (isTypeError literal)
case posted 1 of
Left failure -> do
putStrLn ("[FAIL] ledger posting fixture: " ++ show failure)
exitFailure
Right checked -> do
arithmetic <- try (evaluate (NoNum.addPosted checked))
:: IO (Either TypeError Posted)
assertTest "Posted arithmetic is rejected" (isTypeError arithmetic)
generic <- try (evaluate (NoNum.genericPosted checked))
:: IO (Either TypeError ())
assertTest "Posted Generic is rejected" (isTypeError generic)
container <- try (evaluate (NoNum.addPosting (mempty :: Posting TestBase)))
:: IO (Either TypeError (Posting TestBase))
assertTest "Posting arithmetic is rejected" (isTypeError container)
where
isTypeError (Left _) = True
isTypeError (Right _) = False
-- | Register checked-posting tests with ExchangeAlgebra-test.
runTests :: IO ()
runTests = do
testValidation
testBinary
testSides
testAssociativityRegression
testQueryCoverage
testNoNum
quickProperty "V-1 accepted-domain identity" propAcceptedIdentity
quickProperty "validated posting round trip" propPostedRoundTrip
quickProperty "IX-8a projection and raw conversion" propProjection
quickProperty "Posting monoid laws" propMonoid
quickProperty "Posting conversion preserves structural append" propConversion
putStrLn "[PASS] checked ledger posting"