packages feed

palindromes-1.1.0.0: tests/QuickCheckProperties.hs

{- This program has been developed by students from the bachelor Computer Science at Utrecht
University within the Software Project course.

© Copyright Utrecht University (Department of Information and Computing Sciences) and Johan Jeuring -}

module QuickCheckProperties (propertyList) where

import Data.Char (isAlphaNum, isSpace, readLitChar, toLower)
import Data.Foldable.Levenshtein (levenshtein', levenshteinDistance')
import Data.Maybe (fromJust)
import Test.QuickCheck
    ( Arbitrary
    , Gen
    , Property
    , arbitrary
    , choose
    , counterexample
    , elements
    , forAll
    , ioProperty
    , label
    , listOf
    , suchThat
    )

import Data.Algorithms.Palindromes.DNA (DNA (A, C, G, T), charToDNA, dnaToChar)
import Data.Algorithms.Palindromes.Finders
    ( Algorithm (..)
    , Variant (VarDNA, VarPlain, VarPunctuation, VarText, VarWord)
    , findPalindromes
    )
import Data.Algorithms.Palindromes.Internal.RangeFunctions (Range, rangeToLength)
import Data.Algorithms.Palindromes.PalEq (PalEq (..))
import Data.Algorithms.Palindromes.Palindrome (Palindrome (..), getLength)
import Data.Algorithms.Palindromes.Settings
    ( Settings (..)
    )
import Data.Algorithms.Palindromes.Streaming (findPalindromesVisualised)
import QuickCheckGenerators (generatePalindromes)
import QuickCheckSettings (settingsList)

-- List of to-be-tested properties, where each property is connected to all the settings
propertyList :: [Property]
propertyList =
    -- Property 1
    map propValidPalindromeRangeAndText settingsList
        -- Property 2
        ++ map propValidPalindromeReverse settingsList
        -- Property 3
        ++ map propValidPalLength settingsList
        -- Property 4
        ++ map propValidBoundaries settingsList
        -- Property 5
        ++ map propValidPalRange settingsList
        -- Property 6
        ++ map propAllowedPalLength settingsList
        -- Property 7
        ++ map propStreamSameResult settingsList

-- | Makes a Gen String based on the variant that is being used
stringGenerator :: Settings -> Gen String
stringGenerator = generatePalindromes

-- | Filters the non-alphabetic characters from the input, before converting everything to lowercase
cleanOriginalString :: String -> String
cleanOriginalString string = map toLower (filter (\a -> isAlphaNum a || isSpace a) string)

-- Property 1 ---------------------------------------------------------

-- | Test if all the generated Palindrome objects have a valid text related to the range property.
propValidPalindromeRangeAndText :: Settings -> Property
propValidPalindromeRangeAndText settings = counterexample (show settings ++ " property 1") $ forAll (stringGenerator settings) $ \originalString ->
    all
        (`checkPalRangeAndText` originalString)
        ( findPalindromes
            (variant settings)
            (algorithm settings)
            (minLength settings)
            originalString
        )

{- | Check that taking the substring of the original text described by the start and end of the palRange
property is equal to the palText property
-}
checkPalRangeAndText :: Palindrome -> String -> Bool
checkPalRangeAndText (Palindrome _ "" (x, y)) _ = x == y
checkPalRangeAndText (Palindrome _ palText (start, end)) originalString = palText == substringFromRange
  where
    substringFromRange = take (end - start) (drop start originalString)

-- Property 2 ---------------------------------------------------------

-- | Check that a found character palindrome is actually a palindrome
propValidPalindromeReverse :: Settings -> Property
propValidPalindromeReverse settings = counterexample (show settings ++ " property 2") $ forAll (stringGenerator settings) $ \originalString ->
    all
        (extractPalEq settings)
        ( findPalindromes
            (variant settings)
            (algorithm settings)
            (minLength settings)
            originalString
        )

