256 lines
9.5 KiB
PowerShell
256 lines
9.5 KiB
PowerShell
|
|
#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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|