packages feed

exchangealgebra-0.5.1.0: test/Transfer/RuleSpec.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Laws, legacy regressions and multi-period acceptance for transfer entries.
module Transfer.RuleSpec (runTests) where

import           Control.Monad (unless)
import qualified Data.Binary as Binary
import qualified Data.HashMap.Strict as HashMap
import           Data.List (permutations)
import qualified Data.Map.Strict as Map
import           Data.Proxy (Proxy(..))
import           System.Exit (exitFailure)
import           Test.QuickCheck hiding (label, sample)
import           ExchangeAlgebra.Algebra hiding (map, filter)
import qualified ExchangeAlgebra.Algebra as Algebra
import qualified ExchangeAlgebra.Algebra.Transfer as Legacy
import           ExchangeAlgebra.Algebra.Transfer.Rule
import qualified ExchangeAlgebra.Journal as Journal
import           ExchangeAlgebra.Journal ((.|), Note(..))
import qualified ExchangeAlgebra.Journal.Transfer.Rule as JournalRule
import           ExchangeAlgebra.Value (MoneyDecimal)

-- | Concrete account and unit coordinates for compatibility properties.
type TestBase = HatBase (AccountTitles, CountUnit)

-- The library's two-axis account instance uses the opposite coordinate order.
-- This test-only instance also keeps the historical miss fixture unchanged.
instance ExBaseClass TestBase where
    getAccountTitle (_ :< (title, _)) = title
    setAccountTitle (postingHat :< (_, unit)) title = postingHat :< (title, unit)

-- | Integer posting quantities bounded away from zero for legacy compatibility.
type TestQuantity = Integer

-- | Generated flat postings and one permitted scale coefficient.
data CompatibilityCase = CompatibilityCase [(TestQuantity, TestBase)] Rational
    deriving (Show)

-- | Generate P1-P5-compatible inputs; every rule table includes HatNot,
-- Relabel, MulBy and DivBy, with uniform wildcard positions.
genCompatibilityCase :: Gen CompatibilityCase
genCompatibilityCase = do
    count <- chooseInt (1, 35)
    entries <- vectorOf count $ do
        quantity <- chooseInteger (1, 1000000)
        postingHat <- elements [Hat, Not]
        title <- elements [Cash, Products, Sales, Depreciation]
        unit <- elements [Yen, Amount, Dollar]
        pure (quantity, postingHat :< (title, unit))
    coefficient <- elements [2, 3, 1 / 2, 4]
    pure (CompatibilityCase entries coefficient)

-- | Translate the generated scale without converting division into a reciprocal.
caseRules :: Fractional v => CompatibilityCase -> [TransferRule v TestBase]
caseRules (CompatibilityCase _ coefficient) =
    [ relabel (HatNot :< (Cash, wildcard)) (HatNot :< (Deposits, wildcard))
    , scaleBy (HatNot :< (Products, wildcard))
              (HatNot :< (RawMaterials, wildcard)) (fromRational coefficient)
    , divideBy (HatNot :< (Sales, wildcard))
               (HatNot :< (RetainedEarnings, wildcard)) (fromRational coefficient)
    ]

-- | Construct ordinary non-negative postings through the public smart constructor.
caseAlgebra :: HatVal v => CompatibilityCase -> Alg v TestBase
caseAlgebra (CompatibilityCase entries _) =
    Algebra.fromList [fromInteger quantity .@ postingBase | (quantity, postingBase) <- entries]

-- | Translate the data operations to the legacy function-valued table.
legacyTable :: (HatVal v, HatBaseClass b) => [TransferRule v b] -> Legacy.TransTable v b
legacyTable rules = Legacy.table
    [ (ruleFrom rule, ruleTo rule, operation (ruleScale rule)) | rule <- rules ]
  where
    operation Relabel = id
    operation (MulBy coefficient) = (* coefficient)
    operation (DivBy coefficient) = (/ coefficient)

-- | Law observation: complete base including Hat/Not to value, after bar.
obs :: (HatVal v, HatBaseClass b) => Alg v b -> Map.Map b v
obs = rawObservation . bar

-- | Observe raw postings without discarding any cancellation entries.
rawObservation :: (HatVal v, HatBaseClass b) => Alg v b -> Map.Map b v
rawObservation = foldEntries (\result value postingBase -> Map.insertWith (+) postingBase value result) Map.empty

