diff --git a/docs/reference/cmdlets/Invoke-IdlePlan.md b/docs/reference/cmdlets/Invoke-IdlePlan.md index 71da2505..2f002d73 100644 --- a/docs/reference/cmdlets/Invoke-IdlePlan.md +++ b/docs/reference/cmdlets/Invoke-IdlePlan.md @@ -13,7 +13,7 @@ Executes an IdLE plan. ## SYNTAX ``` -Invoke-IdlePlan [-Plan] [[-Providers] ] [[-EventSink] ] +Invoke-IdlePlan [-Plan] [[-Providers] ] [[-EventSink] ] [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` @@ -41,7 +41,7 @@ Aliases: Required: True Position: 1 Default value: None -Accept pipeline input: False +Accept pipeline input: True (ByValue) Accept wildcard characters: False ``` @@ -49,7 +49,7 @@ Accept wildcard characters: False Provider registry/collection passed through to execution. ```yaml -Type: Object +Type: Hashtable Parameter Sets: (All) Aliases: @@ -129,7 +129,7 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS -### System.Object +### PSCustomObject (PSTypeName: IdLE.ExecutionResult) ## NOTES ## RELATED LINKS diff --git a/docs/usage/workflows.md b/docs/usage/workflows.md index 4df3a317..992a8682 100644 --- a/docs/usage/workflows.md +++ b/docs/usage/workflows.md @@ -45,12 +45,12 @@ The host maps step types to step implementations via a step registry. ## Conditional steps -Steps can be skipped using declarative `When` conditions. +Steps can be skipped using declarative `Condition` key. Example: ```powershell -When = @{ +Condition = @{ Path = 'Plan.LifecycleEvent' Equals = 'Joiner' } diff --git a/examples/workflows/joiner-with-condition.psd1 b/examples/workflows/joiner-with-condition.psd1 new file mode 100644 index 00000000..7993ffb0 --- /dev/null +++ b/examples/workflows/joiner-with-condition.psd1 @@ -0,0 +1,32 @@ +@{ + Name = 'Joiner - Condition Demo' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'EmitOnlyForJoiner' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + With = @{ + Message = 'This step runs only if Plan.LifecycleEvent == Joiner.' + } + } + @{ + Name = 'SkipForJoiner' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } + With = @{ + Message = 'You should never see this in a Joiner run.' + } + } + ) +} diff --git a/examples/workflows/joiner-with-when.psd1 b/examples/workflows/joiner-with-when.psd1 deleted file mode 100644 index 48e88090..00000000 --- a/examples/workflows/joiner-with-when.psd1 +++ /dev/null @@ -1,28 +0,0 @@ -@{ - Name = 'Joiner - When Demo' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'EmitOnlyForJoiner' - Type = 'IdLE.Step.EmitEvent' - When = @{ - Path = 'Plan.LifecycleEvent' - Equals = 'Joiner' - } - With = @{ - Message = 'This step runs only when Plan.LifecycleEvent == Joiner.' - } - } - @{ - Name = 'SkipForJoiner' - Type = 'IdLE.Step.EmitEvent' - When = @{ - Path = 'Plan.LifecycleEvent' - Equals = 'Leaver' - } - With = @{ - Message = 'You should never see this in a Joiner run.' - } - } - ) -} diff --git a/src/IdLE.Core/Private/Test-IdleCondition.ps1 b/src/IdLE.Core/Private/Test-IdleCondition.ps1 new file mode 100644 index 00000000..af6c60c5 --- /dev/null +++ b/src/IdLE.Core/Private/Test-IdleCondition.ps1 @@ -0,0 +1,146 @@ +function Test-IdleCondition { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Condition, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context + ) + + # Evaluates a declarative Condition (data-only) against the provided context. + # + # Supported schema (validated by Test-IdleConditionSchema): + # - Groups: All | Any | None (each contains an array/list of condition nodes) + # - Operators: + # - Equals = @{ Path = ''; Value = } + # - NotEquals = @{ Path = ''; Value = } + # - Exists = '' OR @{ Path = '' } + # - In = @{ Path = ''; Values = } + # + # Paths are resolved via Get-IdleValueByPath against the provided $Context. + # For readability in configuration, a leading "context." prefix is ignored. + + $schemaErrors = Test-IdleConditionSchema -Condition $Condition -StepName $null + if (@($schemaErrors).Count -gt 0) { + $msg = "Condition schema validation failed: {0}" -f ([string]::Join(' ', @($schemaErrors))) + throw [System.ArgumentException]::new($msg, 'Condition') + } + + function Resolve-IdleConditionPathValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + # Allow "context." prefix for readability in config files. + $effectivePath = if ($Path.StartsWith('context.')) { $Path.Substring(8) } else { $Path } + + return Get-IdleValueByPath -Object $Context -Path $effectivePath + } + + function Test-IdleConditionNode { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [System.Collections.IDictionary] $Node + ) + + # GROUPS + if ($Node.Contains('All')) { + foreach ($child in @($Node.All)) { + if (-not (Test-IdleConditionNode -Node ([System.Collections.IDictionary]$child))) { + return $false + } + } + return $true + } + + if ($Node.Contains('Any')) { + foreach ($child in @($Node.Any)) { + if (Test-IdleConditionNode -Node ([System.Collections.IDictionary]$child)) { + return $true + } + } + return $false + } + + if ($Node.Contains('None')) { + foreach ($child in @($Node.None)) { + if (Test-IdleConditionNode -Node ([System.Collections.IDictionary]$child)) { + return $false + } + } + return $true + } + + # OPERATORS + if ($Node.Contains('Equals')) { + $op = $Node.Equals + + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $expected = $op.Value + + # Stable semantics: compare as strings (keeps config predictable across providers/types). + return ([string]$actual -eq [string]$expected) + } + + if ($Node.Contains('NotEquals')) { + $op = $Node.NotEquals + + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $expected = $op.Value + + return ([string]$actual -ne [string]$expected) + } + + if ($Node.Contains('Exists')) { + $existsVal = $Node.Exists + + $path = if ($existsVal -is [string]) { + [string]$existsVal + } else { + [string]$existsVal.Path + } + + $value = Resolve-IdleConditionPathValue -Path $path + return ($null -ne $value) + } + + if ($Node.Contains('In')) { + $op = $Node.In + + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $values = $op.Values + + if ($null -eq $values) { + return $false + } + + # Treat scalar and array uniformly. + $candidates = if ($values -is [System.Collections.IEnumerable] -and -not ($values -is [string])) { + @($values) + } else { + @($values) + } + + foreach ($candidate in $candidates) { + if ([string]$actual -eq [string]$candidate) { + return $true + } + } + + return $false + } + + # Should never happen due to schema validation. + return $false + } + + return (Test-IdleConditionNode -Node $Condition) +} diff --git a/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 b/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 new file mode 100644 index 00000000..fdfb5448 --- /dev/null +++ b/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 @@ -0,0 +1,270 @@ +function Test-IdleConditionSchema { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Condition, + + [Parameter()] + [AllowNull()] + [string] $StepName + ) + + # NOTE: + # This validator is intentionally strict: + # - Unknown keys are errors (keeps configuration deterministic and toolable). + # - A node must be either a group (All/Any/None) OR an operator (Equals/NotEquals/Exists/In). + # - ScriptBlocks are validated elsewhere (Assert-IdleNoScriptBlock). We assume data-only input here. + # + # Supported operator shapes: + # - Equals = @{ Path = ''; Value = } + # - NotEquals = @{ Path = ''; Value = } + # - Exists = '' OR @{ Path = '' } + # - In = @{ Path = ''; Values = } + + $errors = [System.Collections.Generic.List[string]]::new() + $prefix = if ([string]::IsNullOrWhiteSpace($StepName)) { 'Step' } else { "Step '$StepName'" } + + function Add-IdleConditionError { + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $List, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Message + ) + + if ($List -is [System.Collections.Generic.List[string]]) { + $null = $List.Add($Message) + return + } + + if ($List -is [System.Collections.ArrayList]) { + $null = $List.Add($Message) + return + } + + throw [System.InvalidOperationException]::new( + ("Add-IdleConditionError expected a mutable list type but got '{0}'." -f $List.GetType().FullName) + ) + } + + function Test-IdleConditionNodeSchema { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Node, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $NodePath + ) + + $nodeErrors = [System.Collections.Generic.List[string]]::new() + + if (-not ($Node -is [System.Collections.IDictionary])) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be a hashtable/dictionary." -f $NodePath) + return ,$nodeErrors + } + + $allowedGroupKeys = @('All', 'Any', 'None') + $allowedOpKeys = @('Equals', 'NotEquals', 'Exists', 'In') + $allowedKeys = @($allowedGroupKeys + $allowedOpKeys) + + $presentGroupKeys = @($allowedGroupKeys | Where-Object { $Node.Contains($_) }) + $presentOpKeys = @($allowedOpKeys | Where-Object { $Node.Contains($_) }) + + # Enforce: either group OR operator, never both. + if ($presentGroupKeys.Count -gt 0 -and $presentOpKeys.Count -gt 0) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be either a group (All/Any/None) or an operator (Equals/NotEquals/Exists/In), not both." -f $NodePath) + return ,$nodeErrors + } + + # Enforce: at least one recognized key. + if ($presentGroupKeys.Count -eq 0 -and $presentOpKeys.Count -eq 0) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify one group (All/Any/None) or one operator (Equals/NotEquals/Exists/In)." -f $NodePath) + return ,$nodeErrors + } + + # Enforce: exactly one key at this level (avoids ambiguous evaluation). + if (($presentGroupKeys.Count + $presentOpKeys.Count) -ne 1) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify exactly one group/operator key." -f $NodePath) + return ,$nodeErrors + } + + # Unknown keys are errors. + foreach ($k in @($Node.Keys)) { + if ($allowedKeys -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}' in condition node." -f $NodePath, [string]$k) + } + } + + if ($nodeErrors.Count -gt 0) { + return ,$nodeErrors + } + + # GROUP: All/Any/None must be a non-empty array/list of condition nodes. + if ($presentGroupKeys.Count -eq 1) { + $groupKey = [string]$presentGroupKeys[0] + $children = $Node[$groupKey] + $groupPath = ("{0}.{1}" -f $NodePath, $groupKey) + + if ($null -eq $children) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group value must not be null and must contain at least one condition." -f $groupPath) + return ,$nodeErrors + } + + if (-not ($children -is [System.Collections.IEnumerable]) -or ($children -is [string])) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group value must be an array/list of condition nodes." -f $groupPath) + return ,$nodeErrors + } + + $i = 0 + $count = 0 + foreach ($child in @($children)) { + $count++ + foreach ($e in (Test-IdleConditionNodeSchema -Node $child -NodePath ("{0}[{1}]" -f $groupPath, $i))) { + Add-IdleConditionError -List $nodeErrors -Message $e + } + $i++ + } + + if ($count -lt 1) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group must contain at least one condition node." -f $groupPath) + } + + return ,$nodeErrors + } + + # OPERATOR: Exactly one of Equals/NotEquals/Exists/In. + $opKey = [string]$presentOpKeys[0] + $opVal = $Node[$opKey] + $opPath = ("{0}.{1}" -f $NodePath, $opKey) + + switch ($opKey) { + 'Equals' { + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Equals must be a hashtable with keys Path and Value." -f $opPath) + return ,$nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Path', 'Value') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Value." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) + } + + if (-not $opVal.Contains('Value')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath) + } + + return ,$nodeErrors + } + + 'NotEquals' { + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: NotEquals must be a hashtable with keys Path and Value." -f $opPath) + return ,$nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Path', 'Value') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Value." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) + } + + if (-not $opVal.Contains('Value')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath) + } + + return ,$nodeErrors + } + + 'Exists' { + # Exists operator supports two forms: + # Exists = 'context.Attributes.mail' + # Exists = @{ Path = 'context.Attributes.mail' } + if ($opVal -is [string]) { + if ([string]::IsNullOrWhiteSpace([string]$opVal)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Exists path must be a non-empty string." -f $opPath) + } + return ,$nodeErrors + } + + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Exists must be a string path or a hashtable with key Path." -f $opPath) + return ,$nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Path') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) + } + + return ,$nodeErrors + } + + 'In' { + # In operator: + # In = @{ Path = 'context.Identity.Type'; Values = @('Joiner','Mover') } + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: In must be a hashtable with keys Path and Values." -f $opPath) + return ,$nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Path', 'Values') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Values." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) + } + + if (-not $opVal.Contains('Values')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Values." -f $opPath) + return ,$nodeErrors + } + + $values = $opVal.Values + if ($null -eq $values) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Values must not be null." -f $opPath) + return ,$nodeErrors + } + + # Values should be list/array (or scalar) but must not be a dictionary (ambiguous). + if ($values -is [System.Collections.IDictionary]) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Values must be a list/array (or scalar), not a dictionary." -f $opPath) + } + + return ,$nodeErrors + } + } + + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unsupported operator '{1}'." -f $NodePath, $opKey) + return ,$nodeErrors + } + + foreach ($e in (Test-IdleConditionNodeSchema -Node $Condition -NodePath ("{0}: Condition" -f $prefix))) { + Add-IdleConditionError -List $errors -Message $e + } + + return ,$errors +} diff --git a/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 b/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 index b902cbea..d8fbc2fa 100644 --- a/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 +++ b/src/IdLE.Core/Private/Test-IdleStepDefinition.ps1 @@ -40,13 +40,13 @@ function Test-IdleStepDefinition { } } - # Validate When schema (if present) - if ($Step.Contains('When') -and $null -ne $Step['When']) { - if (-not ($Step['When'] -is [hashtable])) { - $errors.Add("Step[$Index] ($name): 'When' must be a hashtable when provided.") + # Validate Condition schema (if present) + if ($Step.Contains('Condition') -and $null -ne $Step['Condition']) { + if (-not ($Step['Condition'] -is [hashtable])) { + $errors.Add("Step[$Index] ($name): 'Condition' must be a hashtable when provided.") } else { - foreach ($e in (Test-IdleWhenConditionSchema -When $Step['When'] -StepName $name)) { + foreach ($e in (Test-IdleConditionSchema -Condition $Step['Condition'] -StepName $name)) { $errors.Add($e) } } diff --git a/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 b/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 deleted file mode 100644 index d92128aa..00000000 --- a/src/IdLE.Core/Private/Test-IdleWhenCondition.ps1 +++ /dev/null @@ -1,44 +0,0 @@ -function Test-IdleWhenCondition { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [hashtable] $When, - - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Context - ) - - # Minimal declarative condition schema: - # - Path (string) required - # - Exactly one of: Equals, NotEquals, Exists - if (-not $When.ContainsKey('Path') -or [string]::IsNullOrWhiteSpace([string]$When.Path)) { - throw [System.ArgumentException]::new("When condition requires key 'Path'.", 'When') - } - - $ops = @('Equals', 'NotEquals', 'Exists') - $presentOps = @($ops | Where-Object { $When.ContainsKey($_) }) - if ($presentOps.Count -ne 1) { - throw [System.ArgumentException]::new("When condition must specify exactly one operator: Equals, NotEquals, Exists.", 'When') - } - - $value = Get-IdleValueByPath -Object $Context -Path ([string]$When.Path) - - if ($When.ContainsKey('Exists')) { - $expected = [bool]$When.Exists - $actual = ($null -ne $value) - return ($actual -eq $expected) - } - - if ($When.ContainsKey('Equals')) { - return ([string]$value -eq [string]$When.Equals) - } - - if ($When.ContainsKey('NotEquals')) { - return ([string]$value -ne [string]$When.NotEquals) - } - - # Should never reach here due to validation. - return $false -} diff --git a/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 deleted file mode 100644 index 50dec924..00000000 --- a/src/IdLE.Core/Private/Test-IdleWhenConditionSchema.ps1 +++ /dev/null @@ -1,36 +0,0 @@ -function Test-IdleWhenConditionSchema { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [hashtable] $When, - - [Parameter()] - [AllowNull()] - [string] $StepName - ) - - $errors = [System.Collections.Generic.List[string]]::new() - $prefix = if ([string]::IsNullOrWhiteSpace($StepName)) { 'Step' } else { "Step '$StepName'" } - - if (-not $When.ContainsKey('Path') -or [string]::IsNullOrWhiteSpace([string]$When.Path)) { - $errors.Add("$($prefix): When requires key 'Path' with a non-empty string value.") - return $errors - } - - # Exactly one operator allowed (MVP) - $ops = @('Equals', 'NotEquals', 'Exists') - $presentOps = @($ops | Where-Object { $When.ContainsKey($_) }) - - if ($presentOps.Count -ne 1) { - $errors.Add("$($prefix): When must specify exactly one operator: Equals, NotEquals, Exists.") - return $errors - } - - # Exists must be boolean-like - if ($When.ContainsKey('Exists')) { - try { [void][bool]$When.Exists } catch { $errors.Add("$($prefix): When.Exists must be boolean.") } - } - - return $errors -} diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 1a61ae9b..f1ff9d22 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -42,7 +42,7 @@ function Test-IdleWorkflowSchema { continue } - $allowedStepKeys = @('Name', 'Type', 'When', 'With', 'Description') + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description') foreach ($k in $step.Keys) { if ($allowedStepKeys -notcontains $k) { $errors.Add("Unknown key '$k' in $stepPath. Allowed keys: $($allowedStepKeys -join ', ').") @@ -64,8 +64,8 @@ function Test-IdleWorkflowSchema { # Conditions must be declarative data, never a ScriptBlock/expression. # We only enforce the shape here; semantic validation comes later. - if ($step.ContainsKey('When') -and $null -ne $step.When -and $step.When -isnot [hashtable]) { - $errors.Add("'$stepPath.When' must be a hashtable (declarative condition object).") + if ($step.ContainsKey('Condition') -and $null -ne $step.Condition -and $step.Condition -isnot [hashtable]) { + $errors.Add("'$stepPath.Condition' must be a hashtable (declarative condition object).") } # 'With' is step parameter bag (data-only). Detailed validation comes with step metadata later. diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index b9dd8738..10083add 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -14,7 +14,7 @@ function Invoke-IdlePlanObject { Provider registry/collection (used for StepRegistry in this increment; passed through for future steps). .PARAMETER EventSink - Optional external event sink for streaming. Must be an object with a WriteEvent(event) method. + Optional external event sink provided by the host. .OUTPUTS PSCustomObject (PSTypeName: IdLE.ExecutionResult) @@ -27,29 +27,22 @@ function Invoke-IdlePlanObject { [Parameter()] [AllowNull()] - [object] $Providers, + [hashtable] $Providers, [Parameter()] [AllowNull()] [object] $EventSink ) - # Validate minimal plan shape. Avoid hard typing to keep cross-module compatibility. - $planProps = $Plan.PSObject.Properties.Name - foreach ($required in @('CorrelationId', 'LifecycleEvent', 'Steps')) { - if ($planProps -notcontains $required) { - throw [System.ArgumentException]::new("Plan object must contain property '$required'.", 'Plan') - } - } + $planProps = @($Plan.PSObject.Properties.Name) - # Secure default: treat host-provided extension points as privileged inputs. # The engine rejects ScriptBlocks in the plan and providers to avoid accidental code execution. Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' $events = [System.Collections.Generic.List[object]]::new() - $corr = [string]$Plan.CorrelationId + $corr = [string]$Plan.CorrelationId $actor = if ($planProps -contains 'Actor') { [string]$Plan.Actor } else { $null } # Create the engine-managed event sink object used by both the engine and steps. @@ -57,15 +50,15 @@ function Invoke-IdlePlanObject { $engineEventSink = New-IdleEventSink -CorrelationId $corr -Actor $actor -ExternalEventSink $EventSink -EventBuffer $events # Resolve step types to PowerShell functions via a registry. - # This decouples workflow "Type" strings from actual implementation functions. - $registry = Get-IdleStepRegistry -Providers $Providers + # + # IMPORTANT: + # - The host MAY provide a StepRegistry, but it is optional. + # - Built-in steps must remain discoverable without requiring the host to wire a registry. + # - Get-IdleStepRegistry merges the host registry (if provided) with built-in handlers (if available). + $stepRegistry = Get-IdleStepRegistry -Providers $Providers - # Provide a small execution context for steps. - # Steps must not call engine-private functions directly; they only use the context. $context = [pscustomobject]@{ PSTypeName = 'IdLE.ExecutionContext' - CorrelationId = $corr - Actor = $actor Plan = $Plan Providers = $Providers @@ -77,12 +70,12 @@ function Invoke-IdlePlanObject { # Emit run start event. $context.EventSink.WriteEvent('RunStarted', 'Plan execution started.', $null, @{ LifecycleEvent = [string]$Plan.LifecycleEvent - WorkflowName = if ($planProps -contains 'WorkflowName') { + WorkflowName = if ($planProps -contains 'WorkflowName') { [string]$Plan.WorkflowName } else { $null } - StepCount = @($Plan.Steps).Count + StepCount = @($Plan.Steps).Count }) $stepResults = @() @@ -97,26 +90,33 @@ function Invoke-IdlePlanObject { $null } - # Evaluate declarative When condition (data-only). - if ($step.PSObject.Properties.Name -contains 'When' -and $null -ne $step.When) { - $shouldRun = Test-IdleWhenCondition -When $step.When -Context $context - if (-not $shouldRun) { - $stepResults += [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $stepName - Type = $stepType - Status = 'Skipped' - Error = $null - } - - $context.EventSink.WriteEvent('StepSkipped', "Step '$stepName' skipped (condition not met).", $stepName, @{ - StepType = $stepType - Index = $i - }) - - $i++ - continue + # Step applicability is evaluated during planning (New-IdlePlanObject). + # At execution time we only respect the planned status. + if ($step.PSObject.Properties.Name -contains 'When') { + throw [System.ArgumentException]::new( + "Plan step '$stepName' still contains legacy key 'When'. This has been renamed to 'Condition'. Please rebuild the plan with an updated workflow definition.", + 'Plan' + ) + } + + if ($step.PSObject.Properties.Name -contains 'Status' -and [string]$step.Status -eq 'NotApplicable') { + + # Synthetic step result: the step was not executed because it was deemed not applicable at plan time. + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'NotApplicable' + Error = $null } + + $context.EventSink.WriteEvent('StepNotApplicable', "Step '$stepName' not applicable (condition not met).", $stepName, @{ + StepType = $stepType + Index = $i + }) + + $i++ + continue } $context.EventSink.WriteEvent('StepStarted', "Step '$stepName' started.", $stepName, @{ @@ -127,15 +127,30 @@ function Invoke-IdlePlanObject { try { # Resolve implementation handler for this step type. # Handler must be a function name (string). - $handler = Resolve-IdleStepHandler -StepType $stepType -Registry $registry - if ($null -eq $handler) { - throw [System.InvalidOperationException]::new("Step type '$stepType' is not registered.") + if ($null -eq $stepType -or [string]::IsNullOrWhiteSpace($stepType)) { + throw [System.ArgumentException]::new("Step '$stepName' is missing a valid Type.", 'Plan') } - # Invoke the step plugin. - $stepResult = & $handler -Context $context -Step $step + if ($null -eq $stepRegistry -or -not ($stepRegistry.ContainsKey($stepType))) { + throw [System.ArgumentException]::new("No step handler registered for type '$stepType'.", 'Providers') + } + + $handlerName = [string]$stepRegistry[$stepType] + if ([string]::IsNullOrWhiteSpace($handlerName)) { + throw [System.ArgumentException]::new("Step handler for type '$stepType' is not a valid function name.", 'Providers') + } - $stepResults += $stepResult + # Execute the step via handler. + $result = & $handlerName -Context $context -Step $step + + # Normalize result shape (minimal contract). + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = if ($null -ne $result -and $result.PSObject.Properties.Name -contains 'Status') { [string]$result.Status } else { 'Completed' } + Error = if ($null -ne $result -and $result.PSObject.Properties.Name -contains 'Error') { $result.Error } else { $null } + } $context.EventSink.WriteEvent('StepCompleted', "Step '$stepName' completed.", $stepName, @{ StepType = $stepType diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 077fe88a..1866428a 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -46,34 +46,73 @@ function New-IdlePlanObject { # Validate workflow and ensure it matches the request's LifecycleEvent. $workflow = Test-IdleWorkflowDefinitionObject -WorkflowPath $WorkflowPath -Request $Request + # Create the plan object (planning artifact). + # Steps will be populated after we have a stable plan context for condition evaluation. + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + WorkflowName = [string]$workflow.Name + LifecycleEvent = [string]$workflow.LifecycleEvent + CorrelationId = [string]$Request.CorrelationId + Actor = if ($reqProps -contains 'Actor') { [string]$Request.Actor } else { $null } + CreatedUtc = [DateTime]::UtcNow + Steps = @() + Actions = @() + Warnings = @() + Providers = $Providers + } + + # Build a planning context for condition evaluation. + # This allows conditions to reference "Plan.*" paths (e.g. Plan.LifecycleEvent). + $planningContext = [pscustomobject]@{ + Plan = $plan + Request = $Request + Workflow = $workflow + } + # Normalize steps into a stable internal representation. # We deliberately keep step entries as PSCustomObject to avoid cross-module class loading issues. + # Step conditions are evaluated during planning and may mark steps as NotApplicable. $normalizedSteps = @() foreach ($s in @($workflow.Steps)) { + + # Breaking change: "When" is no longer supported. Use "Condition" instead. + if ($s.ContainsKey('When')) { + throw [System.ArgumentException]::new( + "Workflow step '$($s.Name)' uses key 'When'. This has been renamed to 'Condition'. Please update the workflow definition.", + 'Workflow' + ) + } + + $condition = if ($s.ContainsKey('Condition')) { $s.Condition } else { $null } + + $status = 'Planned' + if ($null -ne $condition) { + $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName ([string]$s.Name) + if (@($schemaErrors).Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Condition on step '{0}': {1}" -f [string]$s.Name, ([string]::Join(' ', @($schemaErrors)))), + 'Workflow' + ) + } + + $isApplicable = Test-IdleCondition -Condition $condition -Context $planningContext + if (-not $isApplicable) { + $status = 'NotApplicable' + } + } + $normalizedSteps += [pscustomobject]@{ PSTypeName = 'IdLE.PlanStep' Name = [string]$s.Name Type = [string]$s.Type Description = if ($s.ContainsKey('Description')) { [string]$s.Description } else { $null } - When = if ($s.ContainsKey('When')) { $s.When } else { $null } # Declarative; evaluated later. + Condition = $condition With = if ($s.ContainsKey('With')) { $s.With } else { $null } # Parameter bag; validated later. + Status = $status } } - # Create the plan object. Actions are empty in this increment. - # Warnings are an extensibility point (e.g. missing optional inputs). - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - WorkflowName = [string]$workflow.Name - LifecycleEvent = [string]$workflow.LifecycleEvent - CorrelationId = [string]$Request.CorrelationId - Actor = if ($reqProps -contains 'Actor') { [string]$Request.Actor } else { $null } - CreatedUtc = [DateTime]::UtcNow - Steps = $normalizedSteps - Actions = @() - Warnings = @() - Providers = $Providers - } + $plan.Steps = $normalizedSteps return $plan } diff --git a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 index 96085757..7449cfd7 100644 --- a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 +++ b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 @@ -58,7 +58,7 @@ function Test-IdleWorkflowDefinitionObject { } } - # 4b) Validate step definitions (Name/Type/When/With + data-only). + # 4b) Validate step definitions (Name/Type/Condition/With + data-only). $idx = 0 foreach ($s in @($workflow.Steps)) { $stepErrors = Test-IdleStepDefinition -Step $s -Index $idx diff --git a/src/IdLE/Public/Invoke-IdlePlan.ps1 b/src/IdLE/Public/Invoke-IdlePlan.ps1 index d798aae6..1b387b6b 100644 --- a/src/IdLE/Public/Invoke-IdlePlan.ps1 +++ b/src/IdLE/Public/Invoke-IdlePlan.ps1 @@ -20,33 +20,40 @@ function Invoke-IdlePlan { Invoke-IdlePlan -Plan $plan -Providers $providers .OUTPUTS - System.Object + PSCustomObject (PSTypeName: IdLE.ExecutionResult) #> - [CmdletBinding(SupportsShouldProcess)] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( - [Parameter(Mandatory)] + [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNull()] [object] $Plan, [Parameter()] [AllowNull()] - [object] $Providers, + [hashtable] $Providers, [Parameter()] [AllowNull()] [object] $EventSink ) - if (-not $PSCmdlet.ShouldProcess('IdLE Plan', 'Invoke')) { - # For WhatIf: return a minimal preview object. - return [pscustomobject]@{ - PSTypeName = 'IdLE.ExecutionResult' - Status = 'WhatIf' - CorrelationId = if ($Plan.PSObject.Properties.Name -contains 'CorrelationId') { [string]$Plan.CorrelationId } else { $null } - Steps = @($Plan.Steps) - Events = @() + process { + if (-not $PSCmdlet.ShouldProcess('IdLE Plan', 'Invoke')) { + # For -WhatIf: return a minimal preview object. + $correlationId = $null + if ($Plan.PSObject.Properties.Name -contains 'CorrelationId') { + $correlationId = [string]$Plan.CorrelationId + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionResult' + Status = 'WhatIf' + CorrelationId = $correlationId + Steps = @($Plan.Steps) + Events = @() + } } - } - return Invoke-IdlePlanObject -Plan $Plan -Providers $Providers -EventSink $EventSink + return Invoke-IdlePlanObject -Plan $Plan -Providers $Providers -EventSink $EventSink + } } diff --git a/tests/Invoke-IdlePlan.When.Tests.ps1 b/tests/Invoke-IdlePlan.Condition.Tests.ps1 similarity index 54% rename from tests/Invoke-IdlePlan.When.Tests.ps1 rename to tests/Invoke-IdlePlan.Condition.Tests.ps1 index 5b5a729c..d2a08229 100644 --- a/tests/Invoke-IdlePlan.When.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Condition.Tests.ps1 @@ -1,8 +1,10 @@ -BeforeAll { - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' - Import-Module $modulePath -Force +BeforeDiscovery { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule +} - function global:Invoke-IdleWhenTestEmitStep { +BeforeAll { + function global:Invoke-IdleConditionTestEmitStep { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -28,22 +30,27 @@ BeforeAll { AfterAll { # Cleanup global test functions to avoid polluting the session. - Remove-Item -Path 'Function:\Invoke-IdleWhenTestEmitStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdleConditionTestEmitStep' -ErrorAction SilentlyContinue } -Describe 'Invoke-IdlePlan - When conditions' { - - It 'skips a step when condition is not met' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'when.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' +InModuleScope IdLE.Core { + Describe 'Invoke-IdlePlan - Condition applicability' { + It 'does not execute a step when plan marks it as NotApplicable' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'When Demo' + Name = 'Condition Demo' LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Emit' - Type = 'IdLE.Step.EmitEvent' - When = @{ Path = 'Plan.LifecycleEvent'; Equals = 'Leaver' } + Name = 'Emit' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } } ) } @@ -54,29 +61,34 @@ Describe 'Invoke-IdlePlan - When conditions' { $providers = @{ StepRegistry = @{ - 'IdLE.Step.EmitEvent' = 'Invoke-IdleWhenTestEmitStep' + 'IdLE.Step.EmitEvent' = 'Invoke-IdleConditionTestEmitStep' } } $result = Invoke-IdlePlan -Plan $plan -Providers $providers $result.Status | Should -Be 'Completed' - $result.Steps[0].Status | Should -Be 'Skipped' - ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 - ($result.Events | Where-Object Type -eq 'StepSkipped').Count | Should -Be 1 + $result.Steps[0].Status | Should -Be 'NotApplicable' + @($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 + @($result.Events | Where-Object Type -eq 'StepNotApplicable').Count | Should -Be 1 } It 'runs a step when condition is met' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'when2.psd1' + $wfPath = Join-Path -Path $TestDrive -ChildPath 'condition2.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'When Demo' + Name = 'Condition Demo' LifecycleEvent = 'Joiner' Steps = @( @{ - Name = 'Emit' - Type = 'IdLE.Step.EmitEvent' - When = @{ Path = 'Plan.LifecycleEvent'; Equals = 'Joiner' } + Name = 'Emit' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } } ) } @@ -87,7 +99,7 @@ Describe 'Invoke-IdlePlan - When conditions' { $providers = @{ StepRegistry = @{ - 'IdLE.Step.EmitEvent' = 'Invoke-IdleWhenTestEmitStep' + 'IdLE.Step.EmitEvent' = 'Invoke-IdleConditionTestEmitStep' } } @@ -97,4 +109,5 @@ Describe 'Invoke-IdlePlan - When conditions' { $result.Steps[0].Status | Should -Be 'Completed' ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 } -} + } +} \ No newline at end of file diff --git a/tests/Invoke-IdlePlan.StepRegistry.Tests.ps1 b/tests/Invoke-IdlePlan.StepRegistry.Tests.ps1 new file mode 100644 index 00000000..3962ca03 --- /dev/null +++ b/tests/Invoke-IdlePlan.StepRegistry.Tests.ps1 @@ -0,0 +1,46 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + + # The meta module (IdLE) does not automatically import optional step packs. + # For this test we explicitly load the built-in steps module so that + # Get-IdleStepRegistry can discover the handler via Get-Command. + $repoRoot = Get-RepoRootPath + $stepsManifestPath = Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Steps.Common/IdLE.Steps.Common.psd1' + Import-Module -Name $stepsManifestPath -Force -ErrorAction Stop +} + +AfterAll { + # Cleanup to avoid influencing other tests that might rely on a clean module state. + Remove-Module -Name 'IdLE.Steps.Common' -ErrorAction SilentlyContinue +} + +Describe 'Invoke-IdlePlan - StepRegistry' { + It 'executes built-in steps without a host-provided StepRegistry' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'emit-built-in.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Demo' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Emit'; Type = 'IdLE.Step.EmitEvent'; With = @{ Message = 'Hello' } } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + # Intentionally no Providers.StepRegistry here. + $providers = @{} + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + @($result.Steps).Count | Should -Be 1 + $result.Steps[0].Status | Should -Be 'Completed' + + # The built-in EmitEvent step emits a Custom event. + ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + } +} diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index 57b3c832..09b4ae1b 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -1,6 +1,6 @@ BeforeAll { - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' - Import-Module $modulePath -Force + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule # The engine invokes step handlers by function name (string) inside module scope. # Therefore, test handler functions must be visible to the module (global scope). diff --git a/tests/ModuleManifests.Tests.ps1 b/tests/ModuleManifests.Tests.ps1 index 4e28b9cf..029253ec 100644 --- a/tests/ModuleManifests.Tests.ps1 +++ b/tests/ModuleManifests.Tests.ps1 @@ -1,7 +1,8 @@ Set-StrictMode -Version Latest BeforeAll { - . (Join-Path -Path $PSScriptRoot -ChildPath '_testHelpers.ps1') + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'Module manifests' { diff --git a/tests/ModuleSurface.Tests.ps1 b/tests/ModuleSurface.Tests.ps1 index da69f1eb..41734efc 100644 --- a/tests/ModuleSurface.Tests.ps1 +++ b/tests/ModuleSurface.Tests.ps1 @@ -4,6 +4,9 @@ BeforeAll { $corePsd1 = Join-Path $repoRoot 'src\IdLE.Core\IdLE.Core.psd1' $stepsPsd1 = Join-Path $repoRoot 'src\IdLE.Steps.Common\IdLE.Steps.Common.psd1' $providerMockPsd1 = Join-Path $repoRoot 'src\IdLE.Provider.Mock\IdLE.Provider.Mock.psd1' + + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'Module manifests and public surface' { diff --git a/tests/New-IdleLifecycleRequest.Tests.ps1 b/tests/New-IdleLifecycleRequest.Tests.ps1 index ced50f9f..06901ef0 100644 --- a/tests/New-IdleLifecycleRequest.Tests.ps1 +++ b/tests/New-IdleLifecycleRequest.Tests.ps1 @@ -1,6 +1,6 @@ BeforeAll { - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' - Import-Module $modulePath -Force + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'New-IdleLifecycleRequest' { diff --git a/tests/New-IdlePlan.Tests.ps1 b/tests/New-IdlePlan.Tests.ps1 index c487e872..c833b980 100644 --- a/tests/New-IdlePlan.Tests.ps1 +++ b/tests/New-IdlePlan.Tests.ps1 @@ -1,6 +1,6 @@ BeforeAll { - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' - Import-Module $modulePath -Force + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'New-IdlePlan' { diff --git a/tests/PublicApi.Tests.ps1 b/tests/PublicApi.Tests.ps1 index 010b8fda..be225b1f 100644 --- a/tests/PublicApi.Tests.ps1 +++ b/tests/PublicApi.Tests.ps1 @@ -1,13 +1,8 @@ Set-StrictMode -Version Latest BeforeAll { - . (Join-Path -Path $PSScriptRoot -ChildPath '_testHelpers.ps1') - - $repoRoot = Get-RepoRootPath - $idleManifest = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - - Remove-Module -Name IdLE, IdLE.Core -Force -ErrorAction SilentlyContinue - Import-Module -Name $idleManifest -Force -ErrorAction Stop + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'IdLE public API surface' { diff --git a/tests/Test-IdleCondition.Tests.ps1 b/tests/Test-IdleCondition.Tests.ps1 new file mode 100644 index 00000000..86dfd1d7 --- /dev/null +++ b/tests/Test-IdleCondition.Tests.ps1 @@ -0,0 +1,293 @@ +BeforeDiscovery { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Condition DSL (schema + evaluator)' { + + InModuleScope 'IdLE.Core' { + + BeforeAll { + # Guarding to ensure the functions are available. + Get-Command Test-IdleConditionSchema -ErrorAction Stop | Out-Null + Get-Command Test-IdleCondition -ErrorAction Stop | Out-Null + } + + Describe 'Test-IdleConditionSchema' { + + It 'accepts an Equals operator with Path + Value' { + $condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -Be 0 + } + + It 'accepts a nested All group with multiple conditions' { + $condition = @{ + All = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + @{ Exists = @{ Path = 'Plan.LifecycleEvent' } } + ) + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -Be 0 + } + + It 'accepts Exists as short form string path' { + $condition = @{ + Exists = 'Plan.LifecycleEvent' + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -Be 0 + } + + It 'accepts In operator with Values as array' { + $condition = @{ + In = @{ + Path = 'Plan.LifecycleEvent' + Values = @('Joiner', 'Mover') + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -Be 0 + } + + It 'rejects unknown keys' { + $condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + Foo = 'Bar' + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects nodes that define both group and operator' { + $condition = @{ + All = @( + @{ + Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } + Any = @() + } + ) + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects group nodes with empty children' { + $condition = @{ + Any = @() + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects Equals with missing Path' { + $condition = @{ + Equals = @{ + Value = 'Joiner' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects In with missing Values' { + $condition = @{ + In = @{ + Path = 'Plan.LifecycleEvent' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + } + + Describe 'Test-IdleCondition' { + + It 'returns true when Equals matches' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns false when Equals does not match' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeFalse + } + + It 'supports context. prefix in paths' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Equals = @{ + Path = 'context.Plan.LifecycleEvent' + Value = 'Joiner' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns true when Exists finds a non-null value' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Exists = @{ + Path = 'Plan.LifecycleEvent' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns false when Exists cannot resolve the path' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Exists = @{ + Path = 'Plan.DoesNotExist' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeFalse + } + + It 'returns true when In matches a candidate value' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + In = @{ + Path = 'Plan.LifecycleEvent' + Values = @('Joiner', 'Mover') + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'evaluates All as logical AND' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + All = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + @{ NotEquals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + ) + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'evaluates Any as logical OR' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + Any = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + ) + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'evaluates None as logical NOR' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + $condition = @{ + None = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Mover' } } + ) + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'throws when schema validation fails' { + $context = [pscustomobject]@{ + Plan = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + } + } + + # Invalid because Equals is missing Path + $condition = @{ + Equals = @{ + Value = 'Joiner' + } + } + + { Test-IdleCondition -Condition $condition -Context $context } | Should -Throw + } + } + } +} diff --git a/tests/Test-IdleWorkflow.Tests.ps1 b/tests/Test-IdleWorkflow.Tests.ps1 index 33f30faf..6ddc7585 100644 --- a/tests/Test-IdleWorkflow.Tests.ps1 +++ b/tests/Test-IdleWorkflow.Tests.ps1 @@ -1,6 +1,6 @@ BeforeAll { - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\IdLE\IdLE.psd1' - Import-Module $modulePath -Force + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule } Describe 'Test-IdleWorkflow' { diff --git a/tests/WorkflowSamples.Tests.ps1 b/tests/WorkflowSamples.Tests.ps1 index 230b3f27..6431712c 100644 --- a/tests/WorkflowSamples.Tests.ps1 +++ b/tests/WorkflowSamples.Tests.ps1 @@ -1,15 +1,10 @@ Set-StrictMode -Version Latest BeforeAll { - . (Join-Path -Path $PSScriptRoot -ChildPath '_testHelpers.ps1') + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule - $repoRoot = Get-RepoRootPath - $idleManifest = Join-Path -Path $repoRoot -ChildPath 'src/IdLE/IdLE.psd1' - - Remove-Module -Name IdLE, IdLE.Core -Force -ErrorAction SilentlyContinue - Import-Module -Name $idleManifest -Force -ErrorAction Stop - - $workflowsPath = Join-Path -Path $repoRoot -ChildPath 'examples/workflows' + $workflowsPath = Join-Path -Path (Get-RepoRootPath) -ChildPath 'examples/workflows' } Describe 'Example workflows' { diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index d09bd9d0..aa9beb9f 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -8,6 +8,22 @@ function Get-RepoRootPath { return (Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..')).Path } +function Get-IdleModuleManifestPath { + [CmdletBinding()] + param() + + $repoRoot = Get-RepoRootPath + return (Resolve-Path -Path (Join-Path $repoRoot 'src/IdLE/IdLE.psd1')).Path +} + +function Import-IdleTestModule { + [CmdletBinding()] + param() + + $manifestPath = Get-IdleModuleManifestPath + Import-Module -Name $manifestPath -Force -ErrorAction Stop +} + function Get-ModuleManifestPaths { [CmdletBinding()] param() @@ -21,3 +37,4 @@ function Get-ModuleManifestPaths { Where-Object { $_.Directory.Parent -and $_.Directory.Parent.Name -eq 'src' } | Select-Object -ExpandProperty FullName } +