Tests/Add-JevTag.Tests.ps1

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

AfterAll {
    Remove-Module Jev -Force -ErrorAction SilentlyContinue
}

Describe 'Add-JevTag independent labels' {
    BeforeEach {
        $tagDefinitions = [ordered]@{
            billing = 'A current billing problem.'
            urgent = 'An unresolved problem with a deadline today.'
        }
        Mock -ModuleName Jev Invoke-JevDecision {
            param($State, $Questions)
            $Questions.Count | Should -Be 2
            $Questions.billing.type | Should -Be 'noul'
            $Questions.urgent.type | Should -Be 'noul'
            $Questions.billing.criteria['true'] | Should -Be 'A current billing problem.'
            $Questions.urgent.criteria['true'] | Should -Be 'An unresolved problem with a deadline today.'
            [pscustomobject]@{
                model = 'test-model'
                answers = [ordered]@{
                    billing = [pscustomobject]@{ type = 'noul'; noul = 0.93 }
                    urgent = [pscustomobject]@{ type = 'noul'; noul = 0.88 }
                }
                usage = [pscustomobject]@{ input_tokens = 123; output_tokens = 12 }
            }
        }
    }

    It 'returns several matching labels and probabilities in one request per record' {
        $results = @(@('First message.', 'Second message.') | Add-JevTag $tagDefinitions 0.8)

        $results.Count | Should -Be 2
        $results.State | Should -Be @('First message.', 'Second message.')
        $results[0].Tags | Should -Be @('billing', 'urgent')
        ($results[0].Tags -is [string[]]) | Should -BeTrue
        $results[0].billing | Should -Be 0.93
        $results[0].urgent | Should -Be 0.88
        $results[0].model | Should -Be 'test-model'
        $results[0].usage.input_tokens | Should -Be 123
        $results[0].answers.billing.noul | Should -Be 0.93
        Should -Invoke Invoke-JevDecision -ModuleName Jev -Exactly 2 -Scope It
    }

    It 'includes the exact threshold boundary and keeps a single tag as an array' {
        Mock -ModuleName Jev Invoke-JevDecision {
            [pscustomobject]@{ answers = @{
                billing = @{ type = 'noul'; noul = 0.8 }
                urgent = @{ type = 'noul'; noul = 0.799 }
            } }
        }
        $result = 'A message.' | Add-JevTag $tagDefinitions -Threshold 0.8

        $result.Tags.Count | Should -Be 1
        $result.Tags[0] | Should -Be 'billing'
        ($result.Tags -is [string[]]) | Should -BeTrue
        $result.urgent | Should -Be 0.799
    }

    It 'uses the default threshold of one half' {
        Mock -ModuleName Jev Invoke-JevDecision {
            [pscustomobject]@{ answers = @{
                billing = @{ type = 'noul'; noul = 0.5 }
                urgent = @{ type = 'noul'; noul = 0.49 }
            } }
        }
        $result = 'A message.' | Add-JevTag $tagDefinitions
        $result.Tags | Should -Be @('billing')
    }

    It 'keeps the record with an empty tag array when no label meets the cutoff' {
        Mock -ModuleName Jev Invoke-JevDecision {
            [pscustomobject]@{ answers = @{
                billing = @{ type = 'noul'; noul = 0.02 }
                urgent = @{ type = 'noul'; noul = 0.7 }
            } }
        }
        $results = @('No current problem.' | Add-JevTag $tagDefinitions 0.8)

        $results.Count | Should -Be 1
        $results[0].Tags.Count | Should -Be 0
        ($results[0].Tags -is [string[]]) | Should -BeTrue
        $results[0].State | Should -Be 'No current problem.'
        $results[0].urgent | Should -Be 0.7
    }

    It 'retains object properties and handles repeated collisions without modifying the input' {
        $original = [pscustomobject]@{
            Id = 'MAIL-01'
            Message = 'Charged twice today.'
            Tags = @('original')
            Jev_Tags = 'Existing tag metadata'
            billing = 'Existing billing data'
            Jev_billing = 'Older probability'
        }
        $result = $original | Add-JevTag $tagDefinitions

        $result.Id | Should -Be 'MAIL-01'
        $result.Message | Should -Be $original.Message
        $result.Tags | Should -Be @('original')
        $result.Jev_Tags | Should -Be 'Existing tag metadata'
        $result.Jev_Jev_Tags | Should -Be @('billing', 'urgent')
        $result.billing | Should -Be 'Existing billing data'
        $result.Jev_billing | Should -Be 'Older probability'
        $result.Jev_Jev_billing | Should -Be 0.93
        @($original.PSObject.Properties.Name) | Should -Not -Contain 'Jev_Jev_Tags'
        @($original.PSObject.Properties.Name) | Should -Not -Contain 'answers'
        [object]::ReferenceEquals($result, $original) | Should -BeFalse
    }

    It 'retains dictionary input properties and preserves a named array under State' {
        $dictionary = [ordered]@{ Id = 42; Message = 'A billing problem.' }
        $dictionaryResult = Add-JevTag -State $dictionary -Tags $tagDefinitions
        $dictionaryResult.Id | Should -Be 42
        $dictionary.Contains('Tags') | Should -BeFalse

        $array = @('A billing problem.', 'Due today.')
        $arrayResult = Add-JevTag -State $array -Tags $tagDefinitions
        [object]::ReferenceEquals($arrayResult.State, $array) | Should -BeTrue
        $arrayResult.Tags | Should -Be @('billing', 'urgent')
        Should -Invoke Invoke-JevDecision -ModuleName Jev -Exactly 2 -Scope It
    }

    It 'makes no request for empty pipeline input' {
        @(@() | Add-JevTag $tagDefinitions).Count | Should -Be 0
        Should -Invoke Invoke-JevDecision -ModuleName Jev -Exactly 0 -Scope It
    }

    It 'rejects empty definitions before making a request' {
        { 'A message.' | Add-JevTag @{} } | Should -Throw '*at least one*'
        Should -Invoke Invoke-JevDecision -ModuleName Jev -Exactly 0 -Scope It
    }

    It 'rejects invalid definitions: <Case>' -TestCases @(
        @{ Case = 'blank name'; Definitions = @{ ' ' = 'A description.' } }
        @{ Case = 'empty description'; Definitions = @{ billing = '' } }
        @{ Case = 'null description'; Definitions = @{ billing = $null } }
        @{ Case = 'non-string description'; Definitions = @{ billing = 42 } }
    ) {
        param($Case, $Definitions)
        { 'A message.' | Add-JevTag $Definitions } | Should -Throw
        Should -Invoke Invoke-JevDecision -ModuleName Jev -Exactly 0 -Scope It
    }

    It 'rejects labels that differ only in case before making a request' {
        $definitions = [System.Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal)
        $definitions.Add('billing', 'A charge problem.')
        $definitions.Add('Billing', 'An invoice problem.')

        { 'A message.' | Add-JevTag $definitions } | Should -Throw '*case-insensitive*'
        Should -Invoke Invoke-JevDecision -ModuleName Jev -Exactly 0 -Scope It
    }

    It 'rejects an invalid threshold: <Threshold>' -TestCases @(
        @{ Threshold = -0.1 }
        @{ Threshold = 1.1 }
    ) {
        param($Threshold)
        { 'A message.' | Add-JevTag $tagDefinitions -Threshold $Threshold } | Should -Throw
        Should -Invoke Invoke-JevDecision -ModuleName Jev -Exactly 0 -Scope It
    }

    It 'rejects a missing tag probability without emitting a partially tagged record' {
        Mock -ModuleName Jev Invoke-JevDecision {
            [pscustomobject]@{ answers = @{ billing = @{ type = 'noul'; noul = 0.9 } } }
        }
        $emitted = [System.Collections.Generic.List[object]]::new()

        { 'A message.' | Add-JevTag $tagDefinitions | ForEach-Object { $emitted.Add($_) } } |
            Should -Throw '*Noul answer*urgent*'
        $emitted.Count | Should -Be 0
    }

    It 'rejects an invalid probability: <Probability>' -TestCases @(
        @{ Probability = -0.1 }
        @{ Probability = 1.1 }
        @{ Probability = [double]::NaN }
        @{ Probability = [double]::PositiveInfinity }
    ) {
        param($Probability)
        Mock -ModuleName Jev Invoke-JevDecision {
            [pscustomobject]@{ answers = @{
                billing = @{ type = 'noul'; noul = $Probability }
                urgent = @{ type = 'noul'; noul = 0.9 }
            } }
        }
        { 'A message.' | Add-JevTag $tagDefinitions } | Should -Throw '*invalid probability*'
    }

    It 'propagates request failures' {
        Mock -ModuleName Jev Invoke-JevDecision { throw 'API unavailable.' }
        { 'A message.' | Add-JevTag $tagDefinitions } | Should -Throw '*API unavailable*'
    }
}