-- | Relative 1e-9 comparison, with missing bases interpreted as zero.
nearObservations :: (HatVal v, Ord b) => Map.Map b v -> Map.Map b v -> Bool
nearObservations left right = all close (Map.keys (Map.union left right))
  where
    close postingBase =
        let first = Map.findWithDefault zeroValue postingBase left
            second = Map.findWithDefault zeroValue postingBase right
            difference = max first second - min first second
        in difference <= 1e-9 * max first second

-- | L1 compares every resulting base, not only the overall norm.
propCompatibility :: forall v. HatVal v => Proxy v -> Property
propCompatibility _ = forAll genCompatibilityCase $ \sample ->
    let rules = caseRules sample :: [TransferRule v TestBase]
        ledger = caseAlgebra sample :: Alg v TestBase
    in case applyChecked (mkTransferRules rules) ledger of
        Left failure -> counterexample failure False
        Right entries -> counterexample (show (obs (ledger .+ entries))) $
            nearObservations (obs (ledger .+ entries)) (obs (Legacy.transfer ledger (legacyTable rules)))
  where
    -- Keep construction and application failures visible without a partial pattern.
    applyChecked constructed ledger = case constructed of
        Left failure -> Left (show failure)
        Right rules -> case transferEntries rules ledger of
            Left failure -> Left (show failure)
            Right entries -> Right entries

-- | L2 compares net closing with the legacy final-stock transformation.
propClosing :: forall v. HatVal v => Proxy v -> Property
propClosing _ = forAll genCompatibilityCase $ \sample ->
    let ledger = caseAlgebra sample :: Alg v TestBase
    in case closingEntries ledger of
        Left failure -> counterexample (show failure) False
        Right entries -> property $
            nearObservations (obs (ledger .+ entries))
                             (obs (Legacy.finalStockTransfer ledger))

-- | L3 compares debit and credit totals for side-preserving Relabel rules
-- and for closing entries. Decimal totals are exact in this generator.
propBalance :: forall v. HatVal v => Proxy v -> Property
propBalance _ = forAll genCompatibilityCase $ \sample ->
    let ledger = caseAlgebra sample :: Alg v TestBase
        rules = [relabel (HatNot :< (Cash, wildcard)) (HatNot :< (Deposits, wildcard))]
    in case mkTransferRules rules of
        Left failure -> counterexample (show failure) False
        Right validated -> case transferEntries validated ledger of
            Left failure -> counterexample (show failure) False
            Right entries -> case closingEntries ledger of
                Left failure -> counterexample (show failure) False
                Right closing -> balanced entries .&&. balanced closing
  where
    balanced entries = norm (decL entries) == norm (decR entries)

-- | L4 pins permutation invariance of successful construction and fingerprints.
propCanonical :: Property
propCanonical = forAll genCompatibilityCase $ \sample ->
    let rules = caseRules sample :: [TransferRule Double TestBase]
    in forAll (shuffle rules) $ \permuted -> case (mkTransferRules rules, mkTransferRules permuted) of
        (Right first, Right second) -> property (first == second && hash first == hash second)
        failures -> counterexample (show failures) False

-- | L5 observes all output values before bar for transfers and closing.
propNonnegative :: forall v. HatVal v => Proxy v -> Property
propNonnegative _ = forAll genCompatibilityCase $ \sample ->
    let ledger = caseAlgebra sample :: Alg v TestBase
    in case mkTransferRules (caseRules sample) of
        Left failure -> counterexample (show failure) False
        Right rules -> case transferEntries rules ledger of
            Left failure -> counterexample (show failure) False
            Right entries -> case closingEntries ledger of
                Left failure -> counterexample (show failure) False
                Right closing -> property (all (>= zeroValue) (vals entries ++ vals closing))

-- | Run a named assertion with the same IO failure convention as Spec.hs.
assertTest :: String -> Bool -> IO ()
assertTest label success = unless success $ do
    putStrLn ("[FAIL] transfer rules: " ++ label)
    exitFailure

-- | Require a successful checked result without introducing a partial helper.
requireRight :: Show failure => String -> Either failure result -> IO result
requireRight label result = case result of
    Right value -> pure value
    Left failure -> do
        putStrLn ("[FAIL] " ++ label ++ ": " ++ show failure)
        exitFailure

-- | Run 200 QuickCheck cases, reporting failures through the existing harness.
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)

