packages feed

ollama-haskell-0.2.1.0: test/Test/Ollama/Generate.hs

{-# LANGUAGE OverloadedStrings #-}

{-
  Tests related to Generate module
-}
module Test.Ollama.Generate (tests) where

import Control.Monad (void)
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy.Char8 qualified as BSL
import Data.IORef (modifyIORef, newIORef, readIORef, writeIORef)
import Data.Maybe (isJust)
import Data.Ollama.Common.SchemaBuilder
import Data.Ollama.Common.Utils (encodeImage)
import Data.Ollama.Generate
import Data.Text qualified as T
import Data.Time (diffUTCTime, getCurrentTime)
import Network.HTTP.Client
import Network.HTTP.Client.TLS
import Test.Tasty
import Test.Tasty.HUnit

generateTests :: TestTree
generateTests =
  testGroup
    "Generation with various options"
    [ testCase "Should contain 4 in 2+2" $ do
        eRes <-
          generate
            defaultGenerateOps {modelName = "gemma3", prompt = "What is 2+2?"}
            Nothing
        case eRes of
          Left err -> assertFailure $ "Expected success, got error: " ++ show err
          Right r -> assertBool "Should contain 4" (T.isInfixOf "4" (genResponse r))
    , testCase "Setting timeout" $ do
        eRes <-
          generate
            defaultGenerateOps
              { modelName = "gemma3"
              , prompt = "Write a poem about French revolution"
              }
            (Just $ defaultOllamaConfig {timeout = 1})
        case eRes of
          Left (TimeoutError _) -> pure ()
          _ -> assertFailure "Expected timeout error"
    ]

testOnModelHooksFail :: TestTree
testOnModelHooksFail = testCase "Model lifecycle hooks should be triggered" $ do
  refStart <- newIORef False
  refError <- newIORef False
  refFinish <- newIORef True
  let config =
        defaultOllamaConfig
          { hostUrl = "http://localhost:12345" -- guaranteed to fail
          , onModelStart = Just $ writeIORef refStart True
          , onModelError = Just $ writeIORef refError True
          , onModelFinish = Just $ writeIORef refFinish True
          }

  void $
    generate
      defaultGenerateOps {modelName = "gemma3", prompt = "what is 23+41?"}
      (Just config)

  wasStarted <- readIORef refStart
  wasErrored <- readIORef refError
  wasFinished <- readIORef refFinish

  assertBool "onModelStart should be called" wasStarted
  assertBool "onModelError should be called" wasErrored
  assertBool "onModelFinish should be called" wasFinished

testOnModelHooksSucc :: TestTree
testOnModelHooksSucc = testCase "Model lifecycle hooks should be triggered 2" $ do
  refStart <- newIORef False
  refError <- newIORef True
  refFinish <- newIORef False
  let config =
        defaultOllamaConfig
          { onModelStart = Just $ writeIORef refStart True
          , onModelError = Just $ writeIORef refError False
          , onModelFinish = Just $ writeIORef refFinish True
          }

  void $
    generate
      defaultGenerateOps {modelName = "gemma3", prompt = "what is 23+41?"}
      (Just config)

  wasStarted <- readIORef refStart
  wasErrored <- readIORef refError
  wasFinished <- readIORef refFinish

  assertBool "onModelStart should be called" wasStarted
  assertBool "onModelError should be called" wasErrored
  assertBool "onModelFinish should be called" wasFinished

testRetryCount :: TestTree
testRetryCount = testCase "Should retry generate call retryCount times" $ do
  counter <- newIORef (0 :: Int)
  let config =
        defaultOllamaConfig
          { hostUrl = "http://localhost:12345" -- fails
          , retryCount = Just 2
          , retryDelay = Just 1
          , onModelStart = Just $ modifyIORef counter (+ 1)
          , onModelError = Just $ pure ()
          , onModelFinish = Just $ pure ()
          }

  _ <- generate defaultGenerateOps {prompt = "Retry test"} (Just config)
  calls <- readIORef counter
  -- Should be retryCount + 1 (initial + retries)
  assertEqual "Expected 3 attempts (1 initial + 2 retries)" 3 calls

testRetryDelay :: TestTree
testRetryDelay = testCase "Should delay between retries" $ do
  counter <- newIORef (0 :: Int)
  let delaySecs = 2
  start <- getCurrentTime

  let config =
        defaultOllamaConfig
          { hostUrl = "http://localhost:12345" -- fails
          , retryCount = Just 1
          , retryDelay = Just delaySecs
          , onModelStart = Just $ modifyIORef counter (+ 1)
          , onModelError = Just $ pure ()
          , onModelFinish = Just $ pure ()
          }

  _ <- generate defaultGenerateOps {prompt = "Retry delay test"} (Just config)
  end <- getCurrentTime
  let elapsed = realToFrac (diffUTCTime end start) :: Double
  let expectedMin = fromIntegral delaySecs

  assertBool
    ("Elapsed time should be at least " ++ show expectedMin ++ "s, but was " ++ show elapsed)
    (elapsed >= expectedMin)

testCommonManagerUsage :: TestTree
testCommonManagerUsage = testCase "Should reuse provided commonManager" $ do
  refStart <- newIORef (0 :: Int)
  mgr <-
    newTlsManagerWith tlsManagerSettings {managerResponseTimeout = responseTimeoutMicro 1000000}
  let config =
        defaultOllamaConfig
          { hostUrl = "http://localhost:12345" -- will fail fast
          , commonManager = Just mgr
          , timeout = 999 -- shouldn't matter, manager timeout will be used
          , onModelStart = Just $ modifyIORef refStart (+ 1)
          , onModelError = Just $ pure ()
          , onModelFinish = Just $ pure ()
          }

  _ <- generate defaultGenerateOps {prompt = "1"} (Just config)
  _ <- generate defaultGenerateOps {prompt = "2"} (Just config)
  startCount <- readIORef refStart
  assertEqual "Both requests should start (reuse manager)" 2 startCount

{-
 Suffix is not supported for few gemma3 and qwen3.

testSuffixOption :: TestTree
testSuffixOption = testCase "Should respect suffix in generation" $ do
  let ops = defaultGenerateOps
              { modelName = "qwen3:0.6b"
              , prompt = "Complete this sentence: The Eiffel Tower is in"
              , suffix = Just " [End]"
              }
  eRes <- generate ops Nothing
  case eRes of
    Left err -> assertFailure $ "Expected success, got error: " ++ show err
    Right r -> assertBool "Expected suffix in response" $
                  T.isSuffixOf "[End]" (genResponse r)
                  -}

testThinkOption :: TestTree
testThinkOption = testCase "Should activate thinking mode when think=True" $ do
  let ops =
        defaultGenerateOps
          { modelName = "qwen3:0.6b"
          , prompt = "What is 2+2?"
          , think = Just True
          }
  eRes <- generate ops Nothing
  case eRes of
    Left err -> assertFailure $ "Expected success, got error: " ++ show err
    Right _ -> pure () -- TODO: Need to find a way to know if model is thinking

testFormatJsonFormat :: TestTree
testFormatJsonFormat = testCase "Should return response in JsonFormat" $ do
  let ops =
        defaultGenerateOps
          { modelName = "gemma3"
          , prompt =
              "John was 23 year old in 2023, this is year 2025."
                <> "How old is John assuming he celebrated this year's birthday; "
                <> "Return an object with keys 'name' and 'age'."
          , format = Just JsonFormat
          }
  eRes <- generate ops Nothing
  case eRes of
    Left err -> assertFailure $ "Expected success, got error: " ++ show err
    Right r -> do
      let responseText = genResponse r
      let decoded =
            Aeson.decode (BSL.pack $ T.unpack responseText) ::
              Maybe Aeson.Value
      assertBool "Expected valid JSON object in response" (isJust decoded)

testFormatSchemaFormat :: TestTree
testFormatSchemaFormat = testCase "Should include SchemaFormat in the request" $ do
  let schema =
        buildSchema $
          emptyObject
            |+ ("fruit", JString)
            |+ ("quantity", JNumber)
            |! "fruit"
            |! "quantity"

      ops =
        defaultGenerateOps
          { modelName = "gemma3"
          , prompt = "I had 3 apples, 1 gave one away. How many left?"
          , format = Just (SchemaFormat schema)
          }

  eRes <- generate ops Nothing
  case eRes of
    Left err -> assertFailure $ "Expected success, got error: " ++ show err
    Right r -> do
      let response = T.toLower (genResponse r)
      assertBool "Expected fruit information in response" $
        "apple" `T.isInfixOf` response || "fruit" `T.isInfixOf` response

testImageInput :: TestTree
testImageInput = testCase "Should accept and process base64 image input" $ do
  maybeImg <- encodeImage "./examples/sample.png"
  case maybeImg of
    Nothing -> assertFailure "Image encoding failed (unsupported format or missing file)"
    Just imgData -> do
      -- Validate the encoded image data
      assertBool "Encoded image should not be empty" (not $ T.null imgData)
      assertBool "Encoded image should be reasonable length" (T.length imgData > 100)

      let ops =
            defaultGenerateOps
              { modelName = "gemma3"
              , prompt = "Describe this image."
              , images = Just [imgData]
              }
          cfg = Just defaultOllamaConfig {timeout = 300}

      eRes <- generate ops cfg
      case eRes of
        Left err -> assertFailure $ "Expected success, got error: " ++ show err
        Right r -> do
          let response = T.toLower (genResponse r)
          assertBool "Response should not be empty" (not $ T.null response)
          assertBool "Expected image-related description in response" $
            T.isInfixOf "i love haskell" response

testStreamingHandler :: TestTree
testStreamingHandler = testCase "Should handle streaming response" $ do
  -- IORef to collect streamed chunks
  chunksRef <- newIORef []
  -- Define the stream handler: accumulate responses
  let streamHandler chunk = modifyIORef chunksRef (++ [genResponse chunk])
      ops =
        defaultGenerateOps
          { modelName = "gemma3"
          , prompt = "Write few words about Haskell."
          , stream = Just (streamHandler, pure ())
          }
  eRes <- generate ops Nothing
  -- Collect streamed chunks from IORef
  chunks <- readIORef chunksRef
  let fullOutput = T.concat chunks
  case eRes of
    Left err -> assertFailure $ "Expected streaming success, got error: " ++ show err
    Right _ -> do
      assertBool "Expected streamed text to include 'haskell'" $
        "haskell" `T.isInfixOf` T.toLower fullOutput
      assertBool "Expected some streamed content" $ not (T.null fullOutput)

testModelOptionsBasic :: TestTree
testModelOptionsBasic = testCase "ModelOptions: temperature and topP" $ do
  let opts =
        Just $
          defaultModelOptions
            { temperature = Just 0.9
            , topP = Just 0.8
            , topK = Nothing
            , numPredict = Just 20
            }

  eRes <-
    generate
      defaultGenerateOps
        { modelName = "gemma3"
        , prompt = "Generate a random list of 3 animals"
        , options = opts
        }
      Nothing

  case eRes of
    Left err -> assertFailure $ "Expected success, got: " ++ show err
    Right r -> assertBool "Response should not be empty" (not . T.null $ genResponse r)

testModelOptionsEdgeCases :: TestTree
testModelOptionsEdgeCases = testCase "ModelOptions: edge case values" $ do
  let opts =
        Just $
          defaultModelOptions
            { temperature = Just 0.0 -- Minimum temperature
            , topP = Just 1.0 -- Maximum topP
            , topK = Just 1 -- Minimum topK
            , numPredict = Just 1 -- Minimum prediction
            }
  eRes <-
    generate
      defaultGenerateOps
        { modelName = "gemma3"
        , prompt = "Hi"
        , options = opts
        }
      Nothing
  case eRes of
    Left _ -> assertFailure "Expected success, got error"
    Right _ -> assertBool "Should handle edge case model options" True

testInvalidModelName :: TestTree
testInvalidModelName = testCase "Invalid model name should fail gracefully" $ do
  eRes <- generate defaultGenerateOps {modelName = "invalid-model-xyz"} Nothing
  case eRes of
    Left _ -> assertBool "Should fail with invalid model name" True
    Right _ -> assertFailure "Should not succeed with invalid model name"

tests :: TestTree
tests =
  sequentialTestGroup
    "Generate tests"
    AllFinish
    [ generateTests
    , testOnModelHooksFail
    , testOnModelHooksSucc
    , testRetryCount
    , testRetryDelay
    , testCommonManagerUsage
    , testThinkOption
    , testFormatJsonFormat
    , testFormatSchemaFormat
    , testImageInput
    , testStreamingHandler
    , testModelOptionsBasic
    , testModelOptionsEdgeCases
    , testInvalidModelName
    ]