diff --git a/docs/6.0.0.md b/docs/6.0.0.md index 5ea221074..12d5662fc 100644 --- a/docs/6.0.0.md +++ b/docs/6.0.0.md @@ -104,6 +104,20 @@ $null | Should-Be -Expected $null Should-HaveType -Actual ([int[]](1, 2)) -Expected ([int[]]) ``` +If you forget and pipe a collection into a value or type assertion, the unwrapping is never a silent +surprise: when it makes the assertion fail, the message adds a hint that explains what the pipeline +did and points you back to `-Actual`. + +```powershell +[int[]](1, 2) | Should-HaveType ([int[]]) +# Expected value to have type [int[]], but got [Object[]] @(1, 2). +# +# Hint: You piped a [int[]] into a type assertion, but the pipeline streams a multi-item +# collection and re-collects it as [Object[]], so the assertion saw [Object[]], not the +# [int[]] you piped. To assert the type of a collection, pass it as the -Actual argument +# instead of piping it, e.g. -Actual $value. +``` + #### More examples ```powershell @@ -377,6 +391,10 @@ targets **Windows PowerShell 5.1** and **PowerShell 7.2+**. - **Array comparisons** in `Should -Be` now point at the first differing index and show the shape of the input, instead of a flat "expected/but got". - **String diffs** show an arrow marker at the first differing character. +- **Piping a collection into a value or type assertion** unwraps it (one item becomes a scalar, + several become `[object[]]`), so the assertion no longer sees your collection. When that makes an + assertion fail, the message adds a hint explaining what the pipeline did and pointing you to + `-Actual`. - **Control characters and ANSI/VT sequences** in values are escaped (using Unicode Control Pictures) so they can't corrupt the console or the NUnit/JUnit report. - `Output.StackTraceVerbosity` (`None`, `FirstLine`, `Filtered`, `Full`), `Output.CIFormat` diff --git a/src/functions/assert/Boolean/Should-BeFalse.ps1 b/src/functions/assert/Boolean/Should-BeFalse.ps1 index c6b3c762f..9437e5dfc 100644 --- a/src/functions/assert/Boolean/Should-BeFalse.ps1 +++ b/src/functions/assert/Boolean/Should-BeFalse.ps1 @@ -52,6 +52,8 @@ $Actual = $collectedInput.Actual if ($Actual -isnot [bool] -or $Actual) { $Message = Get-AssertionMessage -Expected $false -Actual $Actual -Because $Because -DefaultMessage "Expected , but got: ." + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/Boolean/Should-BeFalsy.ps1 b/src/functions/assert/Boolean/Should-BeFalsy.ps1 index 5a50d6ce4..1471ec0fd 100644 --- a/src/functions/assert/Boolean/Should-BeFalsy.ps1 +++ b/src/functions/assert/Boolean/Should-BeFalsy.ps1 @@ -52,6 +52,8 @@ $Actual = $collectedInput.Actual if ($Actual) { $Message = Get-AssertionMessage -Expected $false -Actual $Actual -Because $Because -DefaultMessage 'Expected or a falsy value: 0, "", $null or @(), but got: .' + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/Boolean/Should-BeTrue.ps1 b/src/functions/assert/Boolean/Should-BeTrue.ps1 index 41b37b38e..63373843f 100644 --- a/src/functions/assert/Boolean/Should-BeTrue.ps1 +++ b/src/functions/assert/Boolean/Should-BeTrue.ps1 @@ -52,6 +52,8 @@ $Actual = $collectedInput.Actual if ($Actual -isnot [bool] -or -not $Actual) { $Message = Get-AssertionMessage -Expected $true -Actual $Actual -Because $Because -DefaultMessage "Expected , but got: ." + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/Boolean/Should-BeTruthy.ps1 b/src/functions/assert/Boolean/Should-BeTruthy.ps1 index e36673733..ef58fc8ce 100644 --- a/src/functions/assert/Boolean/Should-BeTruthy.ps1 +++ b/src/functions/assert/Boolean/Should-BeTruthy.ps1 @@ -53,6 +53,8 @@ $Actual = $collectedInput.Actual if (-not $Actual) { $Message = Get-AssertionMessage -Expected $true -Actual $Actual -Because $Because -DefaultMessage "Expected or a truthy value, but got: ." + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/Common/Ensure-ExpectedIsNotCollection.ps1 b/src/functions/assert/Common/Ensure-ExpectedIsNotCollection.ps1 index 83d174ab0..c0cea8d4c 100644 --- a/src/functions/assert/Common/Ensure-ExpectedIsNotCollection.ps1 +++ b/src/functions/assert/Common/Ensure-ExpectedIsNotCollection.ps1 @@ -5,7 +5,7 @@ function Ensure-ExpectedIsNotCollection { if (Is-Collection $InputObject) { - throw [ArgumentException]'You provided a collection to the -Expected parameter. Using a collection on the -Expected side is not allowed by this assertion, because it leads to unexpected behavior. Please use Should-Any, Should-All or some other specialized collection assertion.' + throw [ArgumentException]'You provided a collection to the -Expected parameter. Using a collection on the -Expected side is not allowed by this assertion, because it leads to unexpected behavior. To compare collections use Should-BeCollection, or a more specialized collection assertion such as Should-Any or Should-All.' } $InputObject diff --git a/src/functions/assert/Common/Get-AssertionGotcha.ps1 b/src/functions/assert/Common/Get-AssertionGotcha.ps1 index f4b83789b..1b2ff4c7e 100644 --- a/src/functions/assert/Common/Get-AssertionGotcha.ps1 +++ b/src/functions/assert/Common/Get-AssertionGotcha.ps1 @@ -25,11 +25,87 @@ function Get-AssertionGotcha { # lone scalar or $null is a perfectly valid one-item collection here, so # only a dictionary -- which PowerShell silently passes through as a # single, non-iterated object -- is a genuine gotcha. - [ValidateSet('Collection', 'CollectionItems')] + # ExactType - the input is checked as a single value against a type (e.g. + # Should-HaveType, Should-NotHaveType). Piping a collection unwraps it + # (one item becomes a scalar, several become [object[]]), so the original + # collection type is lost. Here a piped collection is the gotcha; a piped + # scalar is fine. + # Scalar - the input is inspected as a single value, but not for its type (e.g. + # Should-Be, the string/boolean/comparison/null/hashtable assertions). + # Piping a collection unwraps it the same way, so the assertion silently + # inspects the collapsed value instead of the collection. Unlike + # ExactType the wording is about the collection being flattened, not its + # type changing, so even an [object[]] that stays an [object[]] is worth + # pointing out. + [ValidateSet('Collection', 'CollectionItems', 'ExactType', 'Scalar')] [string] $Expecting = 'Collection' ) try { + if ($Expecting -eq 'ExactType') { + # Only piped input can be a gotcha here: a collection passed with -Actual keeps its real + # type and asserts correctly. The PipelineSource trick recovers the *original* left-hand + # side, so even though the assertion only ever sees the unwrapped remains we can tell: + # scalar - a genuine single value was piped; it keeps its type, so nothing to say. + # collection - a real collection was piped and the pipeline unwrapped it (the gotcha). + # range/etc. - nothing we can name with confidence, so stay quiet. + if (-not $IsPipelineInput) { return $null } + $info = [Pester.PipelineSource]::Resolve($Cmdlet, @($Buffer)) + if ($info.Source -ne 'collection') { return $null } + + # An empty collection is sent through the pipeline as no items at all, so nothing was + # unwrapped in the #2801 sense -- there is no surprising type change to explain. + if ($info.Count -eq 0) { return $null } + + # The trick recovers the genuine piped type (e.g. [string[]]) and item count, neither of + # which the failure message can show because the pipeline already unwrapped the value. + # $CollectedActual is what the assertion actually compared, i.e. what the collection was + # unwrapped into. The recovered count tells us which unwrapping happened: + # one item -> the pipeline yields that single element, so a scalar reaches the assertion. + # many items -> the elements are streamed and re-collected into an [Object[]]. + $pipedType = Get-ShortType2 -Value $info.Value + $seenType = Get-ShortType2 -Value $CollectedActual + + # If the pipeline did not change the observable type (e.g. an [Object[]] is streamed and + # re-collected straight back into an [Object[]]), then the type was never lost and the + # failure is a genuine mismatch. Saying "saw [Object[]], not the [Object[]] you piped" + # would be nonsense, so there is nothing useful to hint. + if ($seenType -eq $pipedType) { return $null } + + $advice = "To assert the type of a collection, pass it as the -Actual argument instead of piping it, e.g. -Actual `$value." + + if ($info.Count -eq 1) { + return "You piped a $pipedType into a type assertion, but the pipeline unwraps a single-item collection to its one element, so the assertion saw a single $seenType, not the $pipedType you piped. $advice" + } + + return "You piped a $pipedType into a type assertion, but the pipeline streams a multi-item collection and re-collects it as $seenType, so the assertion saw $seenType, not the $pipedType you piped. $advice" + } + + if ($Expecting -eq 'Scalar') { + # Same gotcha as ExactType -- a piped collection is unwrapped before the assertion sees + # it -- but here the assertion does not care about the type, only the single value. So the + # story is "your collection was collapsed into one value and inspected as a whole", which + # is worth telling even when the collapsed value is still an [Object[]] (e.g. a piped + # [Object[]] re-collected as [Object[]]). That is why this branch has no "type did not + # change" guard, unlike ExactType. + if (-not $IsPipelineInput) { return $null } + $info = [Pester.PipelineSource]::Resolve($Cmdlet, @($Buffer)) + if ($info.Source -ne 'collection') { return $null } + + # An empty collection sends no items through the pipeline, so there is nothing that was + # collapsed and nothing surprising to explain. + if ($info.Count -eq 0) { return $null } + + $pipedType = Get-ShortType2 -Value $info.Value + $seenType = Get-ShortType2 -Value $CollectedActual + $advice = "To assert on a collection use Should-BeCollection or Should-BeEquivalent; to assert on a single value pass it as the -Actual argument instead of piping it, e.g. -Actual `$value." + + if ($info.Count -eq 1) { + return "You piped a $pipedType into a single-value assertion, but the pipeline unwraps a single-item collection to its one element, so the assertion inspected that single $seenType instead of the collection. $advice" + } + + return "You piped a $pipedType into a single-value assertion, but the pipeline streams a multi-item collection and re-collects it into a single $seenType, so the whole collection was inspected as one value. $advice" + } if ($IsPipelineInput) { # Recover the original left-hand side, undoing the engine's single-item wrapping, so we # can tell "a single hashtable was piped" (scalar) from "a real 1-item collection". diff --git a/src/functions/assert/General/Should-Be.ps1 b/src/functions/assert/General/Should-Be.ps1 index a15d5060d..9ecb01a6d 100644 --- a/src/functions/assert/General/Should-Be.ps1 +++ b/src/functions/assert/General/Should-Be.ps1 @@ -46,6 +46,8 @@ if ((Ensure-ExpectedIsNotCollection $Expected) -ne $Actual) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected , but got ." + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/General/Should-BeGreaterThan.ps1 b/src/functions/assert/General/Should-BeGreaterThan.ps1 index e25429090..5d1dc646c 100644 --- a/src/functions/assert/General/Should-BeGreaterThan.ps1 +++ b/src/functions/assert/General/Should-BeGreaterThan.ps1 @@ -42,8 +42,24 @@ $collectedInput = Collect-Input -ParameterInput $Actual -PipelineInput $local:Input -IsPipelineInput $MyInvocation.ExpectingInput -UnrollInput $Actual = $collectedInput.Actual - if ((Ensure-ExpectedIsNotCollection $Expected) -ge $Actual) { + $expectedValue = Ensure-ExpectedIsNotCollection $Expected + # The comparison operators throw a native conversion error when $Actual is not a comparable + # scalar, which is exactly what happens when a collection is piped in and unwrapped to [object[]]. + # Catch it so we can show the input hint instead of a cryptic "Could not compare" error. When it + # is not a piped-collection gotcha we have nothing to add, so the original error is rethrown. + $failed = $false + $comparisonError = $null + try { + $failed = $expectedValue -ge $Actual + } + catch { + $comparisonError = $_ + } + if ($comparisonError -or $failed) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected the actual value to be greater than , but it was not. Actual: " + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($comparisonError -and -not $hint) { throw $comparisonError } + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/General/Should-BeGreaterThanOrEqual.ps1 b/src/functions/assert/General/Should-BeGreaterThanOrEqual.ps1 index cf830649f..a3caeddc1 100644 --- a/src/functions/assert/General/Should-BeGreaterThanOrEqual.ps1 +++ b/src/functions/assert/General/Should-BeGreaterThanOrEqual.ps1 @@ -42,8 +42,24 @@ $collectedInput = Collect-Input -ParameterInput $Actual -PipelineInput $local:Input -IsPipelineInput $MyInvocation.ExpectingInput -UnrollInput $Actual = $collectedInput.Actual - if ((Ensure-ExpectedIsNotCollection $Expected) -gt $Actual) { + $expectedValue = Ensure-ExpectedIsNotCollection $Expected + # The comparison operators throw a native conversion error when $Actual is not a comparable + # scalar, which is exactly what happens when a collection is piped in and unwrapped to [object[]]. + # Catch it so we can show the input hint instead of a cryptic "Could not compare" error. When it + # is not a piped-collection gotcha we have nothing to add, so the original error is rethrown. + $failed = $false + $comparisonError = $null + try { + $failed = $expectedValue -gt $Actual + } + catch { + $comparisonError = $_ + } + if ($comparisonError -or $failed) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected the actual value to be greater than or equal to , but it was not. Actual: " + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($comparisonError -and -not $hint) { throw $comparisonError } + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/General/Should-BeLessThan.ps1 b/src/functions/assert/General/Should-BeLessThan.ps1 index 3187efc37..26a6324e4 100644 --- a/src/functions/assert/General/Should-BeLessThan.ps1 +++ b/src/functions/assert/General/Should-BeLessThan.ps1 @@ -42,8 +42,24 @@ $collectedInput = Collect-Input -ParameterInput $Actual -PipelineInput $local:Input -IsPipelineInput $MyInvocation.ExpectingInput -UnrollInput $Actual = $collectedInput.Actual - if ((Ensure-ExpectedIsNotCollection $Expected) -le $Actual) { + $expectedValue = Ensure-ExpectedIsNotCollection $Expected + # The comparison operators throw a native conversion error when $Actual is not a comparable + # scalar, which is exactly what happens when a collection is piped in and unwrapped to [object[]]. + # Catch it so we can show the input hint instead of a cryptic "Could not compare" error. When it + # is not a piped-collection gotcha we have nothing to add, so the original error is rethrown. + $failed = $false + $comparisonError = $null + try { + $failed = $expectedValue -le $Actual + } + catch { + $comparisonError = $_ + } + if ($comparisonError -or $failed) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected the actual value to be less than , but it was not. Actual: " + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($comparisonError -and -not $hint) { throw $comparisonError } + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/General/Should-BeLessThanOrEqual.ps1 b/src/functions/assert/General/Should-BeLessThanOrEqual.ps1 index be859503d..73802a49b 100644 --- a/src/functions/assert/General/Should-BeLessThanOrEqual.ps1 +++ b/src/functions/assert/General/Should-BeLessThanOrEqual.ps1 @@ -51,8 +51,24 @@ $collectedInput = Collect-Input -ParameterInput $Actual -PipelineInput $local:Input -IsPipelineInput $MyInvocation.ExpectingInput -UnrollInput $Actual = $collectedInput.Actual - if ((Ensure-ExpectedIsNotCollection $Expected) -lt $Actual) { + $expectedValue = Ensure-ExpectedIsNotCollection $Expected + # The comparison operators throw a native conversion error when $Actual is not a comparable + # scalar, which is exactly what happens when a collection is piped in and unwrapped to [object[]]. + # Catch it so we can show the input hint instead of a cryptic "Could not compare" error. When it + # is not a piped-collection gotcha we have nothing to add, so the original error is rethrown. + $failed = $false + $comparisonError = $null + try { + $failed = $expectedValue -lt $Actual + } + catch { + $comparisonError = $_ + } + if ($comparisonError -or $failed) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected the actual value to be less than or equal to , but it was not. Actual: " + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($comparisonError -and -not $hint) { throw $comparisonError } + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/General/Should-BeNull.ps1 b/src/functions/assert/General/Should-BeNull.ps1 index 148f26768..e5fc1c3d4 100644 --- a/src/functions/assert/General/Should-BeNull.ps1 +++ b/src/functions/assert/General/Should-BeNull.ps1 @@ -38,6 +38,8 @@ $Actual = $collectedInput.Actual if ($null -ne $Actual) { $Message = Get-AssertionMessage -Expected $null -Actual $Actual -Because $Because -DefaultMessage "Expected `$null, but got ''." + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/General/Should-BeSame.ps1 b/src/functions/assert/General/Should-BeSame.ps1 index 320df84a0..b02acd46c 100644 --- a/src/functions/assert/General/Should-BeSame.ps1 +++ b/src/functions/assert/General/Should-BeSame.ps1 @@ -61,6 +61,8 @@ $Actual = $collectedInput.Actual if (-not ([object]::ReferenceEquals($Expected, $Actual))) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected , to be the same instance but it was not. Actual: " + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/General/Should-HaveType.ps1 b/src/functions/assert/General/Should-HaveType.ps1 index 056d864e2..41da7b031 100644 --- a/src/functions/assert/General/Should-HaveType.ps1 +++ b/src/functions/assert/General/Should-HaveType.ps1 @@ -42,8 +42,16 @@ $collectedInput = Collect-Input -ParameterInput $Actual -PipelineInput $local:Input -IsPipelineInput $MyInvocation.ExpectingInput -UnrollInput $Actual = $collectedInput.Actual + + # Captured up-front (cheap reference grabs); the diagnostic hint itself is only computed inside + # the failure branch, so there is no cost on the passing path. + $pipelineBuffer = $local:Input + $isPipelineInput = $collectedInput.IsPipelineInput + if ($Actual -isnot $Expected) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected value to have type , but got ." + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $pipelineBuffer -CollectedActual $Actual -IsPipelineInput $isPipelineInput -Expecting ExactType + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/General/Should-NotBe.ps1 b/src/functions/assert/General/Should-NotBe.ps1 index f9b912776..9926e3d73 100644 --- a/src/functions/assert/General/Should-NotBe.ps1 +++ b/src/functions/assert/General/Should-NotBe.ps1 @@ -45,6 +45,8 @@ $Actual = $collectedInput.Actual if ((Ensure-ExpectedIsNotCollection $Expected) -eq $Actual) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected , to be different than the actual value, but they were equal." + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/General/Should-NotBeNull.ps1 b/src/functions/assert/General/Should-NotBeNull.ps1 index 02ffb1098..d8433f2c6 100644 --- a/src/functions/assert/General/Should-NotBeNull.ps1 +++ b/src/functions/assert/General/Should-NotBeNull.ps1 @@ -38,6 +38,8 @@ $Actual = $collectedInput.Actual if ($null -eq $Actual) { $Message = Get-AssertionMessage -Expected $null -Actual $Actual -Because $Because -DefaultMessage "Expected not `$null, but got `$null." + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/General/Should-NotBeSame.ps1 b/src/functions/assert/General/Should-NotBeSame.ps1 index f78160d76..5f30ac605 100644 --- a/src/functions/assert/General/Should-NotBeSame.ps1 +++ b/src/functions/assert/General/Should-NotBeSame.ps1 @@ -56,6 +56,8 @@ $Actual = $collectedInput.Actual if ([object]::ReferenceEquals($Expected, $Actual)) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected , to not be the same instance, but they were the same instance." + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/General/Should-NotHaveType.ps1 b/src/functions/assert/General/Should-NotHaveType.ps1 index f7bfb9910..8a313a502 100644 --- a/src/functions/assert/General/Should-NotHaveType.ps1 +++ b/src/functions/assert/General/Should-NotHaveType.ps1 @@ -46,6 +46,8 @@ $Actual = $collectedInput.Actual if ($Actual -is $Expected) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected value to be of different type than , but got ." + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting ExactType + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/Hashtable/Should-BeHashtable.ps1 b/src/functions/assert/Hashtable/Should-BeHashtable.ps1 index f064fed5c..b66443e7b 100644 --- a/src/functions/assert/Hashtable/Should-BeHashtable.ps1 +++ b/src/functions/assert/Hashtable/Should-BeHashtable.ps1 @@ -91,6 +91,8 @@ if (-not (Is-Dictionary -Value $Actual)) { $Message = Get-AssertionMessage -Expected $null -Actual $Actual -Because $Because -DefaultMessage "Expected a hashtable, but got ." + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } diff --git a/src/functions/assert/String/Should-BeEmptyString.ps1 b/src/functions/assert/String/Should-BeEmptyString.ps1 index 73a609658..410aefb0f 100644 --- a/src/functions/assert/String/Should-BeEmptyString.ps1 +++ b/src/functions/assert/String/Should-BeEmptyString.ps1 @@ -57,6 +57,8 @@ if ($Actual -isnot [String] -or -not [String]::IsNullOrEmpty( $Actual)) { $formattedMessage = Get-AssertionMessage -Actual $Actual -Because $Because -DefaultMessage "Expected a [string] that is empty, but got : " -Pretty + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $formattedMessage = "$formattedMessage`n`nHint: $hint" } Invoke-AssertionFailed -Message $formattedMessage -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/String/Should-BeString.ps1 b/src/functions/assert/String/Should-BeString.ps1 index 8dc53d57f..1dd7d5afa 100644 --- a/src/functions/assert/String/Should-BeString.ps1 +++ b/src/functions/assert/String/Should-BeString.ps1 @@ -102,6 +102,8 @@ function Should-BeString { else { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected , but got ." } + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/String/Should-NotBeEmptyString.ps1 b/src/functions/assert/String/Should-NotBeEmptyString.ps1 index 35b6cb3fe..cae857863 100644 --- a/src/functions/assert/String/Should-NotBeEmptyString.ps1 +++ b/src/functions/assert/String/Should-NotBeEmptyString.ps1 @@ -57,6 +57,8 @@ if ($Actual -isnot [String] -or [String]::IsNullOrEmpty($Actual)) { $formattedMessage = Get-AssertionMessage -Actual $Actual -Because $Because -DefaultMessage "Expected a [string] that is not `$null or empty, but got : " -Pretty + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $formattedMessage = "$formattedMessage`n`nHint: $hint" } Invoke-AssertionFailed -Message $formattedMessage -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/String/Should-NotBeWhiteSpaceString.ps1 b/src/functions/assert/String/Should-NotBeWhiteSpaceString.ps1 index 81e3fe536..164dcca05 100644 --- a/src/functions/assert/String/Should-NotBeWhiteSpaceString.ps1 +++ b/src/functions/assert/String/Should-NotBeWhiteSpaceString.ps1 @@ -58,6 +58,8 @@ if ($Actual -isnot [string] -or [string]::IsNullOrWhiteSpace($Actual)) { $formattedMessage = Get-AssertionMessage -Actual $Actual -Because $Because -DefaultMessage "Expected a [string] that is not `$null, empty or whitespace, but got : " -Pretty + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($hint) { $formattedMessage = "$formattedMessage`n`nHint: $hint" } Invoke-AssertionFailed -Message $formattedMessage -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/Time/Should-BeAfter.ps1 b/src/functions/assert/Time/Should-BeAfter.ps1 index 09d92991e..31ed26ad7 100644 --- a/src/functions/assert/Time/Should-BeAfter.ps1 +++ b/src/functions/assert/Time/Should-BeAfter.ps1 @@ -111,8 +111,23 @@ } } - if ($Actual -le $Expected) { + # A relational operator throws a native conversion error when $Actual is not a comparable single + # value, which is what happens when a multi-item collection is piped in and unwrapped to [object[]]. + # Catch it so we can show the input hint instead of a cryptic "Could not compare" error; when it is + # not a piped-collection gotcha we have nothing to add, so the original error is rethrown. + $failed = $false + $comparisonError = $null + try { + $failed = $Actual -le $Expected + } + catch { + $comparisonError = $_ + } + if ($comparisonError -or $failed) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected the provided [datetime] to be after , but it was before: " + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($comparisonError -and -not $hint) { throw $comparisonError } + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/src/functions/assert/Time/Should-BeBefore.ps1 b/src/functions/assert/Time/Should-BeBefore.ps1 index 858db6581..f9212d2fc 100644 --- a/src/functions/assert/Time/Should-BeBefore.ps1 +++ b/src/functions/assert/Time/Should-BeBefore.ps1 @@ -111,8 +111,23 @@ } } - if ($Actual -ge $Expected) { + # A relational operator throws a native conversion error when $Actual is not a comparable single + # value, which is what happens when a multi-item collection is piped in and unwrapped to [object[]]. + # Catch it so we can show the input hint instead of a cryptic "Could not compare" error; when it is + # not a piped-collection gotcha we have nothing to add, so the original error is rethrown. + $failed = $false + $comparisonError = $null + try { + $failed = $Actual -ge $Expected + } + catch { + $comparisonError = $_ + } + if ($comparisonError -or $failed) { $Message = Get-AssertionMessage -Expected $Expected -Actual $Actual -Because $Because -DefaultMessage "Expected the provided [datetime] to be before , but it was after: " + $hint = Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $Actual -IsPipelineInput $collectedInput.IsPipelineInput -Expecting Scalar + if ($comparisonError -and -not $hint) { throw $comparisonError } + if ($hint) { $Message = "$Message`n`nHint: $hint" } Invoke-AssertionFailed -Message $Message -CallerCmdlet $PSCmdlet } Set-AssertionPassResult diff --git a/tst/functions/assert/Common/Ensure-ExpectedIsNotCollection.Tests.ps1 b/tst/functions/assert/Common/Ensure-ExpectedIsNotCollection.Tests.ps1 index 93ee5ecd5..b242aa382 100644 --- a/tst/functions/assert/Common/Ensure-ExpectedIsNotCollection.Tests.ps1 +++ b/tst/functions/assert/Common/Ensure-ExpectedIsNotCollection.Tests.ps1 @@ -9,7 +9,7 @@ InPesterModuleScope { It "Given a collection it throws correct message" { $err = { Ensure-ExpectedIsNotCollection -InputObject @() } | Verify-Throw - $err.Exception.Message | Verify-Equal 'You provided a collection to the -Expected parameter. Using a collection on the -Expected side is not allowed by this assertion, because it leads to unexpected behavior. Please use Should-Any, Should-All or some other specialized collection assertion.' + $err.Exception.Message | Verify-Equal 'You provided a collection to the -Expected parameter. Using a collection on the -Expected side is not allowed by this assertion, because it leads to unexpected behavior. To compare collections use Should-BeCollection, or a more specialized collection assertion such as Should-Any or Should-All.' } diff --git a/tst/functions/assert/Common/Get-AssertionGotcha.Tests.ps1 b/tst/functions/assert/Common/Get-AssertionGotcha.Tests.ps1 new file mode 100644 index 000000000..bc7b688e9 --- /dev/null +++ b/tst/functions/assert/Common/Get-AssertionGotcha.Tests.ps1 @@ -0,0 +1,247 @@ +Set-StrictMode -Version Latest + +InPesterModuleScope { + Describe "Get-AssertionGotcha" { + # Get-AssertionGotcha is the single home for the "you piped the wrong shape into me" hints. + # It is only ever called from an assertion's failure branch and never changes pass/fail. These + # tests drive it through the *same* input-collection machinery the real assertions use: two + # fake assertions, one that unrolls the pipeline (single-value / type assertions such as + # Should-Be and Should-HaveType) and one that does not (collection assertions such as + # Should-BeCollection and Should-All). Driving it this way means the pipeline-recovery trick + # sees realistic invocation info -- in particular whether the caller unrolled its input. + BeforeAll { + function Invoke-SingleValueAssertion { + # Mirrors how Should-Be / Should-HaveType collect input: the pipeline is unrolled, so a + # piped collection reaches the assertion already unwrapped to a scalar or [Object[]]. + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline)] $Actual, + [Parameter(Mandatory)][ValidateSet('Scalar', 'ExactType')][string] $Expecting + ) + $collected = Collect-Input -ParameterInput $Actual -PipelineInput $local:Input -IsPipelineInput $MyInvocation.ExpectingInput -UnrollInput + Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $collected.Actual -IsPipelineInput $collected.IsPipelineInput -Expecting $Expecting + } + + function Invoke-CollectionAssertion { + # Mirrors how Should-BeCollection / Should-All collect input: the pipeline is kept as a + # collection, so only a non-collection left-hand side is a surprise. + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline)] $Actual, + [Parameter(Mandatory)][ValidateSet('Collection', 'CollectionItems')][string] $Expecting + ) + $collected = Collect-Input -ParameterInput $Actual -PipelineInput $local:Input -IsPipelineInput $MyInvocation.ExpectingInput + Get-AssertionGotcha -Cmdlet $PSCmdlet -Buffer $local:Input -CollectedActual $collected.Actual -IsPipelineInput $collected.IsPipelineInput -Expecting $Expecting + } + + # The closing advice is constant per family; kept here so the wording lives in one place. + $scalarAdvice = 'To assert on a collection use Should-BeCollection or Should-BeEquivalent; to assert on a single value pass it as the -Actual argument instead of piping it, e.g. -Actual $value.' + $typeAdvice = 'To assert the type of a collection, pass it as the -Actual argument instead of piping it, e.g. -Actual $value.' + } + + Context "Expecting Scalar - a single-value assertion (e.g. Should-Be) inspects the unwrapped value" { + # The Scalar hint is about a collection being *collapsed and inspected as one value*. It + # does not care whether the type changed, so even an [Object[]] that stays an [Object[]] is + # worth pointing out -- contrast with ExactType below. + It "hints that a single-item was unwrapped to its one element" -ForEach @( + @{ Value = @(1); Piped = '[Object[]]'; Seen = '[int]' } + @{ Value = [int[]]@(1); Piped = '[int[]]'; Seen = '[int]' } + @{ Value = @($null); Piped = '[Object[]]'; Seen = '[null]' } + ) { + $hint = $Value | Invoke-SingleValueAssertion -Expecting Scalar + $hint | Verify-Equal "You piped a $Piped into a single-value assertion, but the pipeline unwraps a single-item collection to its one element, so the assertion inspected that single $Seen instead of the collection. $scalarAdvice" + } + + It "hints that a multi-item was re-collected into a single " -ForEach @( + @{ Value = @(1, 2); Piped = '[Object[]]'; Seen = '[Object[]]' } + @{ Value = [int[]]@(1, 2); Piped = '[int[]]'; Seen = '[Object[]]' } + @{ Value = @($null, $null); Piped = '[Object[]]'; Seen = '[Object[]]' } + ) { + $hint = $Value | Invoke-SingleValueAssertion -Expecting Scalar + $hint | Verify-Equal "You piped a $Piped into a single-value assertion, but the pipeline streams a multi-item collection and re-collects it into a single $Seen, so the whole collection was inspected as one value. $scalarAdvice" + } + + It "stays quiet for , which the pipeline does not collapse into a different value" -ForEach @( + @{ Description = 'a genuine scalar'; Value = 5 } + @{ Description = 'a piped $null'; Value = $null } + @{ Description = 'a single dictionary'; Value = @{ a = 1 } } + @{ Description = 'an empty array'; Value = @() } + @{ Description = 'an empty typed array'; Value = [string[]]@() } + ) { + $hint = $Value | Invoke-SingleValueAssertion -Expecting Scalar + $hint | Verify-Null + } + + It "stays quiet for a lazily streamed range, which has no nameable container" { + $hint = 1..3 | Invoke-SingleValueAssertion -Expecting Scalar + $hint | Verify-Null + } + + It "stays quiet for passed by -Actual instead of piped" -ForEach @( + @{ Description = 'a collection'; Value = @(1, 2) } + @{ Description = 'a scalar'; Value = 5 } + ) { + $hint = Invoke-SingleValueAssertion -Actual $Value -Expecting Scalar + $hint | Verify-Null + } + } + + Context "Expecting ExactType - a type assertion (e.g. Should-HaveType) checks the unwrapped value's type" { + # The ExactType hint is about the *type being lost* to unwrapping. So unlike Scalar it has a + # "the observable type did not change" guard: a piped [Object[]] re-collected straight back + # into an [Object[]] is a genuine type comparison, not a gotcha. + It "hints that a single-item was unwrapped to a single " -ForEach @( + @{ Value = @(1); Piped = '[Object[]]'; Seen = '[int]' } + @{ Value = [int[]]@(1); Piped = '[int[]]'; Seen = '[int]' } + @{ Value = @($null); Piped = '[Object[]]'; Seen = '[null]' } + ) { + $hint = $Value | Invoke-SingleValueAssertion -Expecting ExactType + $hint | Verify-Equal "You piped a $Piped into a type assertion, but the pipeline unwraps a single-item collection to its one element, so the assertion saw a single $Seen, not the $Piped you piped. $typeAdvice" + } + + It "hints that a multi-item was re-collected as when that changes the type" -ForEach @( + @{ Value = [int[]]@(1, 2); Piped = '[int[]]'; Seen = '[Object[]]' } + @{ Value = [string[]]('a', 'b'); Piped = '[string[]]'; Seen = '[Object[]]' } + ) { + $hint = $Value | Invoke-SingleValueAssertion -Expecting ExactType + $hint | Verify-Equal "You piped a $Piped into a type assertion, but the pipeline streams a multi-item collection and re-collects it as $Seen, so the assertion saw $Seen, not the $Piped you piped. $typeAdvice" + } + + It "stays quiet for , where unwrapping did not change the observable type" -ForEach @( + # A multi-item [Object[]] streams and re-collects straight back into an [Object[]], so + # the type the assertion sees is the very type that was piped -- nothing was lost. + @{ Description = 'a multi-item [Object[]] of values'; Value = @(1, 2) } + @{ Description = 'a multi-item [Object[]] of $null'; Value = @($null, $null) } + ) { + $hint = $Value | Invoke-SingleValueAssertion -Expecting ExactType + $hint | Verify-Null + } + + It "stays quiet for , where no collection was unwrapped" -ForEach @( + @{ Description = 'a genuine scalar'; Value = 5 } + @{ Description = 'a piped $null'; Value = $null } + @{ Description = 'a single dictionary'; Value = @{ a = 1 } } + @{ Description = 'an empty array'; Value = @() } + @{ Description = 'an empty typed array'; Value = [string[]]@() } + ) { + $hint = $Value | Invoke-SingleValueAssertion -Expecting ExactType + $hint | Verify-Null + } + + It "stays quiet when a collection keeps its real type by being passed by -Actual" { + $hint = Invoke-SingleValueAssertion -Actual ([int[]]@(1, 2)) -Expecting ExactType + $hint | Verify-Null + } + } + + Context "Expecting Collection - a whole-collection assertion (e.g. Should-BeCollection) compares the input as one collection" { + # Here a lone scalar, a $null, or a dictionary is the wrong container, so each is worth a + # hint; a genuine collection is exactly what the assertion wants and stays quiet. + It "hints a piped , which is treated as a single item" -ForEach @( + @{ Description = 'scalar'; Value = 5; Hint = 'You piped a single [int] into a collection assertion. It is treated as a single item. To assert on a one-item collection wrap it as ,$actual, or use Should-Be for a scalar value.' } + @{ Description = '$null'; Value = $null; Hint = 'You piped $null into a collection assertion. It is treated as a single $null item, not an empty collection. Use @() to represent an empty collection.' } + @{ Description = 'dictionary'; Value = @{ a = 1 }; Hint = 'You piped a single [hashtable] into a collection assertion. PowerShell treats a dictionary as a single object, not a collection. To assert on it as a hashtable use Should-BeHashtable, or compare its contents with Should-BeEquivalent.' } + ) { + $hint = $Value | Invoke-CollectionAssertion -Expecting Collection + $hint | Verify-Equal $Hint + } + + It "hints a passed by -Actual, which is not a collection" -ForEach @( + @{ Description = 'scalar'; Value = 5; Hint = '-Actual is a single [int], which is not a collection. It is treated as a single item. To assert on a one-item collection wrap it as ,$actual, or use Should-Be for a scalar value.' } + @{ Description = '$null'; Value = $null; Hint = '-Actual is $null, which is not a collection. It is treated as a single $null item, not an empty collection. Use @() to represent an empty collection.' } + @{ Description = 'dictionary'; Value = @{ a = 1 }; Hint = '-Actual is a single [hashtable], which is not a collection. PowerShell treats a dictionary as a single object, not a collection. To assert on it as a hashtable use Should-BeHashtable, or compare its contents with Should-BeEquivalent.' } + ) { + $hint = Invoke-CollectionAssertion -Actual $Value -Expecting Collection + $hint | Verify-Equal $Hint + } + + It "stays quiet for a genuine collection, " -ForEach @( + @{ Description = 'piped'; Piped = $true } + @{ Description = 'by -Actual'; Piped = $false } + ) { + $hint = if ($Piped) { @(1, 2) | Invoke-CollectionAssertion -Expecting Collection } else { Invoke-CollectionAssertion -Actual @(1, 2) -Expecting Collection } + $hint | Verify-Null + } + + It "stays quiet for a lazily streamed range" { + $hint = 1..3 | Invoke-CollectionAssertion -Expecting Collection + $hint | Verify-Null + } + } + + Context "Expecting CollectionItems - an item-wise assertion (e.g. Should-All) iterates the input" { + # A lone scalar or $null is a perfectly valid one-item collection to iterate, so only a + # dictionary -- which PowerShell silently passes through as a single, non-iterated object -- + # is a genuine gotcha here. + It "hints a piped dictionary, which PowerShell passes through as a single un-iterated item" { + $hint = @{ a = 1 } | Invoke-CollectionAssertion -Expecting CollectionItems + $hint | Verify-Equal 'You piped a single [hashtable] into a collection assertion. PowerShell treats a dictionary as a single object, so it is passed through as one item instead of being iterated. Enumerate it first, e.g. with $actual.GetEnumerator(), or its .Keys or .Values, or assert on it directly with Should-BeHashtable.' + } + + It "hints a dictionary passed by -Actual" { + $hint = Invoke-CollectionAssertion -Actual @{ a = 1 } -Expecting CollectionItems + $hint | Verify-Equal '-Actual is a single [hashtable], which is not a collection. PowerShell treats a dictionary as a single object, so it is passed through as one item instead of being iterated. Enumerate it first, e.g. with $actual.GetEnumerator(), or its .Keys or .Values, or assert on it directly with Should-BeHashtable.' + } + + It "stays quiet for , a valid item or collection to iterate" -ForEach @( + @{ Description = 'a piped scalar'; Value = 5 } + @{ Description = 'a piped $null'; Value = $null } + @{ Description = 'a piped collection'; Value = @(1, 2) } + ) { + $hint = $Value | Invoke-CollectionAssertion -Expecting CollectionItems + $hint | Verify-Null + } + + It "stays quiet for passed by -Actual" -ForEach @( + @{ Description = 'a scalar'; Value = 5 } + @{ Description = 'a collection'; Value = @(1, 2) } + @{ Description = '$null'; Value = $null } + ) { + $hint = Invoke-CollectionAssertion -Actual $Value -Expecting CollectionItems + $hint | Verify-Null + } + } + } +} + +Describe "Assertions surface the pipeline-unwrap hint" { + # The block above proves Get-AssertionGotcha produces the right wording for every input shape. + # This block proves the real, public assertions actually wire it up: each one is made to fail by + # piping a collection into it, and we check that the failure message carries the matching hint. + # That is the "make sure we use them in the right places, with the correct invocation info" half + # of the contract -- if an assertion forgot to call Get-AssertionGotcha, or called it without the + # pipeline info, these would go quiet. We only match a fragment here on purpose; the exact wording + # is asserted once, centrally, in the Get-AssertionGotcha tests above. + + It " appends the multi-item hint when a multi-item collection is piped" -ForEach @( + @{ Assertion = 'Should-Be'; Fail = { @(1, 2) | Should-Be 1 } } + @{ Assertion = 'Should-BeGreaterThan'; Fail = { @(1, 2) | Should-BeGreaterThan 1 } } + @{ Assertion = 'Should-BeGreaterThanOrEqual'; Fail = { @(1, 2) | Should-BeGreaterThanOrEqual 1 } } + @{ Assertion = 'Should-BeLessThan'; Fail = { @(1, 2) | Should-BeLessThan 1 } } + @{ Assertion = 'Should-BeLessThanOrEqual'; Fail = { @(1, 2) | Should-BeLessThanOrEqual 1 } } + @{ Assertion = 'Should-BeSame'; Fail = { @(1, 2) | Should-BeSame ([PSCustomObject]@{}) } } + @{ Assertion = 'Should-BeNull'; Fail = { @(1, 2) | Should-BeNull } } + @{ Assertion = 'Should-BeString'; Fail = { @(1, 2) | Should-BeString 'x' } } + @{ Assertion = 'Should-BeEmptyString'; Fail = { @(1, 2) | Should-BeEmptyString } } + @{ Assertion = 'Should-BeHashtable'; Fail = { @(1, 2) | Should-BeHashtable } } + @{ Assertion = 'Should-NotHaveType'; Fail = { [int[]](1, 2) | Should-NotHaveType ([object[]]) } } + ) { + $err = $Fail | Verify-AssertionFailed + $err.Exception.Message | Verify-Like '*Hint: You piped a*streams a multi-item collection and re-collects it*' + } + + It " appends the single-item hint when a single-item collection is piped" -ForEach @( + @{ Assertion = 'Should-NotBe'; Fail = { @(1) | Should-NotBe 1 } } + @{ Assertion = 'Should-NotBeNull'; Fail = { @($null) | Should-NotBeNull } } + @{ Assertion = 'Should-NotBeSame'; Fail = { $o = [PSCustomObject]@{}; @($o) | Should-NotBeSame $o } } + @{ Assertion = 'Should-BeTrue'; Fail = { @($false) | Should-BeTrue } } + @{ Assertion = 'Should-BeFalse'; Fail = { @($true) | Should-BeFalse } } + @{ Assertion = 'Should-BeTruthy'; Fail = { @(0) | Should-BeTruthy } } + @{ Assertion = 'Should-BeFalsy'; Fail = { @(1) | Should-BeFalsy } } + @{ Assertion = 'Should-NotBeEmptyString'; Fail = { @('') | Should-NotBeEmptyString } } + @{ Assertion = 'Should-NotBeWhiteSpaceString'; Fail = { @(' ') | Should-NotBeWhiteSpaceString } } + ) { + $err = $Fail | Verify-AssertionFailed + $err.Exception.Message | Verify-Like '*Hint: You piped a*unwraps a single-item collection to its one element*' + } +} diff --git a/tst/functions/assert/General/Should-BeGreaterThan.Tests.ps1 b/tst/functions/assert/General/Should-BeGreaterThan.Tests.ps1 index 748442a89..4d0a882a8 100644 --- a/tst/functions/assert/General/Should-BeGreaterThan.Tests.ps1 +++ b/tst/functions/assert/General/Should-BeGreaterThan.Tests.ps1 @@ -71,9 +71,11 @@ Describe "Should-BeGreaterThan" { } } - It "Fails for array input even if the last item is greater than then expected value" { - $err = { 1, 2, 3, 4 | Should-BeGreaterThan 3 } | Verify-Throw - $err.Exception | Verify-Type ([System.Management.Automation.RuntimeException]) + It "Fails with an input hint for array input, which the pipeline unwraps before comparing" { + # A piped multi-item collection is unwrapped to [Object[]], which the comparison operators + # cannot compare. Instead of a cryptic native error the assertion now fails with a hint. #2801 + $err = { 1, 2, 3, 4 | Should-BeGreaterThan 3 } | Verify-AssertionFailed + $err.Exception.Message | Verify-Like '*Hint: You piped a*into a single-value assertion*' } Context "Validate messages" { diff --git a/tst/functions/assert/General/Should-BeGreaterThanOrEqual.Tests.ps1 b/tst/functions/assert/General/Should-BeGreaterThanOrEqual.Tests.ps1 index 5096a63da..97444099e 100644 --- a/tst/functions/assert/General/Should-BeGreaterThanOrEqual.Tests.ps1 +++ b/tst/functions/assert/General/Should-BeGreaterThanOrEqual.Tests.ps1 @@ -71,9 +71,11 @@ Describe "Should-BeGreaterThanOrEqual" { } } - It "Fails for array input even if the last item is greater than then expected value" { - $err = { 1, 2, 3, 4 | Should-BeGreaterThanOrEqual 3 } | Verify-Throw - $err.Exception | Verify-Type ([System.Management.Automation.RuntimeException]) + It "Fails with an input hint for array input, which the pipeline unwraps before comparing" { + # A piped multi-item collection is unwrapped to [Object[]], which the comparison operators + # cannot compare. Instead of a cryptic native error the assertion now fails with a hint. #2801 + $err = { 1, 2, 3, 4 | Should-BeGreaterThanOrEqual 3 } | Verify-AssertionFailed + $err.Exception.Message | Verify-Like '*Hint: You piped a*into a single-value assertion*' } Context "Validate messages" { diff --git a/tst/functions/assert/General/Should-BeLessThan.Tests.ps1 b/tst/functions/assert/General/Should-BeLessThan.Tests.ps1 index 44517fd73..6bb5964eb 100644 --- a/tst/functions/assert/General/Should-BeLessThan.Tests.ps1 +++ b/tst/functions/assert/General/Should-BeLessThan.Tests.ps1 @@ -71,9 +71,11 @@ Describe "Should-BeLessThan" { } } - It "Fails for array input even if the last item is less than the expected value" { - $err = { 4, 3, 2, 1 | Should-BeLessThan 3 } | Verify-Throw - $err.Exception | Verify-Type ([System.Management.Automation.RuntimeException]) + It "Fails with an input hint for array input, which the pipeline unwraps before comparing" { + # A piped multi-item collection is unwrapped to [Object[]], which the comparison operators + # cannot compare. Instead of a cryptic native error the assertion now fails with a hint. #2801 + $err = { 4, 3, 2, 1 | Should-BeLessThan 3 } | Verify-AssertionFailed + $err.Exception.Message | Verify-Like '*Hint: You piped a*into a single-value assertion*' } Context "Validate messages" { diff --git a/tst/functions/assert/General/Should-BeLessThanOrEqual.Tests.ps1 b/tst/functions/assert/General/Should-BeLessThanOrEqual.Tests.ps1 index 92501fa24..686f4e2b6 100644 --- a/tst/functions/assert/General/Should-BeLessThanOrEqual.Tests.ps1 +++ b/tst/functions/assert/General/Should-BeLessThanOrEqual.Tests.ps1 @@ -71,9 +71,11 @@ Describe "Should-BeLessThanOrEqual" { } } - It "Fails for array input even if the last item is less than then expected value" { - $err = { 4, 3, 2, 1 | Should-BeLessThanOrEqual 3 } | Verify-Throw - $err.Exception | Verify-Type ([System.Management.Automation.RuntimeException]) + It "Fails with an input hint for array input, which the pipeline unwraps before comparing" { + # A piped multi-item collection is unwrapped to [Object[]], which the comparison operators + # cannot compare. Instead of a cryptic native error the assertion now fails with a hint. #2801 + $err = { 4, 3, 2, 1 | Should-BeLessThanOrEqual 3 } | Verify-AssertionFailed + $err.Exception.Message | Verify-Like '*Hint: You piped a*into a single-value assertion*' } Context "Validate messages" { diff --git a/tst/functions/assert/General/Should-HaveType.Tests.ps1 b/tst/functions/assert/General/Should-HaveType.Tests.ps1 index c4d66ec01..804c73700 100644 --- a/tst/functions/assert/General/Should-HaveType.Tests.ps1 +++ b/tst/functions/assert/General/Should-HaveType.Tests.ps1 @@ -12,4 +12,47 @@ Describe "Should-HaveType" { It "Can be called with positional parameters" { { Should-HaveType ([string]) 1 } | Verify-AssertionFailed } + + It "Given a collection passed by -Actual it checks the collection type" -ForEach @( + @{ Value = [string[]]('a', 'b') } + @{ Value = [string[]]('a') } + ) { + Should-HaveType -Actual $Value -Expected ([string[]]) + } +} + +Describe "Should-HaveType input hint" { + # Should-HaveType is where the pipeline-unwrap hint started (#2801). The exact wording for every + # input shape is asserted centrally in Get-AssertionGotcha.Tests.ps1; here we only smoke-test that + # the real assertion wires it up -- it asks for the ExactType hint and passes the pipeline info, so + # a piped collection is explained while a value passed by -Actual (or a genuine scalar) stays quiet. + It "Hints how the pipeline unwrapped a piped multi-item collection" { + $err = { [string[]]('a', 'b') | Should-HaveType ([string[]]) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Like '*Hint: You piped a*streams a multi-item collection and re-collects it*' + } + + It "Hints how the pipeline unwrapped a piped single-item collection" { + $err = { [string[]]('a') | Should-HaveType ([string[]]) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Like '*Hint: You piped a*unwraps a single-item collection to its one element*' + } + + It "Does not hint when the pipeline did not change the observable type of a piped collection" { + # A multi-item [Object[]] is streamed and re-collected straight back into an [Object[]], so the + # type the assertion sees is the very type that was piped -- nothing was lost to unwrapping. + $err = { @(1, 2) | Should-HaveType ([hashtable]) } | Verify-AssertionFailed + ($err.Exception.Message -notlike '*Hint:*') | Verify-True + } + + It "Tells a genuinely piped scalar apart from an unwrapped single-item collection" { + # Both '1 | ...' and a piped [string[]]('a') reach the assertion as a single scalar, but only + # the collection was unwrapped, so a real scalar (it keeps its type) gets no hint. #2801. + $err = { 1 | Should-HaveType ([string]) } | Verify-AssertionFailed + ($err.Exception.Message -notlike '*Hint:*') | Verify-True + } + + It "Does not hint when a collection is passed by -Actual" { + $value = [string[]]('a', 'b') + $err = { Should-HaveType -Actual $value -Expected ([int[]]) } | Verify-AssertionFailed + ($err.Exception.Message -notlike '*Hint:*') | Verify-True + } } diff --git a/tst/functions/assert/Time/Should-BeAfter.Tests.ps1 b/tst/functions/assert/Time/Should-BeAfter.Tests.ps1 index e4d9c719b..dad009f99 100644 --- a/tst/functions/assert/Time/Should-BeAfter.Tests.ps1 +++ b/tst/functions/assert/Time/Should-BeAfter.Tests.ps1 @@ -64,6 +64,13 @@ Describe "Should-BeAfter" { $err.Exception.Message | Verify-Like '*because I said so*' } + It "Fails with an input hint when a multi-item collection is piped, which the pipeline unwraps before comparing" { + # A piped multi-item collection is unwrapped to [Object[]], which cannot be compared to a + # [datetime]. Instead of a cryptic native error the assertion now fails with a hint. #2801 + $err = { @([DateTime]::Now.AddDays(-1), [DateTime]::Now.AddDays(-1)) | Should-BeAfter -Expected ([DateTime]::Now) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Like '*Hint: You piped a*into a single-value assertion*' + } + It "Can check file creation date" { New-Item -ItemType Directory -Path "TestDrive:\MyFolder" -Force | Out-Null $path = "TestDrive:\MyFolder\test.txt" diff --git a/tst/functions/assert/Time/Should-BeBefore.Tests.ps1 b/tst/functions/assert/Time/Should-BeBefore.Tests.ps1 index 2ed904e7d..f2f7c06bc 100644 --- a/tst/functions/assert/Time/Should-BeBefore.Tests.ps1 +++ b/tst/functions/assert/Time/Should-BeBefore.Tests.ps1 @@ -64,6 +64,13 @@ Describe "Should-BeBefore" { $err.Exception.Message | Verify-Like '*because I said so*' } + It "Fails with an input hint when a multi-item collection is piped, which the pipeline unwraps before comparing" { + # A piped multi-item collection is unwrapped to [Object[]], which cannot be compared to a + # [datetime]. Instead of a cryptic native error the assertion now fails with a hint. #2801 + $err = { @([DateTime]::Now.AddDays(1), [DateTime]::Now.AddDays(1)) | Should-BeBefore -Expected ([DateTime]::Now) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Like '*Hint: You piped a*into a single-value assertion*' + } + It "Can check file creation date" { New-Item -ItemType Directory -Path "TestDrive:\MyFolder" -Force | Out-Null $path = "TestDrive:\MyFolder\test.txt"