Tests/PSAIOpenAIDecisions.Tests.ps1

BeforeAll {
    Import-Module (Join-Path $PSScriptRoot '..' 'PSAIOpenAIDecisions.psd1') -Force
}

Describe 'PSAIOpenAIDecisions question builders' {
    BeforeEach {
        $env:OPENAI_API_KEY = 'pester-test-key'
    }

    It 'builds predicate, choice, and score questions with their named fields' {
        $predicate = New-OpenAIYesNoQuestion -Name urgent -Question 'Is this urgent?'
        $choice = New-OpenAIDecisionQuestion -Type Choice -Name route `
            -Instructions 'Where should this go?' -Choices @(
                @{ value = 'billing'; description = 'Payment request' }
                'support'
            )
        $score = New-OpenAIDecisionQuestion -Type Score -Name severity `
            -Instructions 'How severe is this?' -Levels @('Low', 'High')

        $predicate.Type | Should -Be 'Predicate'
        $predicate.Name | Should -Be 'urgent'
        $choice.Choices.Count | Should -Be 2
        $choice.Choices[1].value | Should -Be 'support'
        $score.Levels[0].label | Should -Be 'Low'
    }

    It 'rejects empty choice questions' {
        { New-OpenAIDecisionQuestion -Type Choice -Name route -Instructions 'Where?' -Choices @() } |
            Should -Throw '*Choice questions require*'
    }
}

Describe 'Invoke-OpenAIDecision' {
    BeforeEach {
        $env:OPENAI_API_KEY = 'pester-test-key'
    }

    It 'serializes object input, posts a valid request, and maps named answers' {
        Mock Invoke-RestMethod {
            [pscustomobject]@{
                model   = 'gpt-6-luna'
                answers = @([pscustomobject]@{ type = 'predicate'; name = 'damaged'; probability = 0.93 })
                usage   = [pscustomobject]@{ input_tokens = 12; total_tokens = 12 }
            }
        } -ModuleName PSAIOpenAIDecisions

        $inputObject = [pscustomobject]@{ message = 'The package arrived broken.'; order = 42 }
        $question = New-OpenAIYesNoQuestion -Name damaged -Question 'Is the item damaged?'
        $result = Invoke-OpenAIDecision -InputObject $inputObject -Question $question

        [object]::ReferenceEquals($result.State, $inputObject) | Should -BeTrue
        $result.damaged | Should -Be 0.93
        $result.answers.damaged.probability | Should -Be 0.93
        Should -Invoke Invoke-RestMethod -ModuleName PSAIOpenAIDecisions -Times 1 -Exactly -ParameterFilter {
            $payload = ConvertFrom-Json -InputObject $Body
            $Method -eq 'Post' -and
            $Uri.AbsoluteUri -eq 'https://api.openai.com/v1/decisions' -and
            $payload.model -eq 'gpt-6-luna' -and
            $payload.input -eq '{"message":"The package arrived broken.","order":42}' -and
            $payload.questions[0].type -eq 'predicate' -and
            $payload.questions[0].name -eq 'damaged'
        }
    }

    It 'preserves user message arrays and returns the raw response when requested' {
        Mock Invoke-RestMethod {
            [pscustomobject]@{ model = 'gpt-6-luna'; answers = @(); usage = $null }
        } -ModuleName PSAIOpenAIDecisions

        $messages = @([pscustomobject]@{
                role    = 'user'
                content = @([pscustomobject]@{ type = 'input_text'; text = 'Describe this image.' })
            })
        $question = New-OpenAIYesNoQuestion -Name present -Question 'Is a product visible?'
        $result = Invoke-OpenAIDecision -InputObject $messages -Question $question -Raw

        $result.model | Should -Be 'gpt-6-luna'
        $result.PSObject.Properties.Name | Should -Not -Contain 'State'
        Should -Invoke Invoke-RestMethod -ModuleName PSAIOpenAIDecisions -Times 1 -Exactly -ParameterFilter {
            $payload = ConvertFrom-Json -InputObject $Body
            $payload.input[0].role -eq 'user' -and $payload.input[0].content[0].text -eq 'Describe this image.'
        }
    }

    It 'requires an API key before making a request' {
        Remove-Item Env:OPENAI_API_KEY -ErrorAction SilentlyContinue
        $question = New-OpenAIYesNoQuestion -Name ready -Question 'Is it ready?'

        { Invoke-OpenAIDecision -InputObject 'ready' -Question $question } |
            Should -Throw '*OPENAI_API_KEY is not set*'
    }

    It 'rejects malformed wire questions before making a request' {
        Mock Invoke-RestMethod {} -ModuleName PSAIOpenAIDecisions
        { Invoke-OpenAIDecision -InputObject 'hello' -Questions @(@{ type = 'choice'; name = 'route'; instructions = 'Route it' }) } |
            Should -Throw '*Each choice must have a value*'
        Should -Invoke Invoke-RestMethod -ModuleName PSAIOpenAIDecisions -Times 0 -Exactly
    }
}