-- | Reject overlaps, duplicates and all invalid coefficients deterministically.
testValidation :: IO ()
testValidation = do
    let first = relabel (HatNot :< (Cash, wildcard)) (Not :< (Deposits, Yen))
            :: TransferRule Double TestBase
        second = relabel (Not :< (Cash, Yen)) (Not :< (Products, Yen))
        third = relabel (Not :< (Cash, Amount)) (Not :< (Sales, Amount))
        overlap expectedFirst expectedSecond result = case result of
            Left (OverlappingRules actualFirst actualSecond) ->
                actualFirst == expectedFirst && actualSecond == expectedSecond
            _ -> False
    assertTest "overlap input order" (overlap first second (mkTransferRules [first, second, third]))
    assertTest "duplicate rejected" (overlap first first (mkTransferRules [first, first]))
    mapM_ (\coefficient -> mapM_ (checkCoefficient first second coefficient) [scaleBy, divideBy])
        [0, -1, 0 / 0, 1 / 0, -1 / 0]
    let nestedFirst = relabel (Hat :< ((Cash, wildcard), Yen))
                              (Hat :< ((Deposits, Amount), Yen))
            :: TransferRule Double (HatBase ((AccountTitles, CountUnit), CountUnit))
        nestedSecond = relabel (Hat :< ((wildcard, Amount), wildcard))
                               (Hat :< ((Products, Amount), Yen))
    assertTest "nested partial wildcard overlap" $
        overlap nestedFirst nestedSecond (mkTransferRules [nestedFirst, nestedSecond])
    canonical <- requireRight "valid rules" (mkTransferRules ([second, third] :: [TransferRule Double TestBase]))
    assertTest "Binary round trip" (Binary.decode (Binary.encode canonical) == canonical)
    let invalidBytes = Binary.encode [first, first]
    -- Decoder validation is tested separately without trusting a hidden constructor.
    case Binary.decodeOrFail invalidBytes of
        Left _ -> pure ()
        Right (_, _, value) -> assertTest "Binary refuses overlapping rules"
            (const False (value :: TransferRules Double TestBase))
  where
    checkCoefficient first second coefficient constructor = do
        let invalid = constructor (Not :< (Products, Yen)) (Not :< (Cash, Yen)) coefficient
            later = scaleBy (Not :< (Deposits, Yen)) (Not :< (Cash, Yen)) 0
        assertTest "coefficient checked before overlap; first error" $
            case mkTransferRules [first, second, invalid, later] of
                Left (InvalidCoefficient rule) -> ruleFrom rule == ruleFrom invalid
                _ -> False

-- | Mixed wildcard positions reproduce the frozen legacy tree's missed matches.
testLegacyMiss :: IO ()
testLegacyMiss = do
    let rules =
            [ relabel (Hat :< (wildcard, Yen)) (Hat :< (wildcard, Amount))
            , relabel (Not :< (Cash, wildcard)) (Not :< (Deposits, wildcard))
            , relabel (Hat :< (Products, Amount)) (Hat :< (Purchases, Amount))
            ] :: [TransferRule Double TestBase]
        ledger = 9 .@ Hat :< (Sales, Yen)
              .+ 8 .@ Hat :< (Deposits, Yen)
              .+ 7 .@ Hat :< (Cash, Yen)
        expected = 9 .@ Hat :< (Sales, Amount)
                .+ 8 .@ Hat :< (Deposits, Amount)
                .+ 7 .@ Hat :< (Cash, Amount)
    mapM_ (checkPermutation ledger expected) (permutations rules)
  where
    checkPermutation ledger expected rules = do
        validated <- requireRight "mixed wildcard rules" (mkTransferRules rules)
        entries <- requireRight "mixed wildcard application" (transferEntries validated ledger)
        assertTest "new API transforms all three entries" (obs (ledger .+ entries) == obs expected)
        let old = Legacy.transfer ledger (legacyTable rules)
            changed = [postingBase | postingBase@(_ :< (_, Amount)) <- bases old]
        assertTest "legacy transforms only one entry" (length changed == 1)