extractPalEq :: Settings -> Palindrome -> Bool
extractPalEq settings pal = case algorithm settings of
    AlgApproximate _ _ -> case variant settings of
        VarWord -> isApproximatePalindrome $ words (cleanOriginalString (palText pal))
        VarPlain -> isApproximatePalindrome $ palText pal
        VarDNA -> isApproximatePalindrome $ map (fromJust . charToDNA) (palText pal)
        _ -> isApproximatePalindrome $ cleanOriginalString (palText pal)
    _ -> case variant settings of
        VarWord -> isPalindrome $ words (cleanOriginalString (palText pal))
        VarPlain -> isPalindrome $ palText pal
        VarDNA -> isPalindrome $ map (fromJust . charToDNA) (palText pal)
        _ -> isPalindrome $ cleanOriginalString (palText pal)
  where
    (gapSize, maxErrors) = case algorithm settings of
        AlgQuadratic gap err -> (gap, err)
        AlgApproximate gap err -> (gap, err)
        AlgLinear -> (0, 0)

    {- if any of the possible removed gaps has levenshteinDistance with its reverse is
    less than 2 * maxErrors we have an a valid approximate palindromes. This is because
    2 * maxErrors is equivalent to the levenshteinDistance -}
    isApproximatePalindrome :: (PalEq a) => [a] -> Bool
    isApproximatePalindrome input =
        any
            (<= 2 * maxErrors)
            ( zipWith
                (levenshteinDistance' (=:=))
                (allPossibleGapless gapSize maxErrors input)
                (map reverse (allPossibleGapless gapSize maxErrors input))
            )
    isPalindrome :: (PalEq a) => [a] -> Bool
    isPalindrome input = checkMismatches maxErrors $ removeGap 0 gapSize input

-- | for approximate palindromes the gap can be shifted due to insertions, so we need all.
allPossibleGapless :: (PalEq a) => Int -> Int -> [a] -> [[a]]
allPossibleGapless 0 _ palindrome = [palindrome]
allPossibleGapless gapSize 0 palindrome = [removeGap 0 gapSize palindrome]
allPossibleGapless gapSize maxErrors palindrome =
    map (\e -> removeGap e gapSize palindrome) [-maxErrors .. maxErrors]

-- | Checks if the character string is a palindrome, taking gapSize and maxErrors into account
checkMismatches :: (PalEq a) => Int -> [a] -> Bool
checkMismatches maxErrors pal' = mismatches <= maxErrors
  where
    mismatches =
        length
            [ ()
            | i <- [0 .. (length pal' `div` 2) - 1]
            , not $ (pal' !! i) =:= (pal' !! (length pal' - 1 - i))
            ]

-- | Removes gap from palindrome
removeGap :: Int -> Int -> [a] -> [a]
removeGap offset gapSize palindrome = take start palindrome ++ drop end palindrome
  where
    start = (length palindrome - adjustedGap + offset) `div` 2
    end = start + adjustedGap
    adjustGap = if even offset then not adjustWhenOddOffset else adjustWhenOddOffset
    adjustWhenOddOffset = even (length palindrome) == even gapSize || gapSize == 0
    adjustedGap =
        if adjustGap
            then gapSize - 1
            else gapSize

-- Property 3 ---------------------------------------------------------

-- | Tests if the palLength of a character palindrome corresponds to the palText
propValidPalLength :: Settings -> Property
propValidPalLength settings = counterexample (show settings ++ " property 3") $ forAll (stringGenerator settings) $ \originalString ->
    all
        (validPalLength settings)
        ( findPalindromes
            (variant settings)
            (algorithm settings)
            (minLength settings)
            originalString
        )

-- | Checks for every palindrome variant if the palLength corresponds to the length of palText
validPalLength :: Settings -> Palindrome -> Bool
validPalLength settings pal = case variant settings of
    VarWord ->
        length (words (cleanOriginalString (palText pal))) == getLength pal
    VarPlain -> length (palText pal) == getLength pal
    VarDNA -> length (palText pal) == getLength pal
    _ -> length (cleanOriginalString $ palText pal) == getLength pal

-- Property 4 ---------------------------------------------------------

-- | Property for testing if the palindrome range of a character palindrome corresponds to the palindrome length
propValidBoundaries :: Settings -> Property
propValidBoundaries settings = counterexample (show settings ++ " property 4") $ forAll (stringGenerator settings) $ \originalString ->
    all
        (checkValidBoundaries settings originalString)
        ( findPalindromes
            (variant settings)
            (algorithm settings)
            (minLength settings)
            originalString
        )

-- | Tests if the palindrome range of a palindrome corresponds to the palindrome length
checkValidBoundaries :: Settings -> String -> Palindrome -> Bool
checkValidBoundaries settings inputString pal = case variant settings of
    VarWord -> countWordsInRange (palRangeInText pal) inputString == getLength pal
    VarText ->
        let (s, e) = palRangeInText pal
        in  e - s - amountOfNonAlpha 0 (palText pal) == getLength pal
    VarPunctuation ->
        let (s, e) = palRangeInText pal
        in  e - s - amountOfNonAlpha 0 (palText pal) == getLength pal
    _ -> let (s, e) = palRangeInText pal in e - s == getLength pal

-- | Counts the amount of words that are in the substring of the input string corresponding with the given range
countWordsInRange :: Range -> String -> Int
countWordsInRange (s, e) inputString = length . words $ cleanOriginalString $ take (e - s) (drop s inputString)

-- | Counts the amount of non-alphabetic characters in the string
amountOfNonAlpha :: Int -> String -> Int
amountOfNonAlpha acc [] = acc
amountOfNonAlpha acc (x : xs)
    | not (isAlphaNum x) = amountOfNonAlpha (acc + 1) xs
    | otherwise = amountOfNonAlpha acc xs

-- Property 5 ---------------------------------------------------------

-- | Tests if the range boundaries of a character palindrome are not outside the bounds of the string
propValidPalRange :: Settings -> Property
propValidPalRange settings = counterexample (show settings ++ " property 5") $ forAll (stringGenerator settings) $ \originalString ->
    all
        (\pal -> fst (palRangeInText pal) >= 0 && snd (palRangeInText pal) <= length originalString)
        ( findPalindromes
            (variant settings)
            (algorithm settings)
            (minLength settings)
            originalString
        )

-- Property 6 ---------------------------------------------------------

-- | Property for testing if the length of a character palindrome is allowed by the specified minimum length
propAllowedPalLength :: Settings -> Property
propAllowedPalLength settings = counterexample (show settings ++ " property 6") $ forAll (stringGenerator settings) $ \originalString ->
    all
        (isAllowedPalLength settings)
        ( findPalindromes
            (variant settings)
            (algorithm settings)
            (minLength settings)
            originalString
        )

-- | Checks if the length of a palindrome is greater than the minimum length
isAllowedPalLength :: Settings -> Palindrome -> Bool
isAllowedPalLength settings pal = case minLength settings of
    l ->
        getLength pal >= l
            || palText pal == ""

-- Property 7 ---------------------------------------------------------

{- | Check that finding palindromes with intermediate visualisation
returns the same as the normal findPalindromes
-}
propStreamSameResult :: Settings -> Property
propStreamSameResult settings = counterexample (show settings ++ " property 7") $ forAll (stringGenerator settings) $ \originalString ->
    ioProperty $ do
        let pure =
                findPalindromes
                    (variant settings)
                    (algorithm settings)
                    (minLength settings)
                    originalString
        streamed <-
            findPalindromesVisualised
                (variant settings)
                (algorithm settings)
                (minLength settings)
                False
                originalString
                (const $ return ()) -- Don't do anything in the visualisation step
        return (pure == streamed)