#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 } } }