-- | One-way matching, no-op, zero result, HatNot and overflow edge cases.
testApplication :: IO ()
testApplication = do
    let source = Not :< (Cash, Yen)
        target = Not :< (Deposits, Yen)
        literalWildcard = 10 .@ Not :< (Cash, wildcard) :: Alg Double TestBase
    emptyRules <- requireRight "empty rule set"
        (mkTransferRules [] :: Either (TransferRuleError Double TestBase) (TransferRules Double TestBase))
    emptyEntries <- requireRight "empty rules emit nothing" (transferEntries emptyRules literalWildcard)
    assertTest "empty rule set" (Algebra.isZero emptyEntries)
    concrete <- requireRight "concrete rule" (mkTransferRules [relabel source target])
    zeroEntries <- requireRight "zero input" (transferEntries concrete Zero)
    assertTest "zero input emits nothing" (Algebra.isZero zeroEntries)
    unmatched <- requireRight "ledger wildcard is literal" (transferEntries concrete literalWildcard)
    assertTest "one-way matching" (Algebra.isZero unmatched)
    noOp <- requireRight "identity rule" (mkTransferRules [relabel source source])
    identityEntries <- requireRight "identity application" (transferEntries noOp (10 .@ source :: Alg Double TestBase))
    assertTest "identity emits nothing" (Algebra.isZero identityEntries)
    wildcardRule <- requireRight "HatNot rule" $
        mkTransferRules [relabel (HatNot :< (Cash, wildcard)) (HatNot :< (Deposits, wildcard))]
    invalidHat <- requireRight "HatNot posting is ignored" $
        transferEntries wildcardRule (10 .@ HatNot :< (Cash, Yen) :: Alg Double TestBase)
    assertTest "HatNot posting" (Algebra.isZero invalidHat)
    underflow <- requireRight "underflow rule" (mkTransferRules [scaleBy source target (1e-300 :: Double)])
    zeroTarget <- requireRight "underflow result" (transferEntries underflow (1e-300 .@ source))
    assertTest "zero result emits only cancellation" $
        rawObservation zeroTarget == Map.singleton (revHat source) 1e-300
    overflow <- requireRight "overflow rule" (mkTransferRules [scaleBy source target (2 :: Double)])
    assertTest "Algebra overflow is Left" $ case transferEntries overflow (1e308 .@ source) of
        Left (NonFiniteResult rule value postingBase) ->
            ruleFrom rule == source && value == 1e308 && postingBase == source
        _ -> False
    divisionOverflow <- requireRight "division overflow rule" $
        mkTransferRules [divideBy source target (1e-308 :: Double)]
    assertTest "division overflow is Left" $
        case transferEntries divisionOverflow (2 .@ source) of
            Left (NonFiniteResult rule value postingBase) ->
                ruleScale rule == DivBy 1e-308 && value == 2 && postingBase == source
            _ -> False
    let largeSource = 1e308 .@ source .+ 1e308 .@ source :: Alg Double TestBase
    largeEntries <- requireRight "Relabel preserves finite individual values"
        (transferEntries concrete largeSource)
    assertTest "transfer does not aggregate large postings" $
        length (vals largeEntries) == 4 && all (not . isErrorValue) (vals largeEntries)
    let journal = (5 .@ source .| "first") .+ (7 .@ source .| "second")
    additions <- requireRight "two-note application" (JournalRule.transferEntries concrete journal)
    let at note value = case HashMap.lookup note (Journal.toMap additions) of
            Nothing -> False
            Just entries -> rawObservation entries == rawObservation
                (value .@ revHat source .+ value .@ target)
    assertTest "two notes preserved" (HashMap.size (Journal.toMap additions) == 2 && at "first" 5 && at "second" 7)
    let cancellationInput = 10 .@ source .+ 10 .@ revHat source :: Alg Double TestBase
    rawEntries <- requireRight "no implicit bar" (transferEntries wildcardRule cancellationInput)
    assertTest "opposing generated postings remain raw" (length (vals rawEntries) == 4)
    let overflowingJournal = (5 .@ source .| "small") .+ (1e308 .@ source .| "large")
    assertTest "Journal overflow aborts whole result" $
        case JournalRule.transferEntries overflow overflowingJournal of
            Left (NonFiniteResult _ value _) -> value == 1e308
            _ -> False
    let failures = (1e308 .@ source .| "a") .+ (1.1e308 .@ source .| "b")
    case HashMap.toList (Journal.toMap failures) of
        [] -> assertTest "failure fixture is nonempty" False
        (_, first) : _ -> assertTest "Journal returns first traversal error" $
            case JournalRule.transferEntries overflow failures of
                Left (NonFiniteResult _ value _) -> vals first == [value]
                _ -> False
    sideChanging <- requireRight "cross-side relabel" $
        mkTransferRules [relabel source (Not :< (Sales, Yen))]
    unbalanced <- requireRight "cross-side application" (transferEntries sideChanging (10 .@ source :: Alg Double TestBase))
    assertTest "L3 needs equal debit-credit sides" (norm (decL unbalanced) /= norm (decR unbalanced))

