#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } <# FR-030 / acceptance scenarios 9-11 (spec.md User Story 5): the [A]/[E]/[D] commands are actually wired into Edit-PersonaEngineConfig.ps1's interactive loop, structural edits stay in memory until Save, and a depth-limit violation is rejected at edit time using the real validator's own finding. The pure functions behind these commands (Add-PersonaConfigRule, Get/Add/Remove-PersonaConditionNode, Set-PersonaConditionLeaf) are covered directly in EditorAddRule/EditorEditRule/EditorDeleteRule.Tests.ps1. This suite exists to prove the wiring itself - the prompts, the menu, and the candidate-edit-or-revert behaviour - which only exists inside the script, not in a dot-sourceable function. Same technique as NonInteractive.Tests.ps1: a child pwsh with stdin redirected from a file. There, an EMPTY file proves the non-interactive path never reads it. Here, a SCRIPTED file drives the interactive menu exactly as a human typing answers would, which is the only way to exercise Read-Host-based prompts from a top-level script that is not a dot-sourceable module. #> BeforeAll { $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent . (Join-Path $repoRoot 'tests/TestHelpers.ps1') $script:editor = Join-Path $repoRoot 'Edit-PersonaEngineConfig.ps1' $script:scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("pe-structural-{0}" -f [guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory -Path $script:scratch -Force function Invoke-EditorWithScriptedInput { <# Runs the editor interactively (no -NonInteractive) with stdin fed from a sequence of scripted answers, one per line, and a hard timeout so a prompt sequence that runs dry (and therefore blocks on Read-Host) fails the test instead of hanging the suite. #> param( [string[]] $ArgumentList, [string[]] $Answers, [int] $TimeoutSeconds = 60 ) $stdinFile = Join-Path $script:scratch ("in-{0}.txt" -f [guid]::NewGuid().ToString('N')) Set-Content -LiteralPath $stdinFile -Value ($Answers -join "`n") -NoNewline -Encoding utf8NoBOM $stdout = Join-Path $script:scratch ("out-{0}.txt" -f [guid]::NewGuid().ToString('N')) $stderr = Join-Path $script:scratch ("err-{0}.txt" -f [guid]::NewGuid().ToString('N')) $process = Start-Process -FilePath (Get-Process -Id $PID).Path ` -ArgumentList (@('-NoProfile', '-File', $script:editor) + $ArgumentList) ` -RedirectStandardInput $stdinFile ` -RedirectStandardOutput $stdout ` -RedirectStandardError $stderr ` -PassThru -WindowStyle Hidden if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { $process.Kill($true) return [pscustomobject]@{ ExitCode = -1; Output = 'TIMED OUT'; Error = ''; TimedOut = $true } } [pscustomobject]@{ ExitCode = $process.ExitCode Output = (Get-Content -LiteralPath $stdout -Raw -ErrorAction SilentlyContinue) Error = (Get-Content -LiteralPath $stderr -Raw -ErrorAction SilentlyContinue) TimedOut = $false } } function New-ScratchConfigPath { param([hashtable] $Document = (New-TestConfigurationDocument)) Save-TestConfiguration -Document $Document -Directory $script:scratch } } AfterAll { Remove-Item -LiteralPath $script:scratch -Recurse -Force -ErrorAction SilentlyContinue } Describe 'Interactive rule add/edit/delete (FR-027 - FR-030)' { It 'adds a rule and persists it only after Save' { $path = New-ScratchConfigPath $answers = @( 'A' 'RULE-0500-TEST'; 'Test rule'; 'A rule added by a test'; '500'; 'Employee'; 'Y' 'all' 'C'; 'P'; 'isNotNull'; 'JobTitle' 'N' 'S' 'Q' ) $run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers $run.TimedOut | Should -BeFalse $run.ExitCode | Should -Be 0 $run.Output | Should -Match 'Rule RULE-0500-TEST added' $run.Output | Should -Match 'Saved:' $saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32 @($saved.rules | Where-Object { $_.id -eq 'RULE-0500-TEST' }).Count | Should -Be 1 } It 'leaves the file untouched when an add is not saved' { $path = New-ScratchConfigPath $before = Get-Content -LiteralPath $path -Raw $answers = @( 'A' 'RULE-0500-TEST'; 'Test rule'; 'A rule added by a test'; '500'; 'Employee'; 'Y' 'all' 'C'; 'P'; 'isNotNull'; 'JobTitle' 'N' 'Q'; 'y' ) $run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers $run.TimedOut | Should -BeFalse $run.ExitCode | Should -Be 0 (Get-Content -LiteralPath $path -Raw) | Should -Be $before } It 'rejects a duplicate rule id with a message naming the conflict, and does not save it' { $path = New-ScratchConfigPath $answers = @( 'A' 'RULE-0010-GUEST'; 'Duplicate'; 'Collides with an existing id'; '999'; 'Employee'; 'Y' 'all' 'C'; 'P'; 'isNotNull'; 'JobTitle' 'N' 'Q' ) $run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers $run.TimedOut | Should -BeFalse $run.Output | Should -Match 'RULE-0010-GUEST' $run.Output | Should -Match 'Add cancelled' $saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32 @($saved.rules).Count | Should -Be 2 } It 'deletes a rule after confirmation and persists it only after Save' { $path = New-ScratchConfigPath $answers = @('D', 'RULE-0010-GUEST', 'y', 'S', 'Q') $run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers $run.TimedOut | Should -BeFalse $run.Output | Should -Match 'Rule RULE-0010-GUEST deleted' $saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32 @($saved.rules | Where-Object { $_.id -eq 'RULE-0010-GUEST' }).Count | Should -Be 0 } It 'declining the delete confirmation leaves the rule in place' { $path = New-ScratchConfigPath $answers = @('D', 'RULE-0010-GUEST', 'n', 'Q') $run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers $run.TimedOut | Should -BeFalse $run.Output | Should -Match 'Delete cancelled' $saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32 @($saved.rules | Where-Object { $_.id -eq 'RULE-0010-GUEST' }).Count | Should -Be 1 } It 'rejects a condition-tree edit that would exceed the configured nesting depth, using the real validator finding' { # maxConditionDepth: 2 means root(1) -> leaf(2) is the deepest a rule may go. # Nesting a group under the root puts that group's own children at depth 3, # which PE-SEM-012 catches - the same code the runtime validator and the [V] # command produce (see Test-PersonaConfigurationSemantic.ps1). $document = New-TestConfigurationDocument $document.engine.maxConditionDepth = 2 $path = New-ScratchConfigPath -Document $document $before = Get-Content -LiteralPath $path -Raw $answers = @( 'E'; 'RULE-0900-EMPLOYEE' 'C'; '' 'G'; 'any' 'C'; 'P'; 'isNotNull'; 'JobTitle' 'N' 'Q' ) $run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers $run.TimedOut | Should -BeFalse $run.Output | Should -Match 'Edit rejected' $run.Output | Should -Match 'PE-SEM-012' (Get-Content -LiteralPath $path -Raw) | Should -Be $before } It 'an accepted condition-tree edit is held in memory and appears after Save' { $document = New-TestConfigurationDocument $path = New-ScratchConfigPath -Document $document $answers = @( 'E'; 'RULE-0900-EMPLOYEE' 'C'; '' 'C'; 'P'; 'isNotNull'; 'JobTitle' 'S'; 'Q' ) $run = Invoke-EditorWithScriptedInput -ArgumentList @('-ConfigPath', $path) -Answers $answers $run.TimedOut | Should -BeFalse $run.Output | Should -Match 'Rule RULE-0900-EMPLOYEE updated' $run.Output | Should -Match 'Saved:' $saved = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -Depth 32 $rule = $saved.rules | Where-Object { $_.id -eq 'RULE-0900-EMPLOYEE' } @($rule.match.conditions).Count | Should -Be 2 } }