From b7d20be445ce3d95395a8c63b29bff8fef659695 Mon Sep 17 00:00:00 2001 From: nohwnd Date: Tue, 30 Jun 2026 19:28:10 +0200 Subject: [PATCH] Make Should-ContainCollection match a real sub-collection Should-ContainCollection and Should-NotContainCollection used PowerShell's -contains operator, which only tests a single scalar value. The comment-based help, however, documented multi-item examples such as `1, 2, 3 | Should-ContainCollection @(1, 2)` and claimed they pass, when in fact they failed at runtime. The name and docs promised sub-collection containment that was never implemented. Implement an ordered-subsequence match instead: the expected items must appear in the actual collection in the same order, gaps are allowed, and each actual item is consumed at most once (so repeated expected items need at least as many matching actual items). A single value keeps the original one-item behaviour. The matching lives in a shared Is-CollectionSubsequence helper. The comment-based help for both assertions is rewritten to describe the real semantics, and the copy-paste error in Should-NotContainCollection's examples is fixed. Tests cover contiguous, gapped, out-of-order and duplicate cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Collection/Should-ContainCollection.ps1 | 17 +++++---- .../Should-NotContainCollection.ps1 | 21 +++++------ .../Common/Is-CollectionSubsequence.ps1 | 35 +++++++++++++++++++ .../Should-ContainCollection.Tests.ps1 | 32 +++++++++++++++++ .../Should-NotContainCollection.Tests.ps1 | 22 ++++++++++++ 5 files changed, 110 insertions(+), 17 deletions(-) create mode 100644 src/functions/assert/Common/Is-CollectionSubsequence.ps1 diff --git a/src/functions/assert/Collection/Should-ContainCollection.ps1 b/src/functions/assert/Collection/Should-ContainCollection.ps1 index 732da5b7f..f415eb9b4 100644 --- a/src/functions/assert/Collection/Should-ContainCollection.ps1 +++ b/src/functions/assert/Collection/Should-ContainCollection.ps1 @@ -1,16 +1,16 @@ function Should-ContainCollection { <# .SYNOPSIS - Compares collections to see if the expected collection is present in the provided collection. It does not compare the types of the input collections. + Checks that the expected collection is present in the actual collection as an ordered subsequence. It does not compare the types of the input collections. .DESCRIPTION - This assertion uses PowerShell containment to check whether the actual collection contains the expected value. The comparison uses the contained value's own equality semantics. + The items of the expected collection must appear in the actual collection in the same order. Gaps between the matched items are allowed, but each actual item is used at most once, so repeated expected items need at least as many matching items in the actual collection. A single value is treated as a one-item collection. Items are compared using PowerShell equality, the same as the `-contains` operator. .PARAMETER Expected - A collection of items. + One or more items to look for as an ordered subsequence. A single value is treated as a one-item collection. .PARAMETER Actual - A collection of items. + The collection to search in. .PARAMETER Because The reason why the input should be the expected value. @@ -18,19 +18,22 @@ .EXAMPLE ```powershell 1, 2, 3 | Should-ContainCollection @(1, 2) + 1, 2, 3 | Should-ContainCollection @(1, 3) @(1) | Should-ContainCollection @(1) + 1, 2, 3 | Should-ContainCollection 2 ``` - This assertion will pass, because all items are present in the collection, in the right order. + These assertions pass, because the expected items are present in the same order. Gaps between them, as in `@(1, 3)`, are allowed, and a single value is treated as a one-item collection. .EXAMPLE ```powershell 1, 2, 3 | Should-ContainCollection @(3, 4) 1, 2, 3 | Should-ContainCollection @(3, 2, 1) + 1, 2 | Should-ContainCollection @(1, 1) @(1) | Should-ContainCollection @(2) ``` - This assertion will fail, because not all items are present in the collection, or are not in the right order. + These assertions fail, because an expected item is missing (`@(3, 4)`), the items are not in the right order (`@(3, 2, 1)`), or the actual collection does not have enough matching items (`@(1, 1)` needs two 1s). .LINK https://pester.dev/docs/commands/Should-ContainCollection @@ -63,7 +66,7 @@ Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } - if ($Actual -notcontains $Expected) { + if (-not (Is-CollectionSubsequence -Expected $Expected -Actual $Actual)) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected to be present in , but it was not there." & $reportFailure $Message } diff --git a/src/functions/assert/Collection/Should-NotContainCollection.ps1 b/src/functions/assert/Collection/Should-NotContainCollection.ps1 index 2344bf310..cb9d9226c 100644 --- a/src/functions/assert/Collection/Should-NotContainCollection.ps1 +++ b/src/functions/assert/Collection/Should-NotContainCollection.ps1 @@ -1,36 +1,37 @@ function Should-NotContainCollection { <# .SYNOPSIS - Compares collections to ensure that the expected collection is not present in the provided collection. It does not compare the types of the input collections. + Checks that the expected collection is not present in the actual collection as an ordered subsequence. It does not compare the types of the input collections. .DESCRIPTION - This assertion uses PowerShell containment to check that the actual collection does not contain the expected value. The comparison uses the contained value's own equality semantics. + Passes when the items of the expected collection do not appear in the actual collection in the same order. The subsequence is matched the same way as `Should-ContainCollection`: gaps between matched items are allowed, but each actual item is used at most once. A single value is treated as a one-item collection. Items are compared using PowerShell equality, the same as the `-contains` operator. .PARAMETER Expected - A collection of items. + One or more items to look for as an ordered subsequence. A single value is treated as a one-item collection. .PARAMETER Actual - A collection of items. + The collection to search in. .PARAMETER Because The reason why the input should be the expected value. .EXAMPLE ```powershell - 1, 2, 3 | Should-ContainCollection @(3, 4) - 1, 2, 3 | Should-ContainCollection @(3, 2, 1) - @(1) | Should-ContainCollection @(2) + 1, 2, 3 | Should-NotContainCollection @(3, 4) + 1, 2, 3 | Should-NotContainCollection @(3, 2, 1) + @(1) | Should-NotContainCollection @(2) ``` - This assertion will pass, because the collections are different, or the items are not in the right order. + These assertions pass, because the expected items are not present as an ordered subsequence. .EXAMPLE ```powershell 1, 2, 3 | Should-NotContainCollection @(1, 2) + 1, 2, 3 | Should-NotContainCollection @(1, 3) @(1) | Should-NotContainCollection @(1) ``` - This assertion will fail, because all items are present in the collection and are in the right order. + These assertions fail, because the expected items are present in the same order. Gaps between them, as in `@(1, 3)`, are allowed. .LINK https://pester.dev/docs/commands/Should-NotContainCollection @@ -63,7 +64,7 @@ Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } - if ($Actual -contains $Expected) { + if (Is-CollectionSubsequence -Expected $Expected -Actual $Actual) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected to not be present in , but it was there." & $reportFailure $Message } diff --git a/src/functions/assert/Common/Is-CollectionSubsequence.ps1 b/src/functions/assert/Common/Is-CollectionSubsequence.ps1 new file mode 100644 index 000000000..297f3c83f --- /dev/null +++ b/src/functions/assert/Common/Is-CollectionSubsequence.ps1 @@ -0,0 +1,35 @@ +function Is-CollectionSubsequence ($Expected, $Actual) { + # Returns $true when every item of $Expected appears within $Actual in the same + # relative order. Gaps between the matched items are allowed, but each $Actual item is + # consumed at most once, so repeated items in $Expected need at least as many matching + # items in $Actual (e.g. @(1, 1) needs two 1s in $Actual, not one reused twice). + # + # A single, non-collection value is treated as a one-item collection, which keeps the + # original single-item containment behaviour as the one-item special case. + + # Materialise the expected items so we can index them by position. Adding them one by + # one preserves each item as a single element, even when it is itself a collection, + # which @(...) would otherwise flatten. + $expectedItems = [System.Collections.Generic.List[object]]::new() + foreach ($item in $Expected) { $expectedItems.Add($item) } + + $expectedCount = $expectedItems.Count + # An empty expected collection is vacuously present in any collection. + if (0 -eq $expectedCount) { return $true } + + # Greedy two-pointer subsequence match: walk $Actual once and advance through the + # expected items whenever the current actual item matches the next one we need. Matching + # the earliest occurrence is always safe for a subsequence, so a single pass is enough. + $matchIndex = 0 + foreach ($actualItem in $Actual) { + # Compare with -eq, actual item on the left, to match the equality semantics of + # PowerShell's -contains operator. Cast to [bool] so an actual item that is itself a + # collection collapses to a single truthy/falsy result instead of a filtered array. + if ([bool]($actualItem -eq $expectedItems[$matchIndex])) { + $matchIndex++ + if ($matchIndex -eq $expectedCount) { return $true } + } + } + + return $false +} diff --git a/tst/functions/assert/Collection/Should-ContainCollection.Tests.ps1 b/tst/functions/assert/Collection/Should-ContainCollection.Tests.ps1 index b85899680..4e43b503e 100644 --- a/tst/functions/assert/Collection/Should-ContainCollection.Tests.ps1 +++ b/tst/functions/assert/Collection/Should-ContainCollection.Tests.ps1 @@ -15,6 +15,38 @@ InPesterModuleScope { @(1, 2, 3) | Should-ContainCollection 1 } + It "Passes when the expected items appear as a contiguous block" { + 1, 2, 3 | Should-ContainCollection @(1, 2) + 1, 2, 3 | Should-ContainCollection @(2, 3) + } + + It "Passes when the expected items appear in order with gaps" { + 1, 2, 3 | Should-ContainCollection @(1, 3) + } + + It "Passes when an expected collection of one item is present" { + @(1) | Should-ContainCollection @(1) + } + + It "Passes when there are enough duplicate items to match repeated expected items" { + 1, 1, 2 | Should-ContainCollection @(1, 1) + } + + It "Fails when the expected items are not in the right order" { + $err = { 1, 2, 3 | Should-ContainCollection @(3, 2, 1) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected [Object[]] @(3, 2, 1) to be present in [Object[]] @(1, 2, 3), but it was not there." + } + + It "Fails when an expected item is missing" { + $err = { 1, 2, 3 | Should-ContainCollection @(3, 4) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected [Object[]] @(3, 4) to be present in [Object[]] @(1, 2, 3), but it was not there." + } + + It "Fails when a repeated expected item cannot be matched by a single actual item" { + { 1, 2 | Should-ContainCollection @(1, 1) } | Verify-AssertionFailed + { @(1) | Should-ContainCollection @(1, 1) } | Verify-AssertionFailed + } + It "Fails when collection of multiple items does not contain the expected item" { $err = { @(5, 6, 7) | Should-ContainCollection 1 } | Verify-AssertionFailed $err.Exception.Message | Verify-Equal "Expected [int] 1 to be present in [Object[]] @(5, 6, 7), but it was not there." diff --git a/tst/functions/assert/Collection/Should-NotContainCollection.Tests.ps1 b/tst/functions/assert/Collection/Should-NotContainCollection.Tests.ps1 index be60d0eb4..d6c0a5c8a 100644 --- a/tst/functions/assert/Collection/Should-NotContainCollection.Tests.ps1 +++ b/tst/functions/assert/Collection/Should-NotContainCollection.Tests.ps1 @@ -20,6 +20,28 @@ InPesterModuleScope { @(5, 6, 7) | Should-NotContainCollection 1 } + It "Passes when the expected items are not in the right order" { + 1, 2, 3 | Should-NotContainCollection @(3, 2, 1) + } + + It "Passes when an expected item is missing" { + 1, 2, 3 | Should-NotContainCollection @(3, 4) + } + + It "Passes when a repeated expected item cannot be matched by a single actual item" { + 1, 2 | Should-NotContainCollection @(1, 1) + } + + It "Fails when the expected items appear as a contiguous block" { + $err = { 1, 2, 3 | Should-NotContainCollection @(1, 2) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected [Object[]] @(1, 2) to not be present in [Object[]] @(1, 2, 3), but it was there." + } + + It "Fails when the expected items appear in order with gaps" { + $err = { 1, 2, 3 | Should-NotContainCollection @(1, 3) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected [Object[]] @(1, 3) to not be present in [Object[]] @(1, 2, 3), but it was there." + } + It "Can be called with positional parameters" { { Should-NotContainCollection 1 1, 2, 3 } | Verify-AssertionFailed }