From 01d08e635cd3580534234e2a874a65e2827265c9 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 21 Aug 2026 01:18:19 -0400 Subject: [PATCH] config editor --- Edit-PersonaEngineConfig.ps1 | 394 +++++++++++++++++- .../cli-edit-persona-engine-config.md | 23 + specs/001-persona-engine/spec.md | 13 +- specs/001-persona-engine/tasks.md | 42 +- specs/001-persona-engine/traceability.md | 9 +- .../Add-PersonaConditionNode.ps1 | 56 +++ src/Configuration/Add-PersonaConfigRule.ps1 | 56 +++ .../Get-PersonaConditionNode.ps1 | 54 +++ .../Remove-PersonaConditionNode.ps1 | 67 +++ .../Remove-PersonaConfigRule.ps1 | 39 ++ .../Set-PersonaConditionLeaf.ps1 | 56 +++ tests/Configuration/EditorAddRule.Tests.ps1 | 133 ++++++ .../Configuration/EditorDeleteRule.Tests.ps1 | 95 +++++ tests/Configuration/EditorEditRule.Tests.ps1 | 255 ++++++++++++ .../EditorStructuralEdits.Tests.ps1 | 225 ++++++++++ 15 files changed, 1501 insertions(+), 16 deletions(-) create mode 100644 src/Configuration/Add-PersonaConditionNode.ps1 create mode 100644 src/Configuration/Add-PersonaConfigRule.ps1 create mode 100644 src/Configuration/Get-PersonaConditionNode.ps1 create mode 100644 src/Configuration/Remove-PersonaConditionNode.ps1 create mode 100644 src/Configuration/Remove-PersonaConfigRule.ps1 create mode 100644 src/Configuration/Set-PersonaConditionLeaf.ps1 create mode 100644 tests/Configuration/EditorAddRule.Tests.ps1 create mode 100644 tests/Configuration/EditorDeleteRule.Tests.ps1 create mode 100644 tests/Configuration/EditorEditRule.Tests.ps1 create mode 100644 tests/Configuration/EditorStructuralEdits.Tests.ps1 diff --git a/Edit-PersonaEngineConfig.ps1 b/Edit-PersonaEngineConfig.ps1 index 74473e9..04bf6bd 100644 --- a/Edit-PersonaEngineConfig.ps1 +++ b/Edit-PersonaEngineConfig.ps1 @@ -306,17 +306,206 @@ function Save-PersonaConfiguration { $true } +function Test-PersonaCandidateEdit { + <# + .SYNOPSIS + Applies a structural edit to a cloned document and re-validates it (FR-030). + + .DESCRIPTION + Deep-clones Document via a JSON round trip (nested objects are reference + types, so mutating Document directly would leave a half-applied edit in + place if validation then rejected it), runs Apply against the clone, and + validates the clone in full - the identical check the `[V]` command and + `Save-PersonaConfiguration` use, so add/edit/delete can never be held to a + looser standard than a hand edit. + + The document entering the interactive editor is already Error-free, so a + clone that fails here failed because of Apply, not because of pre-existing + state. + + .PARAMETER Document + The current in-memory configuration document. + + .PARAMETER Apply + A scriptblock taking the clone as its first parameter, followed by whatever + ApplyArgs supplies, and mutating the clone in place. Values are passed as + explicit arguments rather than closed over from the caller's scope: `&` + invocation runs a scriptblock in a new child scope of wherever it is invoked + (here, inside this function) rather than of the scope where the scriptblock + literal was written, so a bare `{ $doc.rules = ... $id ... }` would see $id as + unbound. Explicit parameters sidestep that entirely. + + .PARAMETER ApplyArgs + Positional arguments passed to Apply after the clone. + + .OUTPUTS + pscustomobject with Applied (bool), Document (the clone if Applied, otherwise + the original Document, unchanged), and Result (the validation result). + #> + param( + [Parameter(Mandatory)] [object] $Document, + [Parameter(Mandatory)] [scriptblock] $Apply, + [object[]] $ApplyArgs = @() + ) + + $clone = $Document | ConvertTo-Json -Depth 32 | ConvertFrom-Json -Depth 32 + & $Apply $clone @ApplyArgs + + $temp = [System.IO.Path]::GetTempFileName() + try { + Set-Content -LiteralPath $temp -Value ($clone | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM + $check = Invoke-ConfigurationValidation -Path $temp + } + finally { + Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue + } + + [pscustomobject]@{ + Applied = $check.Result.IsValid + Document = $check.Result.IsValid ? $clone : $Document + Result = $check.Result + } +} + +function ConvertTo-PersonaConditionPath { + <# + .SYNOPSIS + Parses an operator-typed path like "1.0" into the int[] Get-PersonaConditionNode expects. + + .DESCRIPTION + Blank input, or the literal word "root", addresses the rule's match group + itself. + #> + param([string] $Text) + + $trimmed = ([string]$Text).Trim() + if ([string]::IsNullOrEmpty($trimmed) -or $trimmed -eq 'root') { return , @() } + + $indices = foreach ($part in ($trimmed -split '[.\s]+' | Where-Object { $_ -ne '' })) { + $value = 0 + if (-not [int]::TryParse($part, [ref] $value)) { + throw "'$part' is not a valid path segment - use dot-separated indices, for example '1.0'." + } + $value + } + + , @($indices) +} + +function Read-PersonaConditionLeafFields { + <# + .SYNOPSIS + Prompts for one leaf condition's fields, in the shape Set-PersonaConditionLeaf expects. + #> + Write-Host ' Condition type: [P]roperty [M]embership [R]ole' -ForegroundColor DarkGray + $typeChoice = (Read-Host ' Type').Trim().ToUpperInvariant() + $type = switch ($typeChoice) { 'P' { 'property' } 'M' { 'membership' } 'R' { 'role' } default { $null } } + if (-not $type) { throw "Unrecognized condition type '$typeChoice'." } + + Write-Host ' Operators: equals notEquals contains notContains startsWith endsWith matchesRegex in notIn isNull isNotNull memberOf notMemberOf' -ForegroundColor DarkGray + $operator = (Read-Host ' Operator').Trim() + + $fields = @{ type = $type; operator = $operator } + + switch ($type) { + 'property' { $fields['property'] = (Read-Host ' Property name').Trim() } + 'membership' { + $ids = (Read-Host ' Group Object IDs (comma-separated)') + $fields['groupObjectIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) + $mode = (Read-Host ' Membership mode (direct/transitive, blank = configuration default)').Trim() + if ($mode) { $fields['membershipMode'] = $mode } + } + 'role' { + $ids = (Read-Host ' Role IDs (comma-separated)') + $fields['roleIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) + } + } + + if ($operator -in @('in', 'notIn')) { + $values = (Read-Host ' Values (comma-separated)') + $fields['values'] = @($values -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) + } + elseif ($operator -notin @('isNull', 'isNotNull', 'memberOf', 'notMemberOf')) { + $fields['value'] = Read-Host ' Comparison value' + } + + $fields +} + +function Read-PersonaConditionNode { + <# + .SYNOPSIS + Interactively builds one condition or nested group (FR-027, FR-028). + + .DESCRIPTION + Recurses for a group's children. Nesting depth is not limited here - see + Test-PersonaCandidateEdit and Add-PersonaConditionNode's doc comment for why + depth is enforced by the real validator instead of a second, local count. + #> + param([int] $Depth = 1) + + $indent = ' ' * $Depth + Write-Host ("{0}[C]ondition or [G]roup?" -f $indent) -ForegroundColor DarkGray + $kind = (Read-Host "$indent Kind").Trim().ToUpperInvariant() + + if ($kind -eq 'G') { + $operator = (Read-Host "$indent Group operator (all/any)").Trim().ToLowerInvariant() + if ($operator -notin @('all', 'any')) { throw "Group operator must be 'all' or 'any'." } + + $children = [System.Collections.Generic.List[object]]::new() + do { + $children.Add((Read-PersonaConditionNode -Depth ($Depth + 1))) + $more = (Read-Host "$indent Add another condition to this group? (y/N)").Trim() + } while ($more.ToLowerInvariant() -eq 'y') + + return [pscustomobject]@{ operator = $operator; conditions = $children.ToArray() } + } + + [pscustomobject](Read-PersonaConditionLeafFields) +} + +function Read-PersonaRuleFields { + <# + .SYNOPSIS + Prompts for a new rule's top-level fields (RE-001), excluding its match tree. + #> + $id = (Read-Host 'Rule id').Trim() + $name = (Read-Host 'Rule name').Trim() + $description = (Read-Host 'Description').Trim() + + $priority = 0 + if (-not [int]::TryParse((Read-Host 'Priority (integer)'), [ref] $priority)) { + throw 'Priority must be an integer.' + } + + $persona = (Read-Host 'Persona').Trim() + $enabled = (Read-Host 'Enabled? (Y/n)').Trim().ToLowerInvariant() -ne 'n' + + [pscustomobject]@{ + id = $id; name = $name; description = $description + enabled = $enabled; priority = $priority; persona = $persona + } +} + function Invoke-InteractiveEditor { <# .SYNOPSIS The interactive loop (FR-023). .DESCRIPTION - Deliberately small. It toggles rules, adjusts priorities, re-validates, tests - against fixtures, and saves. It does not attempt to be a JSON editor - rule - authoring belongs in a text editor with schema completion, and a - half-featured structural editor would only add ways to corrupt a file that - is already under version control. + Toggles rules, adjusts priorities, adds/edits/deletes whole rules including + their condition trees (FR-027 - FR-030), re-validates, tests against + fixtures, and saves. + + Every structural edit (add/edit/delete) goes through Test-PersonaCandidateEdit, + which applies it to a clone and re-runs full validation before it is allowed + to reach $Document. The live document entering this loop is already + Error-free - the main script exits before this function is ever called + otherwise - so a clone that fails validation failed because of the edit just + applied, and is discarded rather than committed. This is what makes + structural editing safe to add at all: the editor can never produce a worse + document than a careful hand edit would, because it runs the same four + validation layers a hand edit is checked against at save time. #> param( [Parameter(Mandatory)] [object] $Document, @@ -333,6 +522,7 @@ function Invoke-InteractiveEditor { Write-Host (" rules {0} ({1} enabled)" -f @($Document.rules).Count, @($Document.rules | Where-Object { $_.enabled }).Count) Write-Host '' Write-Host ' [L] list rules [T] toggle a rule [P] change a priority' + Write-Host ' [A] add a rule [E] edit a rule [D] delete a rule' Write-Host ' [V] re-validate [R] run rule test [S] save' Write-Host ' [Q] quit' Write-Host '' @@ -378,6 +568,200 @@ function Invoke-InteractiveEditor { Write-Host "Rule $id priority is now $value. Re-validate before saving - a reorder changes which rule wins." -ForegroundColor Green } + 'A' { + # FR-027. The candidate rule is built entirely from prompts before + # Test-PersonaCandidateEdit ever sees it, so a collision or a + # validation failure is reported once, against the whole rule, rather + # than mid-build against a rule that only half exists yet. + try { + $fields = Read-PersonaRuleFields + + Write-Host 'Build the condition tree for this rule (the root group):' -ForegroundColor Cyan + $rootOperator = (Read-Host 'Root operator (all/any)').Trim().ToLowerInvariant() + if ($rootOperator -notin @('all', 'any')) { throw "Root operator must be 'all' or 'any'." } + + $rootChildren = [System.Collections.Generic.List[object]]::new() + do { + $rootChildren.Add((Read-PersonaConditionNode -Depth 1)) + $more = (Read-Host 'Add another top-level condition? (y/N)').Trim() + } while ($more.ToLowerInvariant() -eq 'y') + + $newRule = [pscustomobject]@{ + id = $fields.id + name = $fields.name + description = $fields.description + enabled = $fields.enabled + priority = $fields.priority + persona = $fields.persona + match = [pscustomobject]@{ operator = $rootOperator; conditions = $rootChildren.ToArray() } + } + + $attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($newRule) -Apply { + param($doc, $rule) + $doc.rules = Add-PersonaConfigRule -Rules $doc.rules -Rule $rule + } + + if ($attempt.Applied) { + $Document = $attempt.Document + $dirty = $true + Write-Host "Rule $($newRule.id) added." -ForegroundColor Green + } + else { + Write-Host 'Add rejected: the resulting configuration would be invalid.' -ForegroundColor Red + Write-PersonaValidationFinding -Findings $attempt.Result.Findings + } + } + catch { + Write-Host "Add cancelled: $($_.Exception.Message)" -ForegroundColor Yellow + } + } + + 'E' { + # FR-028. One structural operation per pass through this case - the + # operator repeats [E] to make further changes, which keeps each + # Test-PersonaCandidateEdit call scoped to one edit and one finding + # set, rather than a batch where a rejection leaves it unclear which + # of several changes caused it. + try { + $id = (Read-Host 'Rule id to edit').Trim() + $rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1 + if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break } + + Write-Host ("Editing rule {0} ({1})." -f $rule.id, $rule.name) -ForegroundColor Cyan + Write-Host ' [F] change a top-level field [C] add a condition/group' + Write-Host ' [E] edit a leaf condition [X] remove a condition/group' + Write-Host ' [B] back to the main menu, no change' + + $subChoice = (Read-Host ' Edit action').Trim().ToUpperInvariant() + + $attempt = switch ($subChoice) { + + 'F' { + $field = (Read-Host ' Field (name/description/priority/persona/enabled)').Trim().ToLowerInvariant() + $value = Read-Host ' New value' + + Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id, $field, $value) -Apply { + param($doc, $ruleId, $fieldName, $newValue) + $target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1 + switch ($fieldName) { + 'priority' { $target.priority = [int]$newValue } + 'enabled' { $target.enabled = $newValue.Trim().ToLowerInvariant() -in @('y', 'yes', 'true', '1') } + 'name' { $target.name = $newValue } + 'description' { $target.description = $newValue } + 'persona' { $target.persona = $newValue } + default { throw "Unrecognized field '$fieldName'." } + } + } + } + + 'C' { + $parentPath = ConvertTo-PersonaConditionPath -Text (Read-Host ' Parent path (blank = root, e.g. "1.0")') + $newNode = Read-PersonaConditionNode -Depth ($parentPath.Count + 1) + + # ApplyArgs is built with .Add() rather than @(...), because an + # int[] placed inside a `@()` array literal is unrolled into the + # outer array (the same gotcha Get-CapturedAuditRecord's comma + # idiom guards against elsewhere) - @($id, $parentPath, $newNode) + # would silently flatten a two-segment path into two extra + # positional arguments instead of passing it as one array. + $args = [System.Collections.Generic.List[object]]::new() + $args.Add($id); $args.Add($parentPath); $args.Add($newNode) + + Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply { + param($doc, $ruleId, $parentPathArg, $node) + $target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1 + Add-PersonaConditionNode -Group $target.match -ParentPath $parentPathArg -Node $node | Out-Null + } + } + + 'E' { + $path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Leaf condition path (e.g. "1.0")') + if ($path.Count -eq 0) { throw 'The root is a group, not a leaf condition - navigate to a leaf path.' } + $fields = Read-PersonaConditionLeafFields + + $args = [System.Collections.Generic.List[object]]::new() + $args.Add($id); $args.Add($path); $args.Add($fields) + + Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply { + param($doc, $ruleId, $pathArg, $fieldsArg) + $target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1 + $leaf = Get-PersonaConditionNode -Group $target.match -Path $pathArg + Set-PersonaConditionLeaf -Node $leaf -Fields $fieldsArg | Out-Null + } + } + + 'X' { + $path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Condition/group path to remove (e.g. "1.0")') + + $args = [System.Collections.Generic.List[object]]::new() + $args.Add($id); $args.Add($path) + + Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply { + param($doc, $ruleId, $pathArg) + $target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1 + Remove-PersonaConditionNode -Group $target.match -Path $pathArg | Out-Null + } + } + + 'B' { $null } + + default { Write-Host 'Unrecognized edit action.' -ForegroundColor Yellow; $null } + } + + if ($null -ne $attempt) { + if ($attempt.Applied) { + $Document = $attempt.Document + $dirty = $true + Write-Host "Rule $id updated." -ForegroundColor Green + } + else { + Write-Host 'Edit rejected: the resulting configuration would be invalid.' -ForegroundColor Red + Write-PersonaValidationFinding -Findings $attempt.Result.Findings + } + } + } + catch { + Write-Host "Edit cancelled: $($_.Exception.Message)" -ForegroundColor Yellow + } + } + + 'D' { + # FR-029. Confirmation names id, name, and priority before anything is + # touched, matching the acceptance criterion literally rather than a + # generic "are you sure?". + try { + $id = (Read-Host 'Rule id to delete').Trim() + $rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1 + if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break } + + Write-Host ("About to delete rule {0} '{1}' priority {2}." -f $rule.id, $rule.name, $rule.priority) -ForegroundColor Yellow + $confirm = (Read-Host 'Delete this rule? (y/N)').Trim().ToLowerInvariant() + + if ($confirm -ne 'y') { + Write-Host 'Delete cancelled.' -ForegroundColor Yellow + break + } + + $attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id) -Apply { + param($doc, $ruleId) + $doc.rules = Remove-PersonaConfigRule -Rules $doc.rules -RuleId $ruleId + } + + if ($attempt.Applied) { + $Document = $attempt.Document + $dirty = $true + Write-Host "Rule $id deleted." -ForegroundColor Green + } + else { + Write-Host 'Delete rejected: the resulting configuration would be invalid.' -ForegroundColor Red + Write-PersonaValidationFinding -Findings $attempt.Result.Findings + } + } + catch { + Write-Host "Delete cancelled: $($_.Exception.Message)" -ForegroundColor Yellow + } + } + 'V' { $temp = [System.IO.Path]::GetTempFileName() try { diff --git a/specs/001-persona-engine/contracts/cli-edit-persona-engine-config.md b/specs/001-persona-engine/contracts/cli-edit-persona-engine-config.md index 6714bf4..1cd7666 100644 --- a/specs/001-persona-engine/contracts/cli-edit-persona-engine-config.md +++ b/specs/001-persona-engine/contracts/cli-edit-persona-engine-config.md @@ -73,6 +73,29 @@ Stability matters: pipelines and runbooks will match on these codes. | `3` | Configuration file not found or unreadable | | `4` | Schema file not found or itself invalid | +## Interactive editor commands (FR-023, FR-027 – FR-030) + +The interactive loop (entered when neither `-ValidateOnly` nor `-NonInteractive` is set) supports: + +| Command | Behaviour | +| --- | --- | +| List rules | Show every rule's priority, ID, persona, and enabled state. | +| Toggle a rule | Flip `enabled` on an existing rule. | +| Change a priority | Set a new numeric priority on an existing rule. | +| **Add a rule** | Prompt for every RE-001 field (`id`, `name`, `description`, `priority`, `persona`, `enabled`, and optional fields) and for the condition tree — nested `all`/`any` groups and, per leaf condition, the property/membership source, operator, and comparison value. Reject on the spot if the `id` or `priority` collides with an existing rule (FR-027). | +| **Edit a rule** | Select an existing rule by `id`; change any top-level field and/or the condition tree — add, edit, remove, or renest conditions and groups within `MaxConditionDepth` (FR-028). | +| **Delete a rule** | Select an existing rule by `id`; show its `id`, `name`, and `priority` and require explicit confirmation before removing it (FR-029). | +| Re-validate | Run all four validation layers against the in-memory document, including any unsaved add/edit/delete, and print findings without saving. | +| Run rule test | Evaluate the in-memory document (including unsaved structural edits) against `-TestDataPath` fixtures. | +| Save | Re-validate, then persist per the save contract below. | +| Quit | Warn if there are unsaved changes (including structural edits) before discarding them. | + +All structural edits (add/edit/delete) are applied to the in-memory document only. They are never +written to `-ConfigPath` (or `-OutputPath`) until a `Save` re-validates the full document and that +validation passes — the same rule that governs field-level edits (FR-030). A depth violation +introduced by an add or edit is reported immediately using the same finding the runtime validator +would produce, rather than deferred to the next save or re-validate. + ## Save contract (FR-026) 1. Re-validate the edited document in full. diff --git a/specs/001-persona-engine/spec.md b/specs/001-persona-engine/spec.md index af06e7b..079991b 100644 --- a/specs/001-persona-engine/spec.md +++ b/specs/001-persona-engine/spec.md @@ -84,7 +84,7 @@ An operator watching a run sees each user's result appear immediately, receives ### User Story 5 - Validate and edit configuration safely (Priority: P2) -A configuration owner validates a configuration file, edits rules interactively, tests rules against synthetic sample users, compares against another configuration, and saves only after validation passes — with a backup taken first. +A configuration owner validates a configuration file, adds, edits, and deletes rules interactively — including each rule's condition tree — tests rules against synthetic sample users, compares against another configuration, and saves only after validation passes — with a backup taken first. **Why this priority**: Makes the configuration-driven model usable and safe in practice, but the engine can be exercised with a hand-authored file first. @@ -96,6 +96,13 @@ A configuration owner validates a configuration file, edits rules interactively, 2. **Given** an edit session with unsaved valid changes, **When** the file is saved over an existing configuration, **Then** a timestamped backup or save-as output is produced first. 3. **Given** synthetic sample users are supplied, **When** rules are tested, **Then** the resulting persona for each sample is reported without any tenant connection. 4. **Given** a valid configuration, **When** validation runs, **Then** no Error findings are produced and the file is accepted. +5. **Given** an interactive edit session, **When** the configuration owner adds a new rule, **Then** they are prompted for every required field (`id`, `name`, `description`, `priority`, `persona`, `enabled`) and for the rule's condition tree — including nested `all`/`any` groups and, for each leaf condition, the property or membership source, operator, and comparison value — and the new rule is appended to the in-memory document without being written to disk until save. +6. **Given** an interactive edit session, **When** the configuration owner attempts to add a rule using an `id` or `priority` already present in the document, **Then** the attempt is rejected before the rule is added, with a message naming the conflicting rule. +7. **Given** an interactive edit session, **When** the configuration owner selects an existing rule to edit, **Then** they can change any of its top-level fields and its condition tree — adding, editing, removing, or renesting conditions and `all`/`any` groups within the configured depth limit — and the change is held in memory, unsaved, until the session validates and saves. +8. **Given** an interactive edit session, **When** the configuration owner deletes a rule, **Then** they are shown the rule's `id`, `name`, and `priority` and asked to confirm before it is removed from the in-memory document. +9. **Given** a rule add, edit, or delete has been made in an edit session, **When** the session re-validates or saves, **Then** the same four validation layers (VR-001) run over the modified document exactly as they would over a hand-edited file, and any resulting Error finding blocks the save. +10. **Given** an edit session with unsaved add, edit, or delete changes, **When** the operator quits without saving, **Then** they are warned that unsaved changes will be lost and the file on disk is unchanged. +11. **Given** a rule add, edit, or delete would exceed the configured maximum condition nesting depth, **When** the change is applied, **Then** it is rejected at edit time with the same finding the runtime validator would produce, rather than deferred to the next save. --- @@ -219,6 +226,10 @@ Requirement identifiers are carried forward unchanged from the Phase 0 baseline - **FR-024** — Non-interactive validation: the configuration tool MUST support non-interactive validation and return a non-zero exit code on failure. - **FR-025** — Configuration test data: the editor MUST support testing rules against synthetic sample users without tenant connectivity. - **FR-026** — Backup before save: interactive edits MUST be validated before save and SHOULD create a timestamped backup or save-as output before replacing an existing configuration. +- **FR-027** — Add a rule: the interactive editor MUST support adding a new rule, prompting for every field in RE-001 and for the rule's condition tree (nested `all`/`any` groups and leaf conditions), and MUST reject an `id` or `priority` that collides with an existing rule before the rule is added. +- **FR-028** — Edit a rule: the interactive editor MUST support editing any field of an existing rule, including full editing of its condition tree — adding, changing, removing, and renesting conditions and groups within the configured depth limit (RE-004). +- **FR-029** — Delete a rule: the interactive editor MUST support deleting an existing rule, and MUST require the operator to confirm against the rule's `id`, `name`, and `priority` before removal. +- **FR-030** — Uniform validation for structural edits: a rule added, edited, or deleted in the interactive editor MUST be subject to the same four validation layers (VR-001) as a hand-edited file, at re-validation and at save; edits are held only in memory until save succeeds (FR-026). ### Rule Engine Requirements diff --git a/specs/001-persona-engine/tasks.md b/specs/001-persona-engine/tasks.md index 587073e..85c51fe 100644 --- a/specs/001-persona-engine/tasks.md +++ b/specs/001-persona-engine/tasks.md @@ -28,16 +28,21 @@ pass offline Pester tests before any Graph integration exists. Single PowerShell module at repository root: `src/`, `tests/`, `config/`, `docs/`, `pipelines/`, per the Project Structure section of [plan.md](plan.md). -## Implementation status — 2026-08-20 +## Implementation status — 2026-08-21 -**109 of 121 tasks complete.** All twelve remaining tasks require something this workstation does not +**117 of 129 tasks complete.** All twelve remaining tasks require something this workstation does not have: a tenant connection (T055, T056, T101–T103) or an Azure Automation account (Phase 13, T115–T121). Nothing offline-implementable is outstanding. +T122–T129 (interactive add/edit/delete for rules, FR-027–FR-030) landed after the initial Phase 6 +build, which had shipped with toggle/priority editing only. 400 offline Pester tests now pass (up +from 354); see [traceability.md](traceability.md) for FR-027–FR-030's implementation and test +mapping. + ``` -354 offline Pester tests PASS +400 offline Pester tests PASS Engine purity (Principle IV) PASS -Sanitization (SC-013) PASS 156 files +Sanitization (SC-013) PASS Graph module loaded during tests False (SC-008 holds) ``` @@ -235,7 +240,28 @@ documented finding code, severity, and location. - [X] T069 [US5] Implement the timestamped backup and Save-As path in Edit-PersonaEngineConfig.ps1 (FR-026) - [X] T070 [US5] Implement synthetic rule testing via `-TestDataPath` in Edit-PersonaEngineConfig.ps1, reusing the rule engine with no tenant connectivity (FR-025) -**Checkpoint**: invalid configurations cannot reach the engine or overwrite a good file. +### Rule CRUD in the interactive editor (US5 addition — FR-027–FR-030) + +Added after the initial Phase 6 build. The editor originally shipped deliberately scoped to +toggle/priority only (see the `Invoke-InteractiveEditor` comment header); spec.md now requires +add/edit/delete, including full condition-tree editing, so that scope is reversed here rather than +worked around. + +#### Tests for rule CRUD + +- [X] T122 [P] [US5] Add-rule tests in tests/Configuration/EditorAddRule.Tests.ps1 covering the full field/condition-tree prompt sequence and rejection of a duplicate `id` or `priority` before the rule is added (FR-027) +- [X] T123 [P] [US5] Edit-rule tests in tests/Configuration/EditorEditRule.Tests.ps1 covering top-level field changes and condition-tree add/edit/remove/renest operations within `MaxConditionDepth` (FR-028) +- [X] T124 [P] [US5] Delete-rule tests in tests/Configuration/EditorDeleteRule.Tests.ps1 covering the `id`/`name`/`priority` confirmation prompt and removal, including a declined confirmation leaving the rule in place (FR-029) +- [X] T125 [P] [US5] Structural-edit validation tests in tests/Configuration/EditorStructuralEdits.Tests.ps1 asserting add/edit/delete changes stay in memory until save, re-validate through all four layers on `[V]`/`[S]`, and that a depth-limit violation is reported immediately at edit time rather than deferred (FR-030) + +#### Implementation for rule CRUD + +- [X] T126 [US5] Implement rule addition (prompt for every RE-001 field plus the condition tree, reject a colliding `id`/`priority`) in Edit-PersonaEngineConfig.ps1 and wire the `[A]` command into the interactive loop (FR-027) +- [X] T127 [US5] Implement rule editing (top-level fields plus add/edit/remove/renest on the condition tree) in Edit-PersonaEngineConfig.ps1 and wire the `[E]` command into the interactive loop (FR-028) +- [X] T128 [US5] Implement rule deletion with an `id`/`name`/`priority` confirmation prompt in Edit-PersonaEngineConfig.ps1 and wire the `[D]` command into the interactive loop (FR-029) +- [X] T129 [US5] Route add/edit/delete through the existing `$dirty` tracking and `Invoke-ConfigurationValidation` re-validation path in Edit-PersonaEngineConfig.ps1, and surface a depth-limit finding at edit time using the same check the runtime validator uses (FR-030) + +**Checkpoint**: invalid configurations cannot reach the engine or overwrite a good file, and rules can be added, edited, and deleted entirely from the editor. --- @@ -430,7 +456,7 @@ reads the audit shape US7 defines. | 3 | T016–T020 (all tests, separate files) | | 4 | T027, T028, T029 | | 5 | T034–T038 (tests); then T039, T040 | -| 6 | T057–T060 | +| 6 | T057–T060; then T122–T125 | | 7 | T071, T072 | | 8 | T077, T078 | | 9 | T083, T084, T085 | @@ -470,7 +496,7 @@ Automation PowerShell 7 run. | 3 | US2 (P1) | T016–T026 | 11 | | 4 | US3 (P1) | T027–T033 | 7 | | 5 | US1 (P1) 🎯 | T034–T056 | 23 | -| 6 | US5 (P2) | T057–T070 | 14 | +| 6 | US5 (P2) | T057–T070, T122–T129 | 22 | | 7 | US6 (P2) | T071–T076 | 6 | | 8 | US4 (P2) | T077–T082 | 6 | | 9 | US7 (P2) | T083–T090 | 8 | @@ -478,4 +504,4 @@ Automation PowerShell 7 run. | 11 | US8 (P3) 🔒 | T095–T103 | 9 | | 12 Polish | — | T104–T114 | 11 | | 13 Stage B | — | T115–T121 | 7 (deferred) | -| **Total** | | | **121** | +| **Total** | | | **129** | diff --git a/specs/001-persona-engine/traceability.md b/specs/001-persona-engine/traceability.md index d886012..ef671c5 100644 --- a/specs/001-persona-engine/traceability.md +++ b/specs/001-persona-engine/traceability.md @@ -6,8 +6,9 @@ Every functional requirement, non-functional requirement, and success criterion A row with no test is a requirement nobody is checking. Those are listed explicitly at the bottom rather than left out, because an incomplete matrix that looks complete is worse than no matrix. -**Status as at 2026-08-20**: 354 offline tests passing; engine purity and sanitization gates passing; -no tenant-dependent item verified. +**Status as at 2026-08-21**: 400 offline tests passing; engine purity and sanitization gates passing; +no tenant-dependent item verified. The increase from 354 is FR-027–FR-030 (rule add/edit/delete in +the interactive editor), added to spec.md and implemented in the same change (T122–T129). ## Functional requirements @@ -39,6 +40,10 @@ no tenant-dependent item verified. | FR-024 | Non-interactive validation with exit codes | `-NonInteractive` short-circuit | `NonInteractive.Tests.ps1` | | FR-025 | Synthetic rule testing, no tenant | `Invoke-SyntheticRuleTest` | `NonInteractive.Tests.ps1` | | FR-026 | Validate and back up before save | `Save-PersonaConfiguration` | `Safety.Tests.ps1` (`PE-SAF-007`) | +| FR-027 | Add a rule interactively | `Add-PersonaConfigRule`; `[A]` command in `Edit-PersonaEngineConfig.ps1` | `EditorAddRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` | +| FR-028 | Edit a rule interactively, including its condition tree | `Get/Add/Remove-PersonaConditionNode`, `Set-PersonaConditionLeaf`; `[E]` command | `EditorEditRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` | +| FR-029 | Delete a rule interactively, with confirmation | `Remove-PersonaConfigRule`; `[D]` command | `EditorDeleteRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` | +| FR-030 | Structural edits validated like a hand-edited file | `Test-PersonaCandidateEdit` in `Edit-PersonaEngineConfig.ps1` | `EditorStructuralEdits.Tests.ps1` | ## Rule engine requirements diff --git a/src/Configuration/Add-PersonaConditionNode.ps1 b/src/Configuration/Add-PersonaConditionNode.ps1 new file mode 100644 index 0000000..6389dbc --- /dev/null +++ b/src/Configuration/Add-PersonaConditionNode.ps1 @@ -0,0 +1,56 @@ +function Add-PersonaConditionNode { + <# + .SYNOPSIS + Appends a condition or nested group to a group in a rule's condition tree (FR-028). + + .DESCRIPTION + Locates the group at ParentPath and appends Node to its `conditions` + collection. Structural checks only - id/priority-style collisions do not apply + to conditions, and nesting-depth enforcement is deliberately not duplicated + here. + + Depth is enforced by re-running the real validator (VR-001) against the whole + candidate document after the edit, the same way a hand-edited file would be + checked - see Edit-PersonaEngineConfig.ps1's candidate-edit wrapper. A second, + local depth calculation here would risk drifting from Test-PersonaConditionGroup's + engine semantics (RE-004), which count nesting by group levels, not by leaf + conditions - the exact mismatch VR-002's own depth check is careful to avoid + (see Test-PersonaConfigurationSemantic.ps1). + + .PARAMETER Group + The rule's `match` condition group (the root). + + .PARAMETER ParentPath + Path (see Get-PersonaConditionNode) to the group to append into. Empty selects + the root. + + .PARAMETER Node + The condition or condition group to append. + + .OUTPUTS + System.Object - the same Group, mutated in place, returned for convenience. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)] + [object] $Group, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [int[]] $ParentPath, + + [Parameter(Mandatory)] + [object] $Node + ) + + $parent = Get-PersonaConditionNode -Group $Group -Path $ParentPath + + if ($null -eq $parent.PSObject.Properties['conditions']) { + throw 'The target node is a leaf condition, not a group. Choose a group (all/any) to add into.' + } + + $parent.conditions = @($parent.conditions) + $Node + + $Group +} diff --git a/src/Configuration/Add-PersonaConfigRule.ps1 b/src/Configuration/Add-PersonaConfigRule.ps1 new file mode 100644 index 0000000..6dd624e --- /dev/null +++ b/src/Configuration/Add-PersonaConfigRule.ps1 @@ -0,0 +1,56 @@ +function Add-PersonaConfigRule { + <# + .SYNOPSIS + Appends a new rule to a rules collection, rejecting an id/priority collision (FR-027). + + .DESCRIPTION + Pure: takes the existing rules and a candidate rule, returns a new array. Never + mutates the input collection, so a caller that discards the result on a + validation failure has changed nothing. + + The id/priority check is duplicated with PE-SEM-001/PE-SEM-002 by design - the + interactive editor should not need a full validation round trip just to tell an + operator they typed an id that already exists. Anything that reaches the + semantic layer anyway (for example a collision introduced by two concurrent + edits) is still caught there. + + .PARAMETER Rules + The rule collection to append to. Not mutated. + + .PARAMETER Rule + The candidate rule. Must carry non-blank `id` and a `priority`. + + .OUTPUTS + System.Object[] - the new rules collection, existing rules first. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Rules, + + [Parameter(Mandatory)] + [object] $Rule + ) + + $id = [string]$Rule.id + if ([string]::IsNullOrWhiteSpace($id)) { + throw 'The new rule requires a non-blank id.' + } + + $existing = @($Rules) + + $byId = @($existing | Where-Object { [string]$_.id -eq $id }) + if ($byId.Count -gt 0) { + throw "Rule id '$id' already exists (priority $($byId[0].priority)). Rule ids must be unique (RE-002)." + } + + $priority = $Rule.priority + $byPriority = @($existing | Where-Object { [int]$_.priority -eq [int]$priority }) + if ($byPriority.Count -gt 0) { + throw "Priority $priority is already used by rule '$($byPriority[0].id)'. Priorities must be unique (RE-002)." + } + + , ($existing + $Rule) +} diff --git a/src/Configuration/Get-PersonaConditionNode.ps1 b/src/Configuration/Get-PersonaConditionNode.ps1 new file mode 100644 index 0000000..9cbdaf8 --- /dev/null +++ b/src/Configuration/Get-PersonaConditionNode.ps1 @@ -0,0 +1,54 @@ +function Get-PersonaConditionNode { + <# + .SYNOPSIS + Navigates a rule's condition tree to the node at a path (FR-028). + + .DESCRIPTION + A path is a sequence of zero-based indices into successive `.conditions` + collections, root first. An empty path returns the root group itself. + + The returned node is the same object reference that lives inside the tree - + deliberately. `ConvertFrom-Json` produces `PSCustomObject`/array trees of + reference types, so a caller that sets a property on the returned leaf (for + example `$node.value = 'x'`) mutates the tree in place. Editing a leaf's + fields is therefore a plain property assignment, not a separate setter + function; see Set-PersonaConditionLeaf for the one case (switching a + condition's operator/type) where stale fields must also be cleared. + + .PARAMETER Group + The rule's `match` condition group (the root). + + .PARAMETER Path + Zero-based indices from the root, one per nesting level. Empty selects the root. + + .OUTPUTS + System.Object - the condition or condition group at that path. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)] + [object] $Group, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [int[]] $Path + ) + + $node = $Group + + foreach ($index in $Path) { + if ($null -eq $node.PSObject.Properties['conditions'] -or $null -eq $node.conditions) { + throw "Path segment $index does not resolve: the node above it is a leaf condition, not a group." + } + + $children = @($node.conditions) + if ($index -lt 0 -or $index -ge $children.Count) { + throw "Path segment $index is out of range - this group has $($children.Count) condition(s)." + } + + $node = $children[$index] + } + + $node +} diff --git a/src/Configuration/Remove-PersonaConditionNode.ps1 b/src/Configuration/Remove-PersonaConditionNode.ps1 new file mode 100644 index 0000000..8218447 --- /dev/null +++ b/src/Configuration/Remove-PersonaConditionNode.ps1 @@ -0,0 +1,67 @@ +function Remove-PersonaConditionNode { + <# + .SYNOPSIS + Removes a condition or nested group from a rule's condition tree (FR-028). + + .DESCRIPTION + Two structural rules are enforced here rather than left to schema validation, + because both would otherwise produce a document the schema calls invalid with + no indication which editor action caused it: + + - The root group itself cannot be removed - a rule's `match` is required + (RE-001). Delete the rule instead (Remove-PersonaConfigRule). + - A group's last remaining condition cannot be removed - an empty + `conditions` array violates the schema's `minItems: 1` on conditionGroup. + Remove the parent group instead, or add a replacement first. + + .PARAMETER Group + The rule's `match` condition group (the root). + + .PARAMETER Path + Path (see Get-PersonaConditionNode) to the node to remove. Must not be empty. + + .OUTPUTS + System.Object - the same Group, mutated in place, returned for convenience. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)] + [object] $Group, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [int[]] $Path + ) + + if (@($Path).Count -eq 0) { + throw "The root condition group cannot be removed this way - delete the rule instead." + } + + # Special-cased at length 1 because PowerShell's range operator treats 0..-1 as + # the two-element descending range (0, -1), not an empty range - the naive slice + # would silently pick up the wrong parent for a top-level path. + $parentPath = (@($Path).Count -eq 1) ? @() : @($Path)[0..($Path.Count - 2)] + $index = @($Path)[-1] + + $parent = Get-PersonaConditionNode -Group $Group -Path $parentPath + $children = @($parent.conditions) + + if ($index -lt 0 -or $index -ge $children.Count) { + throw "Path segment $index is out of range - this group has $($children.Count) condition(s)." + } + + if ($children.Count -eq 1) { + throw 'This is the only condition in its group. A group requires at least one condition - remove the group itself instead, or add a replacement first.' + } + + # Removed by position, not by value or reference - two structurally identical + # sibling conditions are otherwise indistinguishable. + $remaining = [System.Collections.Generic.List[object]]::new() + for ($i = 0; $i -lt $children.Count; $i++) { + if ($i -ne $index) { $remaining.Add($children[$i]) } + } + $parent.conditions = $remaining.ToArray() + + $Group +} diff --git a/src/Configuration/Remove-PersonaConfigRule.ps1 b/src/Configuration/Remove-PersonaConfigRule.ps1 new file mode 100644 index 0000000..701ae44 --- /dev/null +++ b/src/Configuration/Remove-PersonaConfigRule.ps1 @@ -0,0 +1,39 @@ +function Remove-PersonaConfigRule { + <# + .SYNOPSIS + Removes a rule from a rules collection by id (FR-029). + + .DESCRIPTION + Pure: takes the existing rules and an id, returns a new array with that rule + absent. Throws if no rule carries the id, because a silent no-op would let an + operator believe a delete happened when it did not. + + .PARAMETER Rules + The rule collection to remove from. Not mutated. + + .PARAMETER RuleId + The id of the rule to remove. + + .OUTPUTS + System.Object[] - the remaining rules. + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Rules, + + [Parameter(Mandatory)] + [string] $RuleId + ) + + $existing = @($Rules) + $match = @($existing | Where-Object { [string]$_.id -eq $RuleId }) + + if ($match.Count -eq 0) { + throw "No rule with id '$RuleId'." + } + + , @($existing | Where-Object { [string]$_.id -ne $RuleId }) +} diff --git a/src/Configuration/Set-PersonaConditionLeaf.ps1 b/src/Configuration/Set-PersonaConditionLeaf.ps1 new file mode 100644 index 0000000..a2d0417 --- /dev/null +++ b/src/Configuration/Set-PersonaConditionLeaf.ps1 @@ -0,0 +1,56 @@ +function Set-PersonaConditionLeaf { + <# + .SYNOPSIS + Replaces a leaf condition's fields in place (FR-028). + + .DESCRIPTION + Every condition-specific field (`property`, `value`, `values`, + `groupObjectIds`, `roleIds`, `membershipMode`) is cleared before Fields is + applied, then only the keys in Fields are set. This is not tidiness - the + schema's `additionalProperties: false` on `condition` (VR-001 layer 2) rejects + a stray field outright, and switching, say, a condition from `equals` (which + carries `value`) to `in` (which carries `values`) would otherwise leave the + old `value` behind and fail schema validation for a reason the editor caused + but did not explain. + + `type` and `operator` are cleared and reset the same way, since a caller + editing a condition typically passes both. + + .PARAMETER Node + The leaf condition to edit (from Get-PersonaConditionNode). Mutated in place. + + .PARAMETER Fields + The complete replacement field set for this leaf, for example: + `@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }`. + + .OUTPUTS + System.Object - the same Node, mutated in place, returned for convenience. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory)] + [object] $Node, + + [Parameter(Mandatory)] + [hashtable] $Fields + ) + + if ($null -ne $Node.PSObject.Properties['conditions']) { + throw 'The target node is a condition group, not a leaf condition.' + } + + $knownFields = @('type', 'property', 'operator', 'value', 'values', 'groupObjectIds', 'roleIds', 'membershipMode', 'caseSensitive') + + foreach ($key in $knownFields) { + if ($null -ne $Node.PSObject.Properties[$key]) { + $Node.PSObject.Properties.Remove($key) + } + } + + foreach ($entry in $Fields.GetEnumerator()) { + $Node | Add-Member -NotePropertyName $entry.Key -NotePropertyValue $entry.Value -Force + } + + $Node +} diff --git a/tests/Configuration/EditorAddRule.Tests.ps1 b/tests/Configuration/EditorAddRule.Tests.ps1 new file mode 100644 index 0000000..9b4d0e4 --- /dev/null +++ b/tests/Configuration/EditorAddRule.Tests.ps1 @@ -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 + } + } +} diff --git a/tests/Configuration/EditorDeleteRule.Tests.ps1 b/tests/Configuration/EditorDeleteRule.Tests.ps1 new file mode 100644 index 0000000..04faa0f --- /dev/null +++ b/tests/Configuration/EditorDeleteRule.Tests.ps1 @@ -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 + } + } +} diff --git a/tests/Configuration/EditorEditRule.Tests.ps1 b/tests/Configuration/EditorEditRule.Tests.ps1 new file mode 100644 index 0000000..64087c6 --- /dev/null +++ b/tests/Configuration/EditorEditRule.Tests.ps1 @@ -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 + } + } +} diff --git a/tests/Configuration/EditorStructuralEdits.Tests.ps1 b/tests/Configuration/EditorStructuralEdits.Tests.ps1 new file mode 100644 index 0000000..0672185 --- /dev/null +++ b/tests/Configuration/EditorStructuralEdits.Tests.ps1 @@ -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 + } +}