microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
scripts/tests/evals/Invoke-ContentModeration.Tests.ps1
327lines · modecode
| 1 | #Requires -Modules Pester |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | |
| 5 | BeforeAll { |
| 6 | $script:ScriptPath = (Resolve-Path (Join-Path $PSScriptRoot '../../evals/Invoke-ContentModeration.ps1')).Path |
| 7 | $script:ModulePath = (Resolve-Path (Join-Path $PSScriptRoot '../../evals/Modules/ModerationRunner.psm1')).Path |
| 8 | Import-Module $script:ModulePath -Force |
| 9 | |
| 10 | # Runs the script in a child pwsh process so that explicit `exit N` calls |
| 11 | # set the process exit code. The call operator does not propagate a script |
| 12 | # file's exit code, so the child relays $LASTEXITCODE explicitly. Returns the |
| 13 | # child exit code. All output streams are suppressed to keep test output clean. |
| 14 | function Invoke-Script { |
| 15 | param([string]$Arguments) |
| 16 | $command = "& '$script:ScriptPath' $Arguments; exit `$LASTEXITCODE" |
| 17 | pwsh -NoProfile -NonInteractive -Command $command *> $null |
| 18 | return $LASTEXITCODE |
| 19 | } |
| 20 | |
| 21 | # Runs the script in a child pwsh process with a fake `python` function that |
| 22 | # shadows the external interpreter. The fake function writes a canned |
| 23 | # moderate.py output file (unless -SkipOutput) and sets the simulated process |
| 24 | # exit code via $global:LASTEXITCODE. This exercises the python-in-PATH happy |
| 25 | # path and the non-zero moderate.py exit handling without invoking torch. |
| 26 | function Invoke-ScriptWithFakePython { |
| 27 | param( |
| 28 | [string]$Arguments, |
| 29 | [int]$FlaggedCount = 0, |
| 30 | [int]$PythonExitCode = 0, |
| 31 | [switch]$SkipOutput |
| 32 | ) |
| 33 | |
| 34 | $recordItem = if ($FlaggedCount -gt 0) { |
| 35 | "@{ id = 'rec1'; flagged = `$true; flaggedLabels = @('toxicity') }" |
| 36 | } |
| 37 | else { |
| 38 | '' |
| 39 | } |
| 40 | |
| 41 | if ($SkipOutput) { |
| 42 | $writeBlock = '' |
| 43 | } |
| 44 | else { |
| 45 | $writeBlock = @" |
| 46 | `$outPath = `$null |
| 47 | for (`$i = 0; `$i -lt `$args.Count; `$i++) { |
| 48 | if (`$args[`$i] -eq '--output') { `$outPath = `$args[`$i + 1] } |
| 49 | } |
| 50 | @{ records = @($recordItem); summary = @{ total = 1; flaggedCount = $FlaggedCount } } | |
| 51 | ConvertTo-Json -Depth 10 | Set-Content -Path `$outPath -Encoding utf8NoBOM |
| 52 | "@ |
| 53 | } |
| 54 | |
| 55 | $fakePython = @" |
| 56 | function python { |
| 57 | $writeBlock |
| 58 | `$global:LASTEXITCODE = $PythonExitCode |
| 59 | } |
| 60 | "@ |
| 61 | |
| 62 | $command = "$fakePython`n& '$script:ScriptPath' $Arguments; exit `$LASTEXITCODE" |
| 63 | pwsh -NoProfile -NonInteractive -Command $command *> $null |
| 64 | return $LASTEXITCODE |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | Describe 'Invoke-ContentModeration exit codes' -Tag 'Unit' { |
| 69 | Context 'when -FileList and -Records are both provided' { |
| 70 | It 'Exits with code 2' { |
| 71 | $code = Invoke-Script "-Scope 'test' -FileList 'a.md' -Records @(@{ id = 'x'; text = 'y' })" |
| 72 | $code | Should -Be 2 |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | Context 'when neither -FileList nor -Records is provided' { |
| 77 | It 'Exits with code 2' { |
| 78 | $code = Invoke-Script "-Scope 'test'" |
| 79 | $code | Should -Be 2 |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | Context 'when -Records is an empty array' { |
| 84 | BeforeAll { |
| 85 | $script:OutFile = Join-Path $TestDrive 'moderation-empty.json' |
| 86 | $script:Code = Invoke-Script "-Scope 'empty' -Records @() -OutFile '$script:OutFile'" |
| 87 | } |
| 88 | |
| 89 | It 'Exits with code 0' { |
| 90 | $script:Code | Should -Be 0 |
| 91 | } |
| 92 | |
| 93 | It 'Writes an empty output file' { |
| 94 | Test-Path $script:OutFile | Should -BeTrue |
| 95 | } |
| 96 | |
| 97 | It 'Reports zero total records in the output' { |
| 98 | $output = Get-Content -Path $script:OutFile -Raw | ConvertFrom-Json |
| 99 | $output.summary.total | Should -Be 0 |
| 100 | $output.summary.flaggedCount | Should -Be 0 |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | Describe 'Invoke-ContentModeration moderate.py invocation' -Tag 'Unit' { |
| 106 | Context 'when python is available and content passes' { |
| 107 | BeforeAll { |
| 108 | $script:OutFile = Join-Path $TestDrive 'moderation-pass.json' |
| 109 | $script:Code = Invoke-ScriptWithFakePython -Arguments "-Scope 'pass' -Records @(@{ id = 'x'; text = 'hello' }) -OutFile '$script:OutFile' -RepoRoot '$TestDrive'" -FlaggedCount 0 -PythonExitCode 0 |
| 110 | } |
| 111 | |
| 112 | It 'Exits with code 0' { |
| 113 | $script:Code | Should -Be 0 |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | Context 'when python is available and content is flagged' { |
| 118 | BeforeAll { |
| 119 | $script:OutFile = Join-Path $TestDrive 'moderation-flagged.json' |
| 120 | $script:Code = Invoke-ScriptWithFakePython -Arguments "-Scope 'flag' -Records @(@{ id = 'x'; text = 'bad' }) -OutFile '$script:OutFile' -RepoRoot '$TestDrive'" -FlaggedCount 1 -PythonExitCode 0 |
| 121 | } |
| 122 | |
| 123 | It 'Exits with code 1' { |
| 124 | $script:Code | Should -Be 1 |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | Context 'when moderate.py exits non-zero but output shows flagged content' { |
| 129 | BeforeAll { |
| 130 | $script:OutFile = Join-Path $TestDrive 'moderation-err-flagged.json' |
| 131 | $script:Code = Invoke-ScriptWithFakePython -Arguments "-Scope 'errflag' -Records @(@{ id = 'x'; text = 'bad' }) -OutFile '$script:OutFile' -RepoRoot '$TestDrive'" -FlaggedCount 1 -PythonExitCode 2 |
| 132 | } |
| 133 | |
| 134 | It 'Exits with code 1' { |
| 135 | $script:Code | Should -Be 1 |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | Context 'when moderate.py exits non-zero with clean output' { |
| 140 | BeforeAll { |
| 141 | $script:OutFile = Join-Path $TestDrive 'moderation-err-clean.json' |
| 142 | $script:Code = Invoke-ScriptWithFakePython -Arguments "-Scope 'errclean' -Records @(@{ id = 'x'; text = 'ok' }) -OutFile '$script:OutFile' -RepoRoot '$TestDrive'" -FlaggedCount 0 -PythonExitCode 2 |
| 143 | } |
| 144 | |
| 145 | It 'Propagates the moderate.py exit code' { |
| 146 | $script:Code | Should -Be 2 |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | Context 'when moderate.py exits non-zero and writes no output' { |
| 151 | BeforeAll { |
| 152 | $script:OutFile = Join-Path $TestDrive 'moderation-err-missing.json' |
| 153 | $script:Code = Invoke-ScriptWithFakePython -Arguments "-Scope 'errmissing' -Records @(@{ id = 'x'; text = 'ok' }) -OutFile '$script:OutFile' -RepoRoot '$TestDrive'" -PythonExitCode 2 -SkipOutput |
| 154 | } |
| 155 | |
| 156 | It 'Propagates the moderate.py exit code' { |
| 157 | $script:Code | Should -Be 2 |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | Describe 'New-ModerationInputFile' -Tag 'Unit' { |
| 163 | Context 'when given records' { |
| 164 | BeforeAll { |
| 165 | $script:Records = @( |
| 166 | @{ id = 'rec1'; text = 'Hello world' } |
| 167 | @{ id = 'rec2'; text = 'Test content' } |
| 168 | ) |
| 169 | $script:InputFile = Join-Path $TestDrive 'input.jsonl' |
| 170 | $script:Result = New-ModerationInputFile -Records $script:Records -OutFile $script:InputFile |
| 171 | } |
| 172 | |
| 173 | It 'Returns the output file path' { |
| 174 | $script:Result | Should -Be $script:InputFile |
| 175 | } |
| 176 | |
| 177 | It 'Writes one JSON line per record' { |
| 178 | $lines = Get-Content -Path $script:InputFile |
| 179 | $lines | Should -HaveCount 2 |
| 180 | } |
| 181 | |
| 182 | It 'Writes valid JSON with id and text on each line' { |
| 183 | $lines = Get-Content -Path $script:InputFile |
| 184 | foreach ($line in $lines) { |
| 185 | $record = $line | ConvertFrom-Json |
| 186 | $record.id | Should -Not -BeNullOrEmpty |
| 187 | $record.text | Should -Not -BeNullOrEmpty |
| 188 | } |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | Context 'when no output file is specified' { |
| 193 | It 'Creates a temp file and returns its path' { |
| 194 | $records = @(@{ id = 'rec1'; text = 'content' }) |
| 195 | $path = New-ModerationInputFile -Records $records |
| 196 | try { |
| 197 | Test-Path $path | Should -BeTrue |
| 198 | } |
| 199 | finally { |
| 200 | Remove-Item $path -Force -ErrorAction SilentlyContinue |
| 201 | } |
| 202 | } |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | Describe 'ConvertTo-ModerationRecords' -Tag 'Unit' { |
| 207 | Context 'when given existing files' { |
| 208 | BeforeAll { |
| 209 | $script:FileA = Join-Path $TestDrive 'file-a.md' |
| 210 | $script:FileB = Join-Path $TestDrive 'file-b.md' |
| 211 | Set-Content -Path $script:FileA -Value 'Content A' -Encoding utf8NoBOM |
| 212 | Set-Content -Path $script:FileB -Value 'Content B' -Encoding utf8NoBOM |
| 213 | $script:Records = ConvertTo-ModerationRecords -FileList @($script:FileA, $script:FileB) -RepoRoot $TestDrive |
| 214 | } |
| 215 | |
| 216 | It 'Returns one record per file' { |
| 217 | $script:Records | Should -HaveCount 2 |
| 218 | } |
| 219 | |
| 220 | It 'Populates the text from file content' { |
| 221 | $script:Records[0].text | Should -Match 'Content A' |
| 222 | $script:Records[1].text | Should -Match 'Content B' |
| 223 | } |
| 224 | |
| 225 | It 'Populates a relative id for each record' { |
| 226 | $script:Records | ForEach-Object { |
| 227 | $_.id | Should -Not -BeNullOrEmpty |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | Context 'when a file does not exist' { |
| 233 | It 'Skips the missing file and warns' { |
| 234 | Mock Write-Warning {} -ModuleName ModerationRunner |
| 235 | $missing = Join-Path $TestDrive 'does-not-exist.md' |
| 236 | $records = ConvertTo-ModerationRecords -FileList @($missing) -RepoRoot $TestDrive |
| 237 | $records | Should -HaveCount 0 |
| 238 | Should -Invoke Write-Warning -ModuleName ModerationRunner -Times 1 -Exactly |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | Context 'when a file contains unicode content' { |
| 243 | It 'Preserves unicode characters in the record text' { |
| 244 | $unicodeFile = Join-Path $TestDrive 'unicode.md' |
| 245 | Set-Content -Path $unicodeFile -Value 'Café ☕ 日本語 Ñoño — emoji 🎉' -Encoding utf8NoBOM |
| 246 | $records = @(ConvertTo-ModerationRecords -FileList @($unicodeFile) -RepoRoot $TestDrive) |
| 247 | $records | Should -HaveCount 1 |
| 248 | $records[0].text | Should -Match 'Café' |
| 249 | $records[0].text | Should -Match '日本語' |
| 250 | $records[0].text | Should -Match '🎉' |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | Context 'when a file is large' { |
| 255 | It 'Returns a single record containing the full content' { |
| 256 | $largeFile = Join-Path $TestDrive 'large.md' |
| 257 | $content = ('The quick brown fox jumps over the lazy dog. ' * 5000) |
| 258 | Set-Content -Path $largeFile -Value $content -Encoding utf8NoBOM |
| 259 | $records = @(ConvertTo-ModerationRecords -FileList @($largeFile) -RepoRoot $TestDrive) |
| 260 | $records | Should -HaveCount 1 |
| 261 | $records[0].text.Length | Should -BeGreaterThan 100000 |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | Context 'when a file contains only whitespace' { |
| 266 | It 'Returns a record without warning' { |
| 267 | Mock Write-Warning {} -ModuleName ModerationRunner |
| 268 | $wsFile = Join-Path $TestDrive 'whitespace.md' |
| 269 | Set-Content -Path $wsFile -Value " `n`t `n" -Encoding utf8NoBOM |
| 270 | $records = ConvertTo-ModerationRecords -FileList @($wsFile) -RepoRoot $TestDrive |
| 271 | $records | Should -HaveCount 1 |
| 272 | Should -Invoke Write-Warning -ModuleName ModerationRunner -Times 0 -Exactly |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | Context 'when a file is empty' { |
| 277 | It 'Returns a record without warning' { |
| 278 | Mock Write-Warning {} -ModuleName ModerationRunner |
| 279 | $emptyFile = Join-Path $TestDrive 'empty.md' |
| 280 | [System.IO.File]::WriteAllText($emptyFile, '') |
| 281 | $records = ConvertTo-ModerationRecords -FileList @($emptyFile) -RepoRoot $TestDrive |
| 282 | $records | Should -HaveCount 1 |
| 283 | Should -Invoke Write-Warning -ModuleName ModerationRunner -Times 0 -Exactly |
| 284 | } |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | Describe 'Test-ModerationOutput' -Tag 'Unit' { |
| 289 | BeforeAll { |
| 290 | Mock Write-Host {} -ModuleName ModerationRunner |
| 291 | Mock Write-Warning {} -ModuleName ModerationRunner |
| 292 | Mock Write-Error {} -ModuleName ModerationRunner |
| 293 | } |
| 294 | |
| 295 | Context 'when no records are flagged' { |
| 296 | It 'Returns false' { |
| 297 | $outputPath = Join-Path $TestDrive 'clean.json' |
| 298 | @{ records = @(); summary = @{ total = 3; flaggedCount = 0 } } | |
| 299 | ConvertTo-Json -Depth 10 | Set-Content -Path $outputPath -Encoding utf8NoBOM |
| 300 | Test-ModerationOutput -OutputPath $outputPath | Should -BeFalse |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | Context 'when records are flagged' { |
| 305 | It 'Returns true' { |
| 306 | $outputPath = Join-Path $TestDrive 'flagged.json' |
| 307 | @{ |
| 308 | records = @( |
| 309 | @{ id = 'rec1'; flagged = $true; flaggedLabels = @('toxicity') } |
| 310 | ) |
| 311 | summary = @{ total = 1; flaggedCount = 1 } |
| 312 | } | ConvertTo-Json -Depth 10 | Set-Content -Path $outputPath -Encoding utf8NoBOM |
| 313 | Test-ModerationOutput -OutputPath $outputPath | Should -BeTrue |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | Context 'when the output file is missing' { |
| 318 | It 'Returns true' { |
| 319 | $missing = Join-Path $TestDrive 'missing-output.json' |
| 320 | Test-ModerationOutput -OutputPath $missing | Should -BeTrue |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | AfterAll { |
| 326 | Remove-Module ModerationRunner -Force -ErrorAction SilentlyContinue |
| 327 | } |
| 328 | |