diff --git a/.github/skills/psframework-testing/SKILL.md b/.github/skills/psframework-testing/SKILL.md
new file mode 100644
index 00000000..5885e860
--- /dev/null
+++ b/.github/skills/psframework-testing/SKILL.md
@@ -0,0 +1,70 @@
+---
+name: psframework-testing
+description: 'Write and run Pester tests for PSFramework commands and C#-backed cmdlets. Use for new test files, updating assertions, validating LogEntry metadata, and running tests against the local workspace module instead of the installed module.'
+argument-hint: 'Command/file under test, expected behavior, and scope (single test file or broader run)'
+user-invocable: true
+---
+
+# PSFramework Testing
+
+## When To Use
+- Add or update tests in PSFramework test folders, especially under PSFramework/tests/functions.
+- Validate behavior of commands implemented in C# under library/PSFramework or behaviour of commands implemented in script under PSFramework/functions or PSFramework/internal/functions.
+- Run Pester verification in a way that guarantees the local workspace module is used.
+
+## Key Rules
+1. Match repository test style.
+- Use Describe, Context, It with clear behavioral names.
+- Prefer BeforeEach for Clear-PSFMessage when tests inspect message queues.
+- Keep assertions specific and stable.
+
+2. Verify behavior through public surfaces.
+- Prefer Get-PSFMessage and command outputs instead of internal/private state.
+- When verifying command tests through the messages they write, validate LogEntry shape and metadata (Message, LogMessage, Level, Tags, Data, TargetObject, Runspace, FunctionName, ModuleName, File, Line, CallStack, ErrorRecord).
+
+3. Always test against the workspace module build.
+- Do not rely on an installed PSFramework module for verification.
+- Import from PSFramework/PSFramework.psd1 first.
+- If the interactive session has assembly conflicts, run tests in an isolated job process.
+
+## Workflow
+1. Inspect implementation and existing tests.
+- For C#-based commands, read the command implementation in library/PSFramework/Commands.
+- For script-based commands, read the command implementation in PSFramework/functions or PSFramework/internal/functions, including subfolders.
+- Read related models in library/PSFramework/Message when asserting logged metadata.
+- Mirror neighboring test layout in PSFramework/tests/functions//.
+
+2. Implement or update tests.
+- Create a focused test file named .Tests.ps1 under the matching function area.
+- Add contract tests (parameters/sets) where useful.
+- Add behavior tests for happy path, metadata persistence, and edge cases.
+- Add at least one behavior test per Parameter Set.
+
+3. Run tests with local-manifest import.
+- Preferred command for single-file validation, replacing the path provided to Invoke-Pester with the path to the respective tests file being generated or updated:
+
+```powershell
+$job = Start-Job -ScriptBlock {
+ Import-Module "c:\Code\github\psframework\PSFramework\PSFramework.psd1" -Force
+ $path = (Get-Module PSFramework).Path
+ $result = Invoke-Pester -Path "c:\Code\github\psframework\PSFramework\tests\functions\message\Write-PSFMessage.Tests.ps1" -PassThru
+ [PSCustomObject]@{
+ ModulePath = $path
+ Passed = $result.PassedCount
+ Failed = $result.FailedCount
+ Total = $result.TotalCount
+ }
+}
+Wait-Job $job
+Receive-Job $job
+Remove-Job $job
+```
+
+4. Confirm module provenance in output.
+- Verify ModulePath points to the workspace module path, not user/profile module directories.
+
+## Done Criteria
+- Tests are readable and consistent with nearby repository tests.
+- Assertions verify externally observable behavior.
+- Verification run is executed with local module import from PSFramework.psd1.
+- Report includes pass/fail counts and loaded module path.
diff --git a/PSFramework/PSFramework.psd1 b/PSFramework/PSFramework.psd1
index b59b6e46..6073f77d 100644
--- a/PSFramework/PSFramework.psd1
+++ b/PSFramework/PSFramework.psd1
@@ -4,7 +4,7 @@
RootModule = 'PSFramework.psm1'
# Version number of this module.
- ModuleVersion = '1.14.450'
+ ModuleVersion = '1.14.454'
# ID used to uniquely identify this module
GUID = '8028b914-132b-431f-baa9-94a6952f21ff'
diff --git a/PSFramework/bin/PSFramework.dll b/PSFramework/bin/PSFramework.dll
index d4339c84..2d8de705 100644
Binary files a/PSFramework/bin/PSFramework.dll and b/PSFramework/bin/PSFramework.dll differ
diff --git a/PSFramework/bin/PSFramework.pdb b/PSFramework/bin/PSFramework.pdb
index 5c3b9835..fd54525a 100644
Binary files a/PSFramework/bin/PSFramework.pdb and b/PSFramework/bin/PSFramework.pdb differ
diff --git a/PSFramework/bin/PSFramework.xml b/PSFramework/bin/PSFramework.xml
index d27b9429..91457488 100644
--- a/PSFramework/bin/PSFramework.xml
+++ b/PSFramework/bin/PSFramework.xml
@@ -237,6 +237,14 @@
The key of the entry to remove
+
+
+ Verifies whether the key exists in the cache.
+ Expired entries are not considered.
+
+ The key to check
+ Whether the key is in the cache
+
Read or write an entry in the cache
@@ -11530,6 +11538,58 @@
The ScriptBlock to convert
+
+
+ Wraps a timespan and presents it in a human-friendly readable way.
+
+
+
+
+ How many digits after the dot are used for representing time
+
+
+
+
+ Creates a HumanizedTimeSpan from a TimeSpan object (not the hardest challenge)
+
+ The timespan object to accept
+
+
+
+ Creates a HumanizedTimeSpan from integer, assuming it to mean seconds
+
+ The seconds to run
+
+
+
+ Creates a HumanizedTimeSpan from a string object
+
+ The string to interpret
+
+
+
+ Creates a HumanizedTimeSpan from any kind of object it has been taught to understand
+
+ The object to interpret
+
+
+
+ Creates extra-nice timespan formats
+
+ Humanly readable timespans
+
+
+
+ Implicitly converts a DbaTimeSpan object into a TimeSpan object
+
+ The original object to revert
+
+
+
+ Implicitly converts a TimeSpan object into a DbaTimeSpan object
+
+ The original object to wrap
+
Static class that holds useful regex patterns, ready for use
diff --git a/PSFramework/changelog.md b/PSFramework/changelog.md
index ee198f5f..4595f905 100644
--- a/PSFramework/changelog.md
+++ b/PSFramework/changelog.md
@@ -1,5 +1,12 @@
# CHANGELOG
+## 1.14.454 (2026-07-01)
+
+- New: Type PSFramework.Utility.HumanizedTimeSpan - a human-friendly duration display alternative.
+- Fix: PSFCache - The ContainsKey method will always return $false
+- Fix: Write-PSFMessage - Error when using `-ErrorStack` parameter: "The input string '%CALLSTACK LINE%' was not in a correct format."
+- Fix: Import-PSFJson - fails to process boolean values correctly
+
## 1.14.450 (2026-06-19)
- Fix: Get-PSFMessage - may fail in a runspace situation
diff --git a/PSFramework/functions/data/Import-PSFJson.ps1 b/PSFramework/functions/data/Import-PSFJson.ps1
index 02e3d49e..cd26d9d1 100644
--- a/PSFramework/functions/data/Import-PSFJson.ps1
+++ b/PSFramework/functions/data/Import-PSFJson.ps1
@@ -70,6 +70,7 @@
'System.Int64'
'System.Double'
'System.Bool'
+ 'System.Boolean'
'System.DateTime'
)
}
@@ -162,8 +163,10 @@
$jsonTypes = @(
'System.String'
'System.Int32'
+ 'System.Int64'
'System.Double'
'System.Bool'
+ 'System.Boolean'
'System.DateTime'
)
diff --git a/PSFramework/internal/loggingProviders/eventlog.provider.ps1 b/PSFramework/internal/loggingProviders/eventlog.provider.ps1
index 33710914..a129b6c0 100644
--- a/PSFramework/internal/loggingProviders/eventlog.provider.ps1
+++ b/PSFramework/internal/loggingProviders/eventlog.provider.ps1
@@ -99,8 +99,8 @@
$source = Get-ConfigValue -Name Source
$script:loggingID = [System.Guid]::NewGuid()
- $startingMessage = "Starting new logging provider! | Process ID: $PID | Instance Name: $($script:Instance.Name) | Logging ID: $loggingID"
- $data = $startingMessage, $PID, $script:Instance.Name, $loggingID
+ $startingMessage = "Starting new logging provider! | Process ID: $PID | Instance Name: $($script:Instance.Name) | Logging ID: $($script:loggingID)"
+ $data = $startingMessage, $PID, $script:Instance.Name, $script:loggingID
try {
Write-LogEntry -LogName $logName -Source $source -Type Information -Category (Get-ConfigValue -Name Category) -EventId 999 -Data $data
$script:logName = $logName
diff --git a/PSFramework/internal/loggingProviders/filesystem.provider.ps1 b/PSFramework/internal/loggingProviders/filesystem.provider.ps1
index 359be835..5e56a0c0 100644
--- a/PSFramework/internal/loggingProviders/filesystem.provider.ps1
+++ b/PSFramework/internal/loggingProviders/filesystem.provider.ps1
@@ -105,9 +105,9 @@ $start_event = {
}
else { $filesystem_root = Get-Item -Path $filesystem_path }
- try { [int]$filesystem_num_Error = (Get-ChildItem -Path $filesystem_path.FullName -Filter "$($env:ComputerName)_$($pid)_error_*.xml" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty Name | Select-String -Pattern "(\d+)" -AllMatches).Matches[1].Value }
+ try { [int]$filesystem_num_Error = (Get-ChildItem -Path $filesystem_root.FullName -Filter "$($env:ComputerName)_$($pid)_error_*.xml" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty Name | Select-String -Pattern "(\d+)" -AllMatches).Matches[-1].Value }
catch { }
- try { [int]$filesystem_num_Message = (Get-ChildItem -Path $filesystem_path.FullName -Filter "$($env:ComputerName)_$($pid)_message_*.log" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty Name | Select-String -Pattern "(\d+)" -AllMatches).Matches[1].Value }
+ try { [int]$filesystem_num_Message = (Get-ChildItem -Path $filesystem_root.FullName -Filter "$($env:ComputerName)_$($pid)_message_*.log" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty Name | Select-String -Pattern "(\d+)" -AllMatches).Matches[-1].Value }
catch { }
if (-not ($filesystem_num_Error)) { $filesystem_num_Error = 0 }
if (-not ($filesystem_num_Message)) { $filesystem_num_Message = 0 }
diff --git a/PSFramework/tests/functions/caching/New-PSFCache.Tests.ps1 b/PSFramework/tests/functions/caching/New-PSFCache.Tests.ps1
new file mode 100644
index 00000000..6218ae52
--- /dev/null
+++ b/PSFramework/tests/functions/caching/New-PSFCache.Tests.ps1
@@ -0,0 +1,172 @@
+Describe "New-PSFCache Unit Tests" -Tag "CI", "Pipeline", "Unit" {
+ BeforeEach {
+ $script:collectorCounter = 0
+ }
+
+ It "Should return a CacheMemoryConcurrent object" {
+ $cache = New-PSFCache
+
+ $cache.GetType().FullName | Should -Be 'PSFramework.Caching.CacheMemoryConcurrent'
+ $cache.Count | Should -Be 0
+ }
+
+ It "Should configure max items and lifetime through constructor parameters" {
+ $cache = New-PSFCache -MaxItems 3 -Lifetime 30s
+
+ $cache.GetMaxItems() | Should -Be 3
+ $cache.GetLifetime().TotalSeconds | Should -Be 30
+ }
+
+ It "Should configure TryDispose, Collector and CollectNull" {
+ $collector = { "Collected:$($_)" }
+ $cache = New-PSFCache -TryDispose -Collector $collector -CollectNull
+
+ $cache.GetTryDispose() | Should -BeTrue
+ $cache.GetCollector() | Should -Not -BeNullOrEmpty
+ $cache.GetCollector().ToString() | Should -Match 'Collected:'
+ $cache.GetCacheNull() | Should -BeTrue
+ }
+
+ It "Should support case-insensitive key access and Contains operations" {
+ $cache = New-PSFCache
+ $cache.Add('Alpha', 1)
+
+ $cache.ContainsKey('alpha') | Should -BeTrue
+ $cache.Contains('ALPHA') | Should -BeTrue
+ $cache['aLpHa'] | Should -Be 1
+ }
+
+ It "Should update values through indexer set and retrieve latest value" {
+ $cache = New-PSFCache
+ $cache['Item'] = 1
+ $cache['Item'] = 2
+
+ $cache['Item'] | Should -Be 2
+ $cache.Count | Should -Be 1
+ }
+
+ It "Should remove keys and ignore missing keys without error" {
+ $cache = New-PSFCache
+ $cache.Add('A', 1)
+ $cache.Remove('A')
+
+ $cache.ContainsKey('A') | Should -BeFalse
+ { $cache.Remove('DoesNotExist') } | Should -Not -Throw
+ }
+
+ It "Should enforce MaxItems by draining oldest entries" {
+ $cache = New-PSFCache -MaxItems 2
+ $cache.Add('A', 1)
+ $cache.Add('B', 2)
+ $cache.Add('C', 3)
+
+ $cache.Count | Should -Be 2
+ $cache.ContainsKey('A') | Should -BeFalse
+ $cache.ContainsKey('B') | Should -BeTrue
+ $cache.ContainsKey('C') | Should -BeTrue
+ }
+
+ It "Should expose non-expired entries through Count, Keys and Values" {
+ $cache = New-PSFCache
+ $cache.Add('One', 1)
+ $cache.Add('Two', 2)
+
+ $cache.Count | Should -Be 2
+ $cache.Keys | Should -Contain 'One'
+ $cache.Keys | Should -Contain 'Two'
+ $cache.Values | Should -Contain 1
+ $cache.Values | Should -Contain 2
+ }
+
+ It "Should enumerate as key-value pairs of current cache values" {
+ $cache = New-PSFCache
+ $cache.Add('One', 1)
+ $cache.Add('Two', 2)
+
+ $items = @($cache.GetEnumerator())
+ $items.Count | Should -Be 2
+ ($items | Where-Object Key -eq 'One').Value | Should -Be 1
+ ($items | Where-Object Key -eq 'Two').Value | Should -Be 2
+ }
+
+ It "Should clone current values to a hashtable" {
+ $cache = New-PSFCache
+ $cache.Add('One', 1)
+ $cache.Add('Two', 2)
+
+ $clone = $cache.Clone()
+ $clone | Should -BeOfType ([hashtable])
+ $clone.Count | Should -Be 2
+ $clone['one'] | Should -Be 1
+ $clone['two'] | Should -Be 2
+ }
+
+ It "Should retrieve missing entries using Collector and cache them" {
+ $cache = New-PSFCache -Collector {
+ $script:collectorCounter++
+ "Collected:$($_)"
+ }
+
+ $cache['Key1'] | Should -Be 'Collected:Key1'
+ $cache['Key1'] | Should -Be 'Collected:Key1'
+ $script:collectorCounter | Should -Be 1
+ $cache.ContainsKey('Key1') | Should -BeTrue
+ }
+
+ It "Should not cache null collector results by default" {
+ $cache = New-PSFCache -Collector {
+ $script:collectorCounter++
+ $null
+ }
+
+ $cache['NullKey'] | Should -BeNullOrEmpty
+ $cache['NullKey'] | Should -BeNullOrEmpty
+ $script:collectorCounter | Should -Be 2
+ $cache.ContainsKey('NullKey') | Should -BeFalse
+ }
+
+ It "Should cache null collector results when CollectNull is set" {
+ $cache = New-PSFCache -CollectNull -Collector {
+ $script:collectorCounter++
+ $null
+ }
+
+ $cache['NullKey'] | Should -BeNullOrEmpty
+ $cache['NullKey'] | Should -BeNullOrEmpty
+ $script:collectorCounter | Should -Be 1
+ $cache.ContainsKey('NullKey') | Should -BeTrue
+ $cache.Count | Should -Be 1
+ }
+
+ It "Should exclude expired entries from Count and key lookups" {
+ $cache = New-PSFCache -Lifetime 1s
+ $cache.Add('SoonExpired', 42)
+
+ Start-Sleep -Milliseconds 1200
+
+ $cache.ContainsKey('SoonExpired') | Should -BeFalse
+ $cache.Count | Should -Be 0
+ $cache.Keys | Should -Not -Contain 'SoonExpired'
+ }
+
+ It "Should dispose contained IDisposable values when TryDispose is enabled" {
+ $cache = New-PSFCache -TryDispose
+ $stream = [System.IO.MemoryStream]::new()
+ $cache.Add('Stream', $stream)
+
+ $cache.Remove('Stream')
+
+ $stream.CanRead | Should -BeFalse
+ }
+
+ It "Should not dispose contained IDisposable values when TryDispose is disabled" {
+ $cache = New-PSFCache
+ $stream = [System.IO.MemoryStream]::new()
+ $cache.Add('Stream', $stream)
+
+ $cache.Remove('Stream')
+
+ $stream.CanRead | Should -BeTrue
+ $stream.Dispose()
+ }
+}
diff --git a/PSFramework/tests/functions/message/Write-PSFMessage.Tests.ps1 b/PSFramework/tests/functions/message/Write-PSFMessage.Tests.ps1
new file mode 100644
index 00000000..0d562877
--- /dev/null
+++ b/PSFramework/tests/functions/message/Write-PSFMessage.Tests.ps1
@@ -0,0 +1,141 @@
+Describe "Write-PSFMessage Unit Tests" -Tag "CI", "Pipeline", "Unit" {
+ BeforeAll {
+ function Invoke-ErrorStackTest {
+ [CmdletBinding()]
+ param ()
+
+ try { 1 / 0 }
+ catch { Write-PSFMessage -Message Failed -ErrorRecord $_ -ErrorStack }
+ }
+ }
+ BeforeEach {
+ Clear-PSFMessage
+ }
+
+ AfterAll {
+ Clear-PSFMessage
+ }
+
+ It "Should expose the expected parameter sets" {
+ $command = Get-Command Write-PSFMessage -ErrorAction Stop
+ $command.ParameterSets.Name | Should -Contain 'Message'
+ $command.ParameterSets.Name | Should -Contain 'String'
+ }
+
+ It "Should expose key parameters used by callers" {
+ $command = Get-Command Write-PSFMessage -ErrorAction Stop
+ $command.Parameters.Keys | Should -Contain 'Message'
+ $command.Parameters.Keys | Should -Contain 'String'
+ $command.Parameters.Keys | Should -Contain 'StringValues'
+ $command.Parameters.Keys | Should -Contain 'Level'
+ $command.Parameters.Keys | Should -Contain 'Tag'
+ $command.Parameters.Keys | Should -Contain 'Data'
+ $command.Parameters.Keys | Should -Contain 'Target'
+ $command.Parameters.Keys | Should -Contain 'FunctionName'
+ $command.Parameters.Keys | Should -Contain 'ModuleName'
+ $command.Parameters.Keys | Should -Contain 'File'
+ $command.Parameters.Keys | Should -Contain 'Line'
+ $command.Parameters.Keys | Should -Contain 'Exception'
+ $command.Parameters.Keys | Should -Contain 'ErrorRecord'
+ $command.Parameters.Keys | Should -Contain 'OverrideExceptionMessage'
+ }
+
+ It "Should write a LogEntry object retrievable through Get-PSFMessage" {
+ Write-PSFMessage -Message 'SimpleMessage' -Level Significant
+ $entry = Get-PSFMessage | Select-Object -Last 1
+
+ $entry | Should -Not -BeNullOrEmpty
+ $entry.GetType().FullName | Should -Be 'PSFramework.Message.LogEntry'
+ $entry.Message | Should -Be 'SimpleMessage'
+ $entry.LogMessage | Should -Be 'SimpleMessage'
+ $entry.Level.ToString() | Should -Be 'Significant'
+ $entry.Runspace | Should -Be ([runspace]::DefaultRunspace.InstanceId)
+ $entry.Username | Should -Not -BeNullOrEmpty
+ }
+
+ It "Should persist tags, data and target metadata" {
+ $data = @{ Alpha = 1; Beta = 'two' }
+ $target = [PSCustomObject]@{ Name = 'Target1'; Id = 42 }
+
+ Write-PSFMessage -Message 'MetadataMessage' -Tag 'tagA', 'tagB' -Data $data -Target $target -Level Host
+ $entry = Get-PSFMessage | Select-Object -Last 1
+
+ $entry.Tags | Should -Contain 'tagA'
+ $entry.Tags | Should -Contain 'tagB'
+ $entry.Data.Alpha | Should -Be 1
+ $entry.Data.Beta | Should -Be 'two'
+ $entry.TargetObject | Should -Be $target
+ }
+
+ It "Should allow explicitly setting caller metadata fields" {
+ Write-PSFMessage -Message 'MetaOverride' -FunctionName 'Invoke-TestFn' -ModuleName 'TestModule' -File 'C:\Temp\fake.ps1' -Line 321 -Level Important
+ $entry = Get-PSFMessage | Select-Object -Last 1
+
+ $entry.FunctionName | Should -Be 'Invoke-TestFn'
+ $entry.ModuleName | Should -Be 'TestModule'
+ $entry.File | Should -Be 'C:\Temp\fake.ps1'
+ $entry.Line | Should -Be 321
+ }
+
+ It "Should format the Message using StringValues when provided" {
+ Write-PSFMessage -Message 'Value: {0} / {1}' -StringValues 'A', 5 -Level Verbose
+ $entry = Get-PSFMessage | Select-Object -Last 1
+
+ $entry.Message | Should -Be 'Value: A / 5'
+ }
+
+ It "Should strip color tags in the logged message" {
+ Write-PSFMessage -Message "Colored output" -Level Important
+ $entry = Get-PSFMessage | Select-Object -Last 1
+
+ $entry.Message | Should -Be 'Colored output'
+ $entry.LogMessage | Should -Be 'Colored output'
+ }
+
+ It "Should append exception text to message by default" {
+ $exception = [System.InvalidOperationException]::new('Boom')
+ Write-PSFMessage -Message 'Failure happened' -Exception $exception -Level Warning
+ $entry = Get-PSFMessage | Select-Object -Last 1
+
+ $entry.Message | Should -Be 'Failure happened | Boom'
+ $entry.ErrorRecord | Should -Not -BeNullOrEmpty
+ }
+
+ It "Should respect OverrideExceptionMessage when exception is specified" {
+ $exception = [System.InvalidOperationException]::new('Boom')
+ Write-PSFMessage -Message 'Failure happened' -Exception $exception -OverrideExceptionMessage -Level Warning
+ $entry = Get-PSFMessage | Select-Object -Last 1
+
+ $entry.Message | Should -Be 'Failure happened'
+ $entry.ErrorRecord | Should -Not -BeNullOrEmpty
+ }
+
+ It "Should avoid duplicate exception text when already present in message" {
+ $exception = [System.InvalidOperationException]::new('Boom')
+ $record = [System.Management.Automation.ErrorRecord]::new($exception, 'UnitTestError', [System.Management.Automation.ErrorCategory]::NotSpecified, $null)
+
+ Write-PSFMessage -Message 'Failure happened | Boom' -ErrorRecord $record -Level Warning
+ $entry = Get-PSFMessage | Select-Object -Last 1
+
+ $entry.Message | Should -Be 'Failure happened | Boom'
+ $entry.ErrorRecord | Should -Not -BeNullOrEmpty
+ }
+
+ It "Should use the error script stack when ErrorStack is specified" {
+ { Invoke-ErrorStackTest } | Should -Not -Throw
+
+ $entry = Get-PSFMessage | Select-Object -Last 1
+ $firstStackLine = ($entry.ErrorRecord.ScriptStackTrace -split "`r?`n" | Where-Object { $_ } | Select-Object -First 1)
+ $expectedLine = [int]([regex]::Match($firstStackLine, '(\d+)$').Groups[1].Value)
+
+ $entry.ErrorRecord | Should -Not -BeNullOrEmpty
+ $entry.Message | Should -Be 'Failed | Attempted to divide by zero.'
+ $entry.ErrorRecord.ScriptStackTrace | Should -Match 'Invoke-ErrorStackTest'
+ $entry.ErrorRecord.ScriptStackTrace | Should -Match 'Write-PSFMessage.Tests.ps1: line \d+'
+ $entry.CallStack.Entries | Should -Not -BeNullOrEmpty
+ $entry.CallStack.Entries[0].FunctionName | Should -Be 'Invoke-ErrorStackTest'
+ $entry.CallStack.Entries[0].File | Should -Match 'Write-PSFMessage.Tests.ps1'
+ $entry.CallStack.Entries[0].Line | Should -BeGreaterThan 0
+ $entry.CallStack.Entries[0].Line | Should -Be $expectedLine
+ }
+}
diff --git a/library/PSFramework/Caching/CacheBase.cs b/library/PSFramework/Caching/CacheBase.cs
index 9d48c35e..94972063 100644
--- a/library/PSFramework/Caching/CacheBase.cs
+++ b/library/PSFramework/Caching/CacheBase.cs
@@ -192,7 +192,7 @@ public override bool ContainsKey(object key)
{
if (!base.ContainsKey(key))
return false;
- return ((CachedData)base[key]).IsExpired;
+ return !((CachedData)base[key]).IsExpired;
}
///
/// Verifies whether the key exists in the cache.
diff --git a/library/PSFramework/Caching/CacheMemoryConcurrent.cs b/library/PSFramework/Caching/CacheMemoryConcurrent.cs
index 87b338b2..5a211a4b 100644
--- a/library/PSFramework/Caching/CacheMemoryConcurrent.cs
+++ b/library/PSFramework/Caching/CacheMemoryConcurrent.cs
@@ -93,6 +93,22 @@ public override void Remove(object key)
base.RemoveInt(Key);
}
+ ///
+ /// Verifies whether the key exists in the cache.
+ /// Expired entries are not considered.
+ ///
+ /// The key to check
+ /// Whether the key is in the cache
+ public override bool ContainsKey(object key)
+ {
+ // The new ContainsKey operation comes in two steps
+ // Thread-Safety requires a lock so nobody kills the entry inbetween the check and the expiration test
+ lock (_WriteLock)
+ {
+ return base.ContainsKey(key);
+ }
+ }
+
///
/// Read or write an entry in the cache
///
diff --git a/library/PSFramework/Message/CallStack.cs b/library/PSFramework/Message/CallStack.cs
index bf99b731..f8a5cccf 100644
--- a/library/PSFramework/Message/CallStack.cs
+++ b/library/PSFramework/Message/CallStack.cs
@@ -103,11 +103,12 @@ public CallStack(string ScriptStackTrace)
{
foreach (string line in ScriptStackTrace.Split('\n'))
{
- string command = Regex.Replace(line, "^at (.+?),.+$", "$1", RegexOptions.IgnoreCase);
- string file = Regex.Replace(line, "^at .+?,(.+): line .+$", "$1", RegexOptions.IgnoreCase);
- string lineNr = Regex.Replace(line, "^.+?(\\d+)$", "$1");
+ string command = Regex.Replace(line.Trim(), "^at (.+?),.+$", "$1", RegexOptions.IgnoreCase);
+ string file = Regex.Replace(line.Trim(), "^at .+?,(.+): line .+$", "$1", RegexOptions.IgnoreCase);
+ string lineNr = Regex.Replace(line.Trim(), "^.+?(\\d+)$", "$1");
- Entries.Add(new CallStackEntry(command, file, Int32.Parse(lineNr), null));
+ try { Entries.Add(new CallStackEntry(command, file, Int32.Parse(lineNr), null)); }
+ catch { Entries.Add(new CallStackEntry(command, file, -1, null)); }
}
}
}
diff --git a/library/PSFramework/PSFramework.csproj b/library/PSFramework/PSFramework.csproj
index 594f26a5..3246df40 100644
--- a/library/PSFramework/PSFramework.csproj
+++ b/library/PSFramework/PSFramework.csproj
@@ -263,6 +263,7 @@
+
diff --git a/library/PSFramework/Utility/HumanizedTimeSpan.cs b/library/PSFramework/Utility/HumanizedTimeSpan.cs
new file mode 100644
index 00000000..18263377
--- /dev/null
+++ b/library/PSFramework/Utility/HumanizedTimeSpan.cs
@@ -0,0 +1,107 @@
+using PSFramework.Parameter;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Management.Automation;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace PSFramework.Utility
+{
+ ///
+ /// Wraps a timespan and presents it in a human-friendly readable way.
+ ///
+ public class HumanizedTimeSpan : TimeSpanParameter
+ {
+ ///
+ /// How many digits after the dot are used for representing time
+ ///
+ public int Digits = 2;
+
+ #region Constructors
+ ///
+ /// Creates a HumanizedTimeSpan from a TimeSpan object (not the hardest challenge)
+ ///
+ /// The timespan object to accept
+ public HumanizedTimeSpan(TimeSpan Value)
+ : base(Value)
+ {
+
+ }
+
+ ///
+ /// Creates a HumanizedTimeSpan from integer, assuming it to mean seconds
+ ///
+ /// The seconds to run
+ public HumanizedTimeSpan(int Seconds)
+ : base(Seconds)
+ {
+
+ }
+
+ ///
+ /// Creates a HumanizedTimeSpan from a string object
+ ///
+ /// The string to interpret
+ public HumanizedTimeSpan(string Value)
+ : base(Value)
+ {
+
+ }
+
+ ///
+ /// Creates a HumanizedTimeSpan from any kind of object it has been taught to understand
+ ///
+ /// The object to interpret
+ public HumanizedTimeSpan(object InputObject)
+ : base(InputObject)
+ {
+
+ }
+ #endregion Constructors
+
+ ///
+ /// Creates extra-nice timespan formats
+ ///
+ /// Humanly readable timespans
+ public override string ToString()
+ {
+ if (Value.TotalSeconds < 1)
+ return Math.Round(Value.TotalMilliseconds, Digits).ToString() + " ms";
+ else if (Value.TotalSeconds <= 60)
+ return Math.Round(Value.TotalSeconds, Digits).ToString() + " s";
+ else
+ {
+ if (Value.Ticks % 10000000 == 0) { return Value.ToString(); }
+ else
+ {
+ string temp = Value.ToString();
+ temp = temp.Substring(0, temp.LastIndexOf("."));
+ return temp;
+ }
+ }
+ }
+
+ #region Implicit Operators
+ ///
+ /// Implicitly converts a DbaTimeSpan object into a TimeSpan object
+ ///
+ /// The original object to revert
+ public static implicit operator TimeSpan(HumanizedTimeSpan Base)
+ {
+ try { return Base.Value; }
+ catch { }
+ return new TimeSpan();
+ }
+
+ ///
+ /// Implicitly converts a TimeSpan object into a DbaTimeSpan object
+ ///
+ /// The original object to wrap
+ public static implicit operator HumanizedTimeSpan(TimeSpan Base)
+ {
+ return new HumanizedTimeSpan(Base);
+ }
+ #endregion Implicit Operators
+ }
+}