Describe 'PSAIOpenAIDecisions pipeline commands' {
    BeforeEach {
        $env:OPENAI_API_KEY = 'pester-test-key'
    }

    It 'tests predicates and keeps only inputs above the threshold' {
        Mock Invoke-RestMethod {
            $payload = ConvertFrom-Json -InputObject $Body
            $probability = if ($payload.input -eq 'urgent') { 0.91 } else { 0.18 }
            [pscustomobject]@{
                model   = 'gpt-6-luna'
                answers = @([pscustomobject]@{ type = 'predicate'; name = 'decision'; probability = $probability })
                usage   = $null
            }
        } -ModuleName PSAIOpenAIDecisions

        Test-OpenAIDecision -State 'urgent' -Question 'Is this urgent?' -Threshold 0.8 | Should -BeTrue
        $selected = @('urgent', 'routine', 'urgent') | Select-OpenAIDecision 'Is this urgent?' -Threshold 0.8
        $selected | Should -Be @('urgent', 'urgent')
        Should -Invoke Invoke-RestMethod -ModuleName PSAIOpenAIDecisions -Times 4 -Exactly
    }

    It 'ranks original inputs by probability and honors Top' {
        Mock Invoke-RestMethod {
            $payload = ConvertFrom-Json -InputObject $Body
            $probability = switch ($payload.input) {
                'first' { 0.5 }
                'second' { 0.9 }
                default { 0.2 }
            }
            [pscustomobject]@{
                model   = 'gpt-6-luna'
                answers = @([pscustomobject]@{ type = 'predicate'; name = 'ranking'; probability = $probability })
                usage   = $null
            }
        } -ModuleName PSAIOpenAIDecisions

        $ranked = @('first', 'second', 'third') | Get-OpenAIDecisionRanking 'Which needs attention?' -Top 2

        $ranked | Should -Be @('second', 'first')
        Should -Invoke Invoke-RestMethod -ModuleName PSAIOpenAIDecisions -Times 3 -Exactly
    }

    It 'returns the selected choice and a numeric score' {
        Mock Invoke-RestMethod {
            $payload = ConvertFrom-Json -InputObject $Body
            if ($payload.questions[0].type -eq 'choice') {
                $answer = [pscustomobject]@{ type = 'choice'; name = 'selection'; choice = 'billing' }
            } else {
                $answer = [pscustomobject]@{ type = 'score'; name = 'rating'; score = 1.4 }
            }
            [pscustomobject]@{ model = 'gpt-6-luna'; answers = @($answer); usage = $null }
        } -ModuleName PSAIOpenAIDecisions

        Get-OpenAIDecisionChoice -State 'duplicate charge' -Question 'Which team?' billing support |
            Should -Be 'billing'
        Get-OpenAIDecisionScore -State 'blocked task' -Question 'How urgent?' low medium high |
            Should -Be 1.4
        Should -Invoke Invoke-RestMethod -ModuleName PSAIOpenAIDecisions -Times 2 -Exactly
    }

    It 'finds the selected original candidate in one request' {
        Mock Invoke-RestMethod {
            [pscustomobject]@{
                model   = 'gpt-6-luna'
                answers = @([pscustomobject]@{ type = 'choice'; name = 'match'; choice = 'candidate0002' })
                usage   = $null
            }
        } -ModuleName PSAIOpenAIDecisions

        $selected = @('unrelated', 'checkout failure', 'other') |
            Find-OpenAIDecision 'Which log explains the checkout failure?'

        $selected | Should -Be 'checkout failure'
        Should -Invoke Invoke-RestMethod -ModuleName PSAIOpenAIDecisions -Times 1 -Exactly
    }

    It 'adds named answers and matching tags without losing input fields' {
        Mock Invoke-RestMethod {
            $payload = ConvertFrom-Json -InputObject $Body
            $answers = foreach ($question in $payload.questions) {
                if ($question.type -eq 'choice') {
                    [pscustomobject]@{ type = 'choice'; name = $question.name; choice = 'billing' }
                } else {
                    $probability = if ($question.name -eq 'urgent') { 0.87 } else { 0.12 }
                    [pscustomobject]@{ type = 'predicate'; name = $question.name; probability = $probability }
                }
            }
            [pscustomobject]@{ model = 'gpt-6-luna'; answers = @($answers); usage = $null }
        } -ModuleName PSAIOpenAIDecisions

        $question = New-OpenAIDecisionQuestion -Type Choice -Name route -Instructions 'Which team?' -Choices @('billing', 'support')
        $annotated = [pscustomobject]@{ Id = 10; Subject = 'Duplicate charge' } |
            Add-OpenAIDecisionAnnotation -Question $question
        $tagged = [pscustomobject]@{ Id = 11; Tags = @('existing'); Subject = 'Urgent invoice issue' } |
            Add-OpenAIDecisionTag -Tags ([ordered]@{ urgent = 'Needs action today.'; account = 'Account access issue.' }) -Threshold 0.8

        $annotated.Id | Should -Be 10
        $annotated.route | Should -Be 'billing'
        $annotated.answers[0].choice | Should -Be 'billing'
        $tagged.Id | Should -Be 11
        $tagged.Tags | Should -Be @('existing')
        $tagged.OpenAIDecision_Tags | Should -Be @('urgent')
        Should -Invoke Invoke-RestMethod -ModuleName PSAIOpenAIDecisions -Times 2 -Exactly
    }
}