packages feed

ychr-0.1.0.0: test/YCHR/GoldenTest.hs

{-# LANGUAGE OverloadedStrings #-}

module YCHR.GoldenTest (tests) where

import Control.Exception (SomeException, fromException, try)
import Control.Monad (filterM)
import Data.Char (isSpace)
import Data.Foldable (traverse_)
import Data.List (isInfixOf, partition, sort, sortOn)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import System.Directory (doesDirectoryExist, listDirectory)
import System.FilePath (dropExtension, takeExtension, (<.>), (</>))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
import YCHR.Internal.Compile.Pipeline (CompiledProgram (..))
import YCHR.Internal.Display (Display (..))
import YCHR.Internal.Meta (metaHostCallRegistry)
import YCHR.Internal.Pretty (prettyBindings)
import YCHR.Internal.Runtime.Interpreter (baseHostCallRegistry)
import YCHR.Internal.TypeCheck (typeCheckProgram)
import YCHR.Run
  ( Error,
    Warning,
    compileFiles,
    prepareGoal,
    runPreparedGoal,
  )

-- | Test directories whose @.chr@ programs or goals deliberately
-- reference bare atoms that the renamer cannot resolve — typically
-- because the test exists to verify the renamer's behaviour on
-- unexported or unknown constructors, or because the test uses bare
-- sentinel atoms as RHS of @=@ (where @quote/1@ no longer strips,
-- per the spec). Warnings emitted by these tests are part of what
-- they exercise, not a failure mode.
--
-- @nonexhaustive_color@ and @nonexhaustive_nested@ deliberately define
-- functions that do not cover every constructor of a declared algebraic
-- type, so they emit the exhaustiveness warning (YCHR-20103) on purpose.
expectsWarnings :: Set String
expectsWarnings =
  Set.fromList
    [ "arity_overload",
      "nonexhaustive_color",
      "nonexhaustive_nested",
      "bare_atom_canonicalization",
      "bare_vs_qualified",
      "bare_vs_qualified_swapped",
      "comments_and_whitespace",
      "comparisons",
      "copy_term_sharing",
      "cross_module_function_leak",
      "false_guard",
      "function_reference_dispatch",
      "graph_test",
      "hnf_compound_head",
      "hnf_list_head",
      "hnf_literal_in_head",
      "hnf_repeated_var_across_partners",
      "hnf_repeated_var_within_head",
      "hnf_wildcard_in_head",
      "lambda_curried_adder",
      "quoted_constraint_name",
      "short_alias_collision",
      "term_variables",
      "type_export_constructor_allowlist",
      "type_export_constructor_empty",
      "type_import_constructor_narrowing",
      "type_predicates",
      "typecheck_polymorphic_constraint",
      "typecheck_qualified_in_head",
      "unicode_atoms_strings",
      "unifiable",
      -- The lambda-calculus object language (var/lam/app/lit_int/add) is
      -- host-supplied opaque data matched structurally in rule heads, so
      -- it is intentionally left undeclared and warns as YCHR-20101.
      "stlc"
    ]

data Case
  = Positive String FilePath FilePath
  | Negative String FilePath
  | -- | Compilation and program-level type-checking succeed, but running
    -- the goal must throw an error whose displayed message contains the
    -- given error code. Encoded by colocating @<basename>.goal@ and
    -- @<basename>.error@ in the same test directory.
    GoalNegative String FilePath FilePath

data TestSpec = TestSpec
  { testName :: String,
    chrFiles :: [FilePath],
    cases :: [Case]
  }

tests :: IO TestTree
tests = do
  let root = "test/golden"
  entries <- sort <$> listDirectory root
  dirs <- filterM (doesDirectoryExist . (root </>)) entries
  trees <- mapM (makeGoldenTest root) dirs
  pure (testGroup "Golden" trees)

makeGoldenTest :: FilePath -> String -> IO TestTree
makeGoldenTest root name = do
  let dir = root </> name
  files <- sort <$> listDirectory dir
  let chrs = [dir </> f | f <- files, takeExtension f == ".chr"]
      goals = [f | f <- files, takeExtension f == ".goal"]
      expecteds = [f | f <- files, takeExtension f == ".expected"]
      errors = [f | f <- files, takeExtension f == ".error"]
  pure $ case validate dir name chrs goals expecteds errors of
    Left msg -> testCase name (assertFailure msg)
    Right spec -> testGroup spec.testName (map (makeCase spec) spec.cases)

validate ::
  FilePath ->
  String ->
  [FilePath] ->
  [FilePath] ->
  [FilePath] ->
  [FilePath] ->
  Either String TestSpec
validate dir name chrs goals expecteds errors
  | null chrs =
      Left ("No .chr files in " ++ dir)
  | null goals && null errors =
      Left ("No .goal or .error files in " ++ dir)
  | not (null goals) && not (null errors) = do
      -- Mixed-mode directory: each .goal must be paired with either a
      -- .expected (positive) or a .error (goal-negative). A bare .error
      -- (with no matching .goal) in the same directory is rejected
      -- because we'd otherwise have to disambiguate it from a regular
      -- compilation-negative case.
      let goalNames = sort (map dropExtension goals)
          expectedNames = map dropExtension expecteds
          errorNames = map dropExtension errors
          (positiveMatched, unmatchedAfterExpected) =
            partition (`elem` expectedNames) goalNames
          (goalNegMatched, orphanGoals) =
            partition (`elem` errorNames) unmatchedAfterExpected
          orphanExpecteds = filter (`notElem` goalNames) expectedNames
          orphanErrors = filter (`notElem` goalNames) errorNames
      case (orphanGoals, orphanExpecteds, orphanErrors) of
        ([], [], []) ->
          let pcases =
                [ Positive c (dir </> c <.> "goal") (dir </> c <.> "expected")
                | c <- positiveMatched
                ]
              gncases =
                [ GoalNegative c (dir </> c <.> "goal") (dir </> c <.> "error")
                | c <- goalNegMatched
                ]
           in Right (TestSpec name chrs (sortCases (pcases ++ gncases)))
        (gs, es, ers) ->
          Left
            ( "Orphan files in "
                ++ dir
                ++ ":"
                ++ concatMap (("\n  missing .expected or .error for " ++) . (<.> "goal")) gs
                ++ concatMap (("\n  missing .goal for " ++) . (<.> "expected")) es
                ++ concatMap
                  ( ("\n  bare .error not paired with a .goal in mixed dir for " ++)
                      . (<.> "error")
                  )
                  ers
            )
  | not (null errors) =
      let ecases =
            [ Negative (dropExtension e) (dir </> e)
            | e <- sort errors
            ]
       in Right (TestSpec name chrs ecases)
  | otherwise = do
      let goalNames = sort (map dropExtension goals)
          expectedNames = sort (map dropExtension expecteds)
          (matched, orphanGoals) =
            partition (`elem` expectedNames) goalNames
          orphanExpecteds = filter (`notElem` goalNames) expectedNames
      case (orphanGoals, orphanExpecteds) of
        ([], []) ->
          let pcases =
                [ Positive c (dir </> c <.> "goal") (dir </> c <.> "expected")
                | c <- matched
                ]
           in Right (TestSpec name chrs pcases)
        (gs, es) ->
          Left
            ( "Orphan files in "
                ++ dir
                ++ ":"
                ++ concatMap (("\n  missing .expected for " ++) . (<.> "goal")) gs
                ++ concatMap (("\n  missing .goal for " ++) . (<.> "expected")) es
            )

sortCases :: [Case] -> [Case]
sortCases = sortOn caseName
  where
    caseName (Positive n _ _) = n
    caseName (Negative n _) = n
    caseName (GoalNegative n _ _) = n

makeCase :: TestSpec -> Case -> TestTree
makeCase spec c = case c of
  Positive cname gf ef -> testCase cname (runPositive spec gf ef)
  Negative cname ef -> testCase cname (runNegative spec ef)
  GoalNegative cname gf ef -> testCase cname (runGoalNegative spec gf ef)

runPositive :: TestSpec -> FilePath -> FilePath -> IO ()
runPositive spec goalFile expectedFile = do
  (prog, ws) <-
    compileFiles False spec.chrFiles
      >>= either (assertFailure . show) pure
  checkWarnings spec "compile" ws
  typeErrors <- typeCheckProgram prog.desugaredProgram
  case typeErrors of
    [] -> pure ()
    errs ->
      assertFailure
        ("Type errors in " ++ spec.testName ++ ":\n" ++ unlines (map displayMsg errs))
  query <- TIO.readFile goalFile
  expected <- readFile expectedFile
  (constraint, goalWs) <- prepareGoal prog (T.strip query)
  checkWarnings spec "goal" goalWs
  bindings <- runPreparedGoal prog (baseHostCallRegistry <> metaHostCallRegistry) constraint
  prettyBindings bindings @?= expected

-- | Compile + program-typecheck must succeed; running the goal must throw
-- an 'Error' whose displayed message contains every non-empty line of
-- the '.error' file (each line is an independent substring assertion).
-- The first line is conventionally the @YCHR-NNNNN@ code, but the code
-- alone is often too generic — @YCHR-60001@ covers every runtime error,
-- so a typo could satisfy the assertion accidentally. Subsequent lines
-- pin a phrase from the actual error text to anchor the test against
-- the intended failure path. Any other outcome (success, non-'Error'
-- exception, missing substring) fails the test.
runGoalNegative :: TestSpec -> FilePath -> FilePath -> IO ()
runGoalNegative spec goalFile errorFile = do
  (prog, ws) <-
    compileFiles False spec.chrFiles
      >>= either (assertFailure . show) pure
  checkWarnings spec "compile" ws
  typeErrors <- typeCheckProgram prog.desugaredProgram
  case typeErrors of
    [] -> pure ()
    errs ->
      assertFailure
        ("Type errors in " ++ spec.testName ++ ":\n" ++ unlines (map displayMsg errs))
  query <- TIO.readFile goalFile
  expectedSubstrings <- nonEmptyLines <$> readFile errorFile
  (constraint, goalWs) <- prepareGoal prog (T.strip query)
  checkWarnings spec "goal" goalWs
  outcome <-
    try @SomeException $
      runPreparedGoal
        prog
        ( baseHostCallRegistry
            <> metaHostCallRegistry
        )
        constraint
  case outcome of
    Right _ ->
      assertFailure
        ( "Expected goal to fail with "
            ++ show expectedSubstrings
            ++ " but it succeeded"
        )
    Left exc -> case fromException exc of
      Just (err :: Error) -> do
        let msg = displayMsg err
        traverse_
          ( \sub ->
              assertBool
                ("Expected substring " ++ show sub ++ " in:\n" ++ msg)
                (sub `isInfixOf` msg)
          )
          expectedSubstrings
      Nothing ->
        assertFailure
          ( "Expected an Error matching "
              ++ show expectedSubstrings
              ++ " but got: "
              ++ show exc
          )
  where
    trim = reverse . dropWhile isSpace . reverse . dropWhile isSpace
    nonEmptyLines = filter (not . null) . map trim . lines

runNegative :: TestSpec -> FilePath -> IO ()
runNegative spec errorFile = do
  result <- compileFiles False spec.chrFiles
  expectedSubstrings <- nonEmptyLines <$> readFile errorFile
  case result of
    Left err -> assertAllPresent (displayMsg err) expectedSubstrings
    Right (prog, _ws) -> do
      typeErrors <- typeCheckProgram prog.desugaredProgram
      case typeErrors of
        [] -> assertFailure "Expected compilation or type checking to fail, but it succeeded"
        errs -> assertAllPresent (unlines (map displayMsg errs)) expectedSubstrings
  where
    trim = reverse . dropWhile isSpace . reverse . dropWhile isSpace
    nonEmptyLines = filter (not . null) . map trim . lines
    assertAllPresent msg =
      traverse_
        ( \sub ->
            assertBool
              ("Expected substring " ++ show sub ++ " in:\n" ++ msg)
              (sub `isInfixOf` msg)
        )

-- | Assert no warnings unless the test is on the allowlist. The
-- @phase@ label distinguishes compile-time warnings from goal-time
-- ones in the failure message.
checkWarnings :: TestSpec -> String -> [Warning] -> IO ()
checkWarnings spec phase ws
  | Set.member spec.testName expectsWarnings = pure ()
  | null ws = pure ()
  | otherwise =
      assertFailure
        ( spec.testName
            ++ ": "
            ++ phase
            ++ ": unexpected warnings\n"
            ++ unlines (map displayMsg ws)
        )