packages feed

keiki-0.7.0.0: test/Keiki/ProjectionDomainSpec.hs

{-# LANGUAGE TypeFamilies #-}

module Keiki.ProjectionDomainSpec where

import Control.Monad (forM_, replicateM)
import Data.Int (Int32, Int64)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime)
import Data.Word (Word16, Word32, Word64, Word8)
import Keiki.Core
import Keiki.ProjectionDomain
import Keiki.Symbolic
  ( PredicateVerificationDetail (..),
    Sym (..),
    TranslationIssue (..),
    TranslationStrength (..),
    predicateTranslationReport,
    projectionModelKeyAs,
    projectionModelOwnerAs,
    symIsBot,
    symbolicWholeCarrierExact,
    verifyPredicateDetailed,
  )
import Numeric.Natural (Natural)
import Test.Hspec

expectRight :: (HasCallStack, Show e) => Either e a -> IO a
expectRight (Right value) = pure value
expectRight (Left err) = do
  expectationFailure (show err)
  fail "expected Right"

smallPattern :: TextPattern
smallPattern = either (error . show) id $ do
  chars <- textCharSet ('a' :| ['b'])
  textRepeatBetween 1 2 chars

data SmallTextProjection

instance FieldProjection SmallTextProjection where
  type FieldName SmallTextProjection = "key"
  type FieldOwner SmallTextProjection = Text
  type FieldResult SmallTextProjection = Text
  fieldShapeId _ = "test.small-text.v1"
  projectFieldValue _ = id

instance ExactFieldProjection SmallTextProjection where
  fieldProjectionDomain _ = textProjectionDomain smallPattern
  reconstructFieldOwner _ key
    | matchesTextPattern smallPattern key = Just key
    | otherwise = Nothing

smallTextWitness :: FieldWitness SmallTextProjection
smallTextWitness = exactFieldWitness @SmallTextProjection

type SmallTextRegs = '[ '("owner", Text)]

smallTextOwner :: Index SmallTextRegs Text
smallTextOwner = #owner

-- A schema-local TypeID-v7-shaped key. This fixture intentionally derives its
-- accepted language without depending on the consumer's TypeID package:
-- "order_", followed by a 26-character Crockford Base32 UUID encoding whose
-- leading character cannot overflow 128 bits, whose character 10 encodes UUID
-- version 7, and whose character 13 encodes the RFC 4122 variant.
newtype OrderTypeIdOwner = OrderTypeIdOwner Text
  deriving stock (Eq, Show)

data OrderTypeIdText

orderTypeIdPattern :: TextPattern
orderTypeIdPattern = either (error . show) id $ do
  prefix <- textLiteral "order_"
  leading <- textCharSet ('0' :| "1234567")
  crockford <- textCharSet ('0' :| "123456789abcdefghjkmnpqrstvwxyz")
  version <- textCharSet ('e' :| "f")
  variant <- textCharSet ('8' :| "9abrstv")
  beforeVersion <- textRepeatBetween 9 9 crockford
  beforeVariant <- textRepeatBetween 2 2 crockford
  afterVariant <- textRepeatBetween 12 12 crockford
  pure
    ( textConcat
        ( prefix
            :| [ leading,
                 beforeVersion,
                 version,
                 beforeVariant,
                 variant,
                 afterVariant
               ]
        )
    )

instance FieldProjection OrderTypeIdText where
  type FieldName OrderTypeIdText = "typeId"
  type FieldOwner OrderTypeIdText = OrderTypeIdOwner
  type FieldResult OrderTypeIdText = Text
  fieldShapeId _ = "test.order-type-id.v7"
  projectFieldValue _ (OrderTypeIdOwner value) = value

instance ExactFieldProjection OrderTypeIdText where
  fieldProjectionDomain _ = textProjectionDomain orderTypeIdPattern
  reconstructFieldOwner _ value
    | matchesTextPattern orderTypeIdPattern value = Just (OrderTypeIdOwner value)
    | otherwise = Nothing

orderTypeIdWitness :: FieldWitness OrderTypeIdText
orderTypeIdWitness = exactFieldWitness @OrderTypeIdText

type OrderTypeIdRegs = '[ '("owner", OrderTypeIdOwner)]

orderTypeIdOwner :: Index OrderTypeIdRegs OrderTypeIdOwner
orderTypeIdOwner = #owner

orderTypeIdSuffix :: Text
orderTypeIdSuffix = "01h455vb4pex5vsknk084sn02q"

orderTypeIdSample :: Text
orderTypeIdSample = "order_" <> orderTypeIdSuffix

replaceTextAt :: Int -> Char -> Text -> Text
replaceTextAt position replacement value =
  T.take position value <> T.singleton replacement <> T.drop (position + 1) value

replaceSuffixAt :: Int -> Char -> Text
replaceSuffixAt position replacement =
  replaceTextAt (T.length "order_" + position) replacement orderTypeIdSample