-- | Exact netting retains current earnings after large historical cancellation.
testClosing :: IO ()
testClosing = do
    let source = Not :< (Sales, Yen)
        ledger = 1e12 .@ source .+ 1e12 .@ revHat source .+ 1 .@ source
            :: Alg Double TestBase
        expected = 1 .@ revHat source .+ 1 .@ Not :< (RetainedEarnings, Yen)
    assertTest "bar loses historical-small residual fixture" (Map.null (obs ledger))
    entries <- requireRight "historical-small balance closing" (closingEntries ledger)
    assertTest "closing preserves current one" (rawObservation entries == rawObservation expected)
    assertTest "closing-side public classification" $
        closingSide Sales == Just ClosingKeep && closingSide Purchases == Just ClosingFlip
        && closingSide Cash == Nothing && closingSide NetIncome == Nothing && closingSide NetLoss == Nothing
    let offset = 10 .@ source .+ 10 .@ revHat source :: Alg Double TestBase
    offsetEntries <- requireRight "exactly balanced closing" (closingEntries offset)
    assertTest "exactly balanced source emits nothing" (Algebra.isZero offsetEntries)
    let accounts = 10 .@ source .+ 4 .@ Not :< (InterestEarned, Yen) :: Alg Double TestBase
    accountEntries <- requireRight "separate account closing" (closingEntries accounts)
    let targets = Algebra.projByAccountTitle RetainedEarnings accountEntries
    assertTest "separate closing targets preserve account audit trail" (length (vals targets) == 2)

-- | Closing rejects either overflowing side, even if both totals are Infinity.
-- Error order follows ascending normalized bases, not posting insertion order.
testClosingOverflow :: IO ()
testClosingOverflow = do
    let sales = Not :< (Sales, Yen)
        purchases = Not :< (Purchases, Yen)
        overflowing postingBase = 1e308 .@ postingBase .+ 1e308 .@ postingBase
            :: Alg Double TestBase
        isBalanceError expected result = case result of
            Left (NonFiniteBalance actual) -> actual == expected
            _ -> False
    assertTest "closing Not sum overflow" $
        isBalanceError sales (closingEntries (overflowing sales))
    assertTest "closing Hat sum overflow" $
        isBalanceError sales (closingEntries (overflowing (revHat sales)))
    assertTest "equal infinite sides must not be treated as zero" $
        isBalanceError sales (closingEntries (overflowing sales .+ overflowing (revHat sales)))
    let expectedFirst = min sales purchases
    assertTest "first overflowing base in ascending order" $
        isBalanceError expectedFirst (closingEntries (overflowing sales .+ overflowing purchases))
        && isBalanceError expectedFirst (closingEntries (overflowing purchases .+ overflowing sales))
    let journal = (1e308 .@ sales .| "first") .+ (1e308 .@ sales .| "second")
            :: Journal.Journal String Double TestBase
    assertTest "Journal closing overflow across notes" $
        isBalanceError sales (JournalRule.closingEntries journal)
    let cash = Not :< (Cash, Yen)
    ignored <- requireRight "non-closing accounts are not summed" (closingEntries (overflowing cash))
    assertTest "non-closing overflow ignored" (Algebra.isZero ignored)
    let largeDistinct = 1e308 .@ sales .+ 1e308 .@ Not :< (InterestEarned, Yen)
            :: Alg Double TestBase
    retained <- requireRight "distinct closing sources remain separate" (closingEntries largeDistinct)
    assertTest "closing target postings are not aggregated" $
        length (vals retained) == 4 && all (not . isErrorValue) (vals retained)

-- | A good axis owned only by this acceptance fixture.
data Good
    = Widget
    | AnyGood
    deriving (Eq, Ord, Show, Generic)

instance Hashable Good

instance Element Good where
    wildcard = AnyGood

-- | Two owners and a query wildcard, local to this fixture.
data Owner
    = Alice
    | Bob
    | AnyOwner
    deriving (Eq, Ord, Show, Generic)

instance Hashable Owner

instance Element Owner where
    wildcard = AnyOwner

-- | The model-kit account, good, owner and unit axes.
type ModelBase = HatBase (AccountTitles, Good, Owner, CountUnit)

instance ExBaseClass ModelBase where
    getAccountTitle (_ :< (title, _, _, _)) = title
    setAccountTitle (postingHat :< (_, good, owner, unit)) title =
        postingHat :< (title, good, owner, unit)

