config editor

This commit is contained in:
2026-08-21 01:18:19 -04:00
parent c30ef6ec24
commit 01d08e635c
15 changed files with 1501 additions and 16 deletions
+133
View File
@@ -0,0 +1,133 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-027: the interactive editor can add a new rule, and rejects a colliding id or
priority before the rule is added.
Add-PersonaConfigRule is exercised directly rather than through the interactive
script - it is the pure function the [A] command calls, and pure functions are
what this suite can test without driving Read-Host through a subprocess (see
NonInteractive.Tests.ps1 for why that path is reserved for hang/prompt proof, not
ordinary behaviour).
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
function New-TestRule {
param(
[string] $Id = 'RULE-0500-TEST',
[int] $Priority = 500,
[string] $Persona = 'Employee'
)
[pscustomobject]@{
id = $Id
name = 'Test rule'
description = 'A rule added by a test.'
enabled = $true
priority = $Priority
persona = $Persona
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
}
}
Describe 'Add-PersonaConfigRule (FR-027)' {
It 'appends the new rule to the collection' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$new = New-TestRule -Id 'RULE-0500-TEST' -Priority 500
$result = Add-PersonaConfigRule -Rules $existing -Rule $new
$result.Count | Should -Be 2
($result | Where-Object { [string]$_.id -eq 'RULE-0500-TEST' }).Count | Should -Be 1
}
It 'does not mutate the input collection' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$new = New-TestRule -Id 'RULE-0500-TEST' -Priority 500
Add-PersonaConfigRule -Rules $existing -Rule $new | Out-Null
$existing.Count | Should -Be 1
}
It 'always returns an array, even when the result has exactly one rule' {
# The single-element case is the one PowerShell silently collapses to a
# scalar unless the function guards against it (see Add-PersonaConfigRule's
# `, (...)` return) - assigning the call's result directly, with no extra
# @() at this call site, is what proves the function's own guard is doing
# the work rather than an @() here masking a function that does not.
$new = New-TestRule -Id 'RULE-0500-TEST' -Priority 500
$result = Add-PersonaConfigRule -Rules @() -Rule $new
, $result | Should -BeOfType [array]
$result.Count | Should -Be 1
}
It 'rejects a duplicate id before adding' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$colliding = New-TestRule -Id 'RULE-0010-GUEST' -Priority 999
{ Add-PersonaConfigRule -Rules $existing -Rule $colliding } | Should -Throw '*RULE-0010-GUEST*'
}
It 'rejects a duplicate priority before adding' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$colliding = New-TestRule -Id 'RULE-0500-TEST' -Priority 10
{ Add-PersonaConfigRule -Rules $existing -Rule $colliding } | Should -Throw '*10*'
}
It 'leaves the original collection unchanged when the add is rejected' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$colliding = New-TestRule -Id 'RULE-0010-GUEST' -Priority 999
try { Add-PersonaConfigRule -Rules $existing -Rule $colliding } catch { }
$existing.Count | Should -Be 1
}
It 'rejects a blank id' {
$existing = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$blank = New-TestRule -Id '' -Priority 999
{ Add-PersonaConfigRule -Rules $existing -Rule $blank } | Should -Throw
}
It 'the added rule survives a full validation pass' {
$document = New-TestConfigurationDocument
$new = @{
id = 'RULE-0500-TEST'
name = 'Test rule'
description = 'A rule added by a test.'
enabled = $true
priority = 500
persona = 'Employee'
match = @{
operator = 'all'
conditions = @(@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
$document.rules = Add-PersonaConfigRule -Rules $document.rules -Rule $new
$path = Save-TestConfiguration -Document $document -Directory ([System.IO.Path]::GetTempPath())
try {
$result = Test-PersonaConfiguration -Path $path
$result.IsValid | Should -BeTrue
@($result.Document.rules).Count | Should -Be 3
}
finally {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
}
}
@@ -0,0 +1,95 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-029: the interactive editor can delete an existing rule.
Remove-PersonaConfigRule is the pure function the [D] command calls after the
operator confirms against the rule's id, name, and priority - the confirmation
prompt itself lives in Edit-PersonaEngineConfig.ps1 and is covered by
EditorStructuralEdits.Tests.ps1, which drives the real interactive loop.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
function New-TestRule {
param([string] $Id, [int] $Priority)
[pscustomobject]@{
id = $Id
name = "Rule $Id"
description = 'A test rule.'
enabled = $true
priority = $Priority
persona = 'Employee'
match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
}
}
}
Describe 'Remove-PersonaConfigRule (FR-029)' {
It 'removes the named rule' {
$rules = @(
(New-TestRule -Id 'RULE-0010-GUEST' -Priority 10),
(New-TestRule -Id 'RULE-0900-EMPLOYEE' -Priority 900)
)
$result = Remove-PersonaConfigRule -Rules $rules -RuleId 'RULE-0010-GUEST'
, $result | Should -BeOfType [array]
$result.Count | Should -Be 1
$result[0].id | Should -Be 'RULE-0900-EMPLOYEE'
}
It 'does not mutate the input collection' {
$rules = @(
(New-TestRule -Id 'RULE-0010-GUEST' -Priority 10),
(New-TestRule -Id 'RULE-0900-EMPLOYEE' -Priority 900)
)
Remove-PersonaConfigRule -Rules $rules -RuleId 'RULE-0010-GUEST' | Out-Null
$rules.Count | Should -Be 2
}
It 'throws when the id does not exist, rather than silently doing nothing' {
$rules = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
{ Remove-PersonaConfigRule -Rules $rules -RuleId 'RULE-9999-ABSENT' } | Should -Throw '*RULE-9999-ABSENT*'
}
It 'can remove the only remaining rule, leaving an empty collection' {
$rules = @((New-TestRule -Id 'RULE-0010-GUEST' -Priority 10))
$result = Remove-PersonaConfigRule -Rules $rules -RuleId 'RULE-0010-GUEST'
, $result | Should -BeOfType [array]
$result.Count | Should -Be 0
}
It 'a deletion that leaves zero enabled rules is caught by re-validation (PE-SEM-003)' {
# Remove-PersonaConfigRule itself has no opinion on "zero enabled rules" - that
# is VR-002's job. Exercised here to prove the editor's re-validation path
# would in fact block this rather than silently accepting it.
$document = New-TestConfigurationDocument
($document.rules | Where-Object { $_.id -eq 'RULE-0010-GUEST' }).enabled = $false
$document.rules = Remove-PersonaConfigRule -Rules $document.rules -RuleId 'RULE-0900-EMPLOYEE'
$path = Save-TestConfiguration -Document $document -Directory ([System.IO.Path]::GetTempPath())
try {
$result = Test-PersonaConfiguration -Path $path
$result.IsValid | Should -BeFalse
@($result.Findings | Where-Object Code -EQ 'PE-SEM-003').Count | Should -BeGreaterThan 0
}
finally {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
}
}
@@ -0,0 +1,255 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }
<#
FR-028: the interactive editor can edit an existing rule, including its condition
tree - adding, editing, removing, and renesting conditions and all/any groups.
Get-PersonaConditionNode, Add-PersonaConditionNode, Remove-PersonaConditionNode,
and Set-PersonaConditionLeaf are the pure functions the [E] command composes.
Editing a rule's top-level fields (name, priority, persona, ...) is a plain
property assignment - Get-PersonaConditionNode's own doc comment explains why -
so there is nothing there to unit test beyond what Add-PersonaConfigRule already
proves about the shape surviving validation.
Depth-limit rejection at edit time (FR-030 / acceptance scenario 11) is
deliberately not tested here: Add-PersonaConditionNode does not duplicate depth
math (see its doc comment), so that behaviour lives entirely in
Edit-PersonaEngineConfig.ps1's candidate-edit wrapper and is covered by
EditorStructuralEdits.Tests.ps1, which drives the real interactive loop.
#>
BeforeAll {
$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
. (Join-Path $repoRoot 'tests/TestHelpers.ps1')
foreach ($file in (Get-PersonaSourceFile -RepoRoot $repoRoot)) { . $file }
function New-TestMatchGroup {
# root(all) -> [ leaf(Department isNotNull), group(any) -> [ leaf(UserType equals Guest) ] ]
[pscustomobject]@{
operator = 'all'
conditions = @(
[pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }
[pscustomobject]@{
operator = 'any'
conditions = @(
[pscustomobject]@{ type = 'property'; property = 'UserType'; operator = 'equals'; value = 'Guest' }
)
}
)
}
}
}
Describe 'Get-PersonaConditionNode (FR-028)' {
It 'returns the root group for an empty path' {
$match = New-TestMatchGroup
$node = Get-PersonaConditionNode -Group $match -Path @()
$node.operator | Should -Be 'all'
}
It 'navigates to a top-level leaf' {
$match = New-TestMatchGroup
$node = Get-PersonaConditionNode -Group $match -Path @(0)
$node.property | Should -Be 'Department'
}
It 'navigates into a nested group' {
$match = New-TestMatchGroup
$node = Get-PersonaConditionNode -Group $match -Path @(1)
$node.operator | Should -Be 'any'
}
It 'navigates to a leaf inside a nested group' {
$match = New-TestMatchGroup
$node = Get-PersonaConditionNode -Group $match -Path @(1, 0)
$node.property | Should -Be 'UserType'
}
It 'throws on an out-of-range index' {
$match = New-TestMatchGroup
{ Get-PersonaConditionNode -Group $match -Path @(9) } | Should -Throw '*out of range*'
}
It 'throws when a path segment tries to descend into a leaf' {
$match = New-TestMatchGroup
{ Get-PersonaConditionNode -Group $match -Path @(0, 0) } | Should -Throw '*leaf*'
}
}
Describe 'Add-PersonaConditionNode (FR-028)' {
It 'appends a leaf condition to the root group' {
$match = New-TestMatchGroup
$newLeaf = [pscustomobject]@{ type = 'property'; property = 'CompanyName'; operator = 'isNotNull' }
Add-PersonaConditionNode -Group $match -ParentPath @() -Node $newLeaf | Out-Null
@($match.conditions).Count | Should -Be 3
$match.conditions[2].property | Should -Be 'CompanyName'
}
It 'appends into a nested group by path' {
$match = New-TestMatchGroup
$newLeaf = [pscustomobject]@{ type = 'property'; property = 'JobTitle'; operator = 'isNotNull' }
Add-PersonaConditionNode -Group $match -ParentPath @(1) -Node $newLeaf | Out-Null
$nested = Get-PersonaConditionNode -Group $match -Path @(1)
@($nested.conditions).Count | Should -Be 2
}
It 'appends a whole nested group as the new node (renesting)' {
$match = New-TestMatchGroup
$newGroup = [pscustomobject]@{
operator = 'any'
conditions = @([pscustomobject]@{ type = 'property'; property = 'JobTitle'; operator = 'isNotNull' })
}
Add-PersonaConditionNode -Group $match -ParentPath @() -Node $newGroup | Out-Null
$added = Get-PersonaConditionNode -Group $match -Path @(2)
$added.operator | Should -Be 'any'
}
It 'throws when the target path is a leaf, not a group' {
$match = New-TestMatchGroup
$newLeaf = [pscustomobject]@{ type = 'property'; property = 'X'; operator = 'isNotNull' }
{ Add-PersonaConditionNode -Group $match -ParentPath @(0) -Node $newLeaf } | Should -Throw '*leaf*'
}
}
Describe 'Remove-PersonaConditionNode (FR-028)' {
It 'removes a top-level leaf' {
$match = New-TestMatchGroup
Remove-PersonaConditionNode -Group $match -Path @(0) | Out-Null
@($match.conditions).Count | Should -Be 1
$match.conditions[0].operator | Should -Be 'any'
}
It 'removes a leaf nested inside a group' {
$match = New-TestMatchGroup
Add-PersonaConditionNode -Group $match -ParentPath @(1) `
-Node ([pscustomobject]@{ type = 'property'; property = 'JobTitle'; operator = 'isNotNull' }) | Out-Null
Remove-PersonaConditionNode -Group $match -Path @(1, 1) | Out-Null
$nested = Get-PersonaConditionNode -Group $match -Path @(1)
@($nested.conditions).Count | Should -Be 1
$nested.conditions[0].property | Should -Be 'UserType'
}
It 'removes a whole nested group in one call' {
$match = New-TestMatchGroup
Remove-PersonaConditionNode -Group $match -Path @(1) | Out-Null
@($match.conditions).Count | Should -Be 1
}
It 'throws rather than removing the root group' {
$match = New-TestMatchGroup
{ Remove-PersonaConditionNode -Group $match -Path @() } | Should -Throw '*root*'
}
It 'throws rather than emptying a group down to zero conditions' {
$match = New-TestMatchGroup
{ Remove-PersonaConditionNode -Group $match -Path @(1, 0) } | Should -Throw '*only condition*'
}
It 'removal by position is unambiguous between identical siblings' {
$match = [pscustomobject]@{
operator = 'any'
conditions = @(
[pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }
[pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }
)
}
Remove-PersonaConditionNode -Group $match -Path @(0) | Out-Null
@($match.conditions).Count | Should -Be 1
}
}
Describe 'Set-PersonaConditionLeaf (FR-028)' {
It 'mutates the node in place, visible through the original tree reference' {
$match = New-TestMatchGroup
$leaf = Get-PersonaConditionNode -Group $match -Path @(0)
Set-PersonaConditionLeaf -Node $leaf -Fields @{ type = 'property'; property = 'CompanyName'; operator = 'isNotNull' } | Out-Null
$match.conditions[0].property | Should -Be 'CompanyName'
}
It 'clears stale fields when switching operator families (equals -> isNull)' {
$match = New-TestMatchGroup
$leaf = Get-PersonaConditionNode -Group $match -Path @(1, 0)
$leaf.value | Should -Be 'Guest'
Set-PersonaConditionLeaf -Node $leaf -Fields @{ type = 'property'; property = 'UserType'; operator = 'isNull' } | Out-Null
$leaf.PSObject.Properties['value'] | Should -BeNullOrEmpty
}
It 'clears a stale value when switching to an in/values operator' {
$match = New-TestMatchGroup
$leaf = Get-PersonaConditionNode -Group $match -Path @(1, 0)
Set-PersonaConditionLeaf -Node $leaf -Fields @{
type = 'property'; property = 'UserType'; operator = 'in'; values = @('Guest', 'Member')
} | Out-Null
$leaf.PSObject.Properties['value'] | Should -BeNullOrEmpty
@($leaf.values) | Should -Be @('Guest', 'Member')
}
It 'throws when the target node is a group, not a leaf' {
$match = New-TestMatchGroup
$group = Get-PersonaConditionNode -Group $match -Path @(1)
{ Set-PersonaConditionLeaf -Node $group -Fields @{ type = 'property'; property = 'X'; operator = 'isNotNull' } } | Should -Throw '*group*'
}
It 'an edited leaf survives a full validation pass' {
$document = New-TestConfigurationDocument
$rule = $document.rules | Where-Object { $_.id -eq 'RULE-0900-EMPLOYEE' }
# Replaced with a fresh pscustomobject tree - the shape Get-PersonaConditionNode
# expects and the shape ConvertFrom-Json actually produces (see its doc comment).
$rule.match = [pscustomobject]@{
operator = 'all'
conditions = @([pscustomobject]@{ type = 'property'; property = 'Department'; operator = 'isNotNull' })
}
$leaf = Get-PersonaConditionNode -Group $rule.match -Path @(0)
Set-PersonaConditionLeaf -Node $leaf -Fields @{ type = 'property'; property = 'JobTitle'; operator = 'isNotNull' } | Out-Null
$path = Save-TestConfiguration -Document $document -Directory ([System.IO.Path]::GetTempPath())
try {
$result = Test-PersonaConfiguration -Path $path
$result.IsValid | Should -BeTrue
}
finally {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
}
}
@@ -0,0 +1,225 @@
#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
}
}