leapSecondValue :: UTCTime
leapSecondValue = UTCTime (fromGregorian 2016 12 31) (secondsToDiffTime 86400)

data FiniteLeapSecond

instance FieldProjection FiniteLeapSecond where
  type FieldName FiniteLeapSecond = "leap"
  type FieldOwner FiniteLeapSecond = UTCTime
  type FieldResult FiniteLeapSecond = UTCTime
  fieldShapeId _ = "test.finite-leap-second.v1"
  projectFieldValue _ = id

instance ExactFieldProjection FiniteLeapSecond where
  fieldProjectionDomain _ = finiteProjectionDomain (leapSecondValue :| [])
  reconstructFieldOwner _ value
    | value == leapSecondValue = Just value
    | otherwise = Nothing

finiteLeapSecondWitness :: FieldWitness FiniteLeapSecond
finiteLeapSecondWitness = exactFieldWitness @FiniteLeapSecond

data FiniteHighText

instance FieldProjection FiniteHighText where
  type FieldName FiniteHighText = "highText"
  type FieldOwner FiniteHighText = Text
  type FieldResult FiniteHighText = Text
  fieldShapeId _ = "test.finite-high-text.v1"
  projectFieldValue _ = id

instance ExactFieldProjection FiniteHighText where
  fieldProjectionDomain _ =
    finiteProjectionDomain (T.singleton '\x30000' :| [])
  reconstructFieldOwner _ value
    | value == T.singleton '\x30000' = Just value
    | otherwise = Nothing

finiteHighTextWitness :: FieldWitness FiniteHighText
finiteHighTextWitness = exactFieldWitness @FiniteHighText