-- | Events keep the original sales and later settlement separately readable.
data EventTag
    = SalesPurchase
    | Settlement
    | BlankEvent
    deriving (Eq, Ord, Show, Generic)

instance Hashable EventTag

instance Note EventTag where
    plank = BlankEvent

-- | Accounting period number; the Note Int instance supplies its blank value.
type Term = Int

-- | Exact decimal ledger for two owners and two periods.
type ModelJournal = Journal.Journal (EventTag, Term) MoneyDecimal ModelBase

-- | Build balanced sale and purchase transactions for one owner and period.
periodEntries :: Term -> Owner -> MoneyDecimal -> MoneyDecimal -> ModelJournal
periodEntries term owner revenue cost =
    ( revenue .@ Not :< (Cash, Widget, owner, Yen)
      .+ revenue .@ Not :< (Sales, Widget, owner, Yen)
      .+ cost .@ Not :< (Purchases, Widget, owner, Yen)
      .+ cost .@ Hat :< (Cash, Widget, owner, Yen)
    ) .| (SalesPurchase, term)

-- | Two-period acceptance: retained axes, historical notes and no double closing.
testAcceptance :: IO ()
testAcceptance = do
    let first = periodEntries 1 Alice 100 30 .+ periodEntries 1 Bob 150 40
    firstClosing <- requireRight "period-one closing" (JournalRule.closingEntries first)
    let afterFirst = first .+ (firstClosing .| (Settlement, 1))
        second = afterFirst .+ periodEntries 2 Alice 60 20 .+ periodEntries 2 Bob 90 30
    secondClosing <- requireRight "period-two closing" (JournalRule.closingEntries second)
    let afterSecond = second .+ (secondClosing .| (Settlement, 2))
        earnings owner algebra = Map.findWithDefault 0
            (Not :< (RetainedEarnings, Widget, owner, Yen)) (obs algebra)
        salesAt term journal = fmap (Algebra.projByAccountTitle Sales)
            (HashMap.lookup (SalesPurchase, term) (Journal.toMap journal))
    assertTest "period-one owner earnings" (earnings Alice firstClosing == 70 && earnings Bob firstClosing == 110)
    assertTest "period-two only current earnings" (earnings Alice secondClosing == 40 && earnings Bob secondClosing == 60)
    assertTest "accumulated owner earnings" $
        earnings Alice (Journal.toAlg afterSecond) == 110 && earnings Bob (Journal.toAlg afterSecond) == 170
    assertTest "historical sales remain under their original notes" $
        fmap rawObservation (salesAt 1 afterSecond) == fmap rawObservation (salesAt 1 first)
        && fmap rawObservation (salesAt 2 afterSecond) == fmap rawObservation (salesAt 2 second)
    assertTest "settlement note contains its entries" $
        fmap rawObservation (HashMap.lookup (Settlement, 2) (Journal.toMap afterSecond))
            == Just (rawObservation secondClosing)
    repeatedClosing <- requireRight "repeat closing" (JournalRule.closingEntries afterSecond)
    assertTest "closing twice emits no further entries" (Algebra.isZero repeatedClosing)
    assertTest "acceptance ledger stays balanced" (norm (decL afterSecond) == norm (decR afterSecond))

-- | Register all properties and fixed examples in ExchangeAlgebra-test.
runTests :: IO ()
runTests = do
    quickProperty "L1 Double" (propCompatibility (Proxy :: Proxy Double))
    quickProperty "L1 MoneyDecimal" (propCompatibility (Proxy :: Proxy MoneyDecimal))
    quickProperty "L2 Double" (propClosing (Proxy :: Proxy Double))
    quickProperty "L2 MoneyDecimal" (propClosing (Proxy :: Proxy MoneyDecimal))
    quickProperty "L3 Double" (propBalance (Proxy :: Proxy Double))
    quickProperty "L3 MoneyDecimal" (propBalance (Proxy :: Proxy MoneyDecimal))
    quickProperty "L4 canonical rules" propCanonical
    quickProperty "L5 Double" (propNonnegative (Proxy :: Proxy Double))
    quickProperty "L5 MoneyDecimal" (propNonnegative (Proxy :: Proxy MoneyDecimal))
    testValidation
    testLegacyMiss
    testApplication
    testClosing
    testClosingOverflow
    testAcceptance
    putStrLn "[PASS] transfer rule regressions and two-period acceptance"