spec :: Spec
spec = describe "projection domain" $ do
  it "deduplicates finite values while preserving membership" $ do
    let domain =
          finiteProjectionDomain ("open" :| ["closed", "open"]) ::
            ProjectionDomain Text
    memberProjectionDomain domain "open" `shouldBe` True
    memberProjectionDomain domain "closed" `shouldBe` True
    memberProjectionDomain domain "invented" `shouldBe` False

  it "matches literals as complete strings" $ do
    textPattern <- expectRight (textLiteral "type_")
    matchesTextPattern textPattern "type_" `shouldBe` True
    matchesTextPattern textPattern "type_value" `shouldBe` False

  it "composes sets, ranges, alternation, and bounded repetition" $ do
    prefix <- expectRight (textLiteral "id_")
    lower <- expectRight (textCharRanges (('a', 'z') :| []))
    digits <- expectRight (textCharSet ('0' :| ['1']))
    suffix <- expectRight (textRepeatBetween 2 3 digits)
    let textPattern = textConcat (prefix :| [textAlternation (lower :| [digits]), suffix])
    matchesTextPattern textPattern "id_a01" `shouldBe` True
    matchesTextPattern textPattern "id_1011" `shouldBe` True
    matchesTextPattern textPattern "id_a0" `shouldBe` False
    matchesTextPattern textPattern "id_A01" `shouldBe` False
    matchesTextPattern textPattern "xid_a01" `shouldBe` False

  it "gives the pure matcher and SBV compiler the same small language" $ do
    let samples = T.pack <$> concatMap (`replicateM` "abx") [0 .. 3]
    forM_ samples $ \sample -> do
      let concrete = matchesTextPattern smallPattern sample
          predicate =
            regProj smallTextWitness smallTextOwner .== TLit sample ::
              HsPred SmallTextRegs ()
      symIsBot predicate `shouldBe` not concrete

  it "checks both directions of an exact witness declaration" $ do
    checkFieldProjectionOwner smallTextWitness "ab" `shouldBe` Right ()
    checkFieldProjectionKey smallTextWitness "ba" `shouldBe` Right "ba"
    checkFieldProjectionOwner smallTextWitness "x"
      `shouldBe` Left ProjectedKeyOutsideDeclaredDomain

  it "classifies every curated whole carrier conservatively" $ do
    symbolicWholeCarrierExact @Bool `shouldBe` True
    symbolicWholeCarrierExact @Int `shouldBe` False
    symbolicWholeCarrierExact @Integer `shouldBe` True
    symbolicWholeCarrierExact @Natural `shouldBe` True
    symbolicWholeCarrierExact @Text `shouldBe` False
    symbolicWholeCarrierExact @UTCTime `shouldBe` False
    symbolicWholeCarrierExact @Word64 `shouldBe` True
    symbolicWholeCarrierExact @Word32 `shouldBe` True
    symbolicWholeCarrierExact @Word16 `shouldBe` True
    symbolicWholeCarrierExact @Word8 `shouldBe` True
    symbolicWholeCarrierExact @Int64 `shouldBe` True
    symbolicWholeCarrierExact @Int32 `shouldBe` True

  it "keeps leap-second UTCTime values out of the whole-carrier claim" $ do
    fromSym (toSym leapSecondValue) `shouldNotBe` leapSecondValue

  it "rejects non-representable literals from otherwise finite domains" $ do
    let leapOwner = ZIdx :: Index '[ '("owner", UTCTime)] UTCTime
        leapPredicate =
          regProj finiteLeapSecondWitness leapOwner
            .== TLit (fromSym (toSym leapSecondValue)) ::
            HsPred '[ '("owner", UTCTime)] ()
        textOwner = ZIdx :: Index '[ '("owner", Text)] Text
        textPredicate =
          regProj finiteHighTextWitness textOwner .== TLit "ordinary" ::
            HsPred '[ '("owner", Text)] ()
    predicateTranslationReport leapPredicate `shouldSatisfy` hasUnsupportedDomain
    predicateTranslationReport textPredicate `shouldSatisfy` hasUnsupportedDomain
    symIsBot leapPredicate `shouldBe` False
    symIsBot textPredicate `shouldBe` False

  it "rejects reversed ranges and repetition intervals" $ do
    textCharRanges (('z', 'a') :| [])
      `shouldBe` Left (ReversedCharacterRange 'z' 'a')
    literal <- expectRight (textLiteral "x")
    textRepeatBetween 2 1 literal
      `shouldBe` Left (InvalidRepetitionInterval 2 1)
    let tooLarge = fromIntegral (maxBound :: Int) + 1
    textRepeatBetween tooLarge tooLarge literal
      `shouldBe` Left (RepetitionBoundTooLarge tooLarge)
    textRepeatBetween 0 tooLarge literal
      `shouldBe` Left (RepetitionBoundTooLarge tooLarge)

  it "accepts U+2FFFF and rejects U+30000 in every character constructor" $ do
    textLiteral (T.singleton maximumSmtCodePoint) `shouldSatisfy` isRight
    textCharSet (maximumSmtCodePoint :| []) `shouldSatisfy` isRight
    textCharRanges ((maximumSmtCodePoint, maximumSmtCodePoint) :| [])
      `shouldSatisfy` isRight
    let firstUnrepresentable = '\x30000'
    textLiteral (T.singleton firstUnrepresentable)
      `shouldBe` Left (CodePointAboveSmtMaximum firstUnrepresentable)
    textCharSet (firstUnrepresentable :| [])
      `shouldBe` Left (CodePointAboveSmtMaximum firstUnrepresentable)
    textCharRanges ((firstUnrepresentable, firstUnrepresentable) :| [])
      `shouldBe` Left (CodePointAboveSmtMaximum firstUnrepresentable)

  describe "TypeID-v7-shaped exact text domain" $ do
    let accepted =
          orderTypeIdSample
            : replaceSuffixAt 0 '7'
            : replaceSuffixAt 10 'f'
            : [replaceSuffixAt 13 variant | variant <- "89abrstv"]
        rejected =
          [ "",
            orderTypeIdSuffix,
            "user_" <> orderTypeIdSuffix,
            "order__" <> orderTypeIdSuffix,
            T.dropEnd 1 orderTypeIdSample,
            orderTypeIdSample <> "0",
            replaceSuffixAt 0 '8',
            replaceSuffixAt 1 'i',
            replaceSuffixAt 1 'l',
            replaceSuffixAt 1 'o',
            replaceSuffixAt 1 'u',
            replaceSuffixAt 1 'H',
            replaceSuffixAt 1 '_',
            replaceSuffixAt 10 'd',
            replaceSuffixAt 10 'g',
            replaceSuffixAt 13 'c'
          ]
        predicate sample =
          regProj orderTypeIdWitness orderTypeIdOwner .== TLit sample ::
            HsPred OrderTypeIdRegs ()

    it "accepts every version/variant boundary and rejects malformed boundaries" $ do
      forM_ accepted $ \sample -> do
        matchesTextPattern orderTypeIdPattern sample `shouldBe` True
        symIsBot (predicate sample) `shouldBe` False
      forM_ rejected $ \sample -> do
        matchesTextPattern orderTypeIdPattern sample `shouldBe` False
        symIsBot (predicate sample) `shouldBe` True

    it "reconstructs and round-trips every accepted boundary model" $
      forM_ accepted $ \sample -> do
        checkFieldProjectionKey orderTypeIdWitness sample
          `shouldBe` Right (OrderTypeIdOwner sample)
        detail <- verifyPredicateDetailed (predicate sample)
        case detail of
          PredicateSatisfiable ExactTranslation [projectionModel] -> do
            projectionModelKeyAs @Text projectionModel `shouldBe` Just sample
            projectionModelOwnerAs @OrderTypeIdOwner projectionModel
              `shouldBe` Just (OrderTypeIdOwner sample)
          other -> expectationFailure ("expected one exact TypeID model, got " <> show other)
  where
    isRight (Right _) = True
    isRight (Left _) = False
    hasUnsupportedDomain (ConservativeOverApproximation issues) =
      any isUnsupportedDomain issues
    hasUnsupportedDomain ExactTranslation = False
    isUnsupportedDomain UnsupportedProjectionDomain {} = True
    isUnsupportedDomain _ = False