From a89e67348b95a3cdd87186bbab99030c53378107 Mon Sep 17 00:00:00 2001
From: Meketreve <34199654+meketreve@users.noreply.github.com>
Date: Thu, 13 Aug 2026 15:22:04 -0300
Subject: [PATCH 01/24] test: add end-to-end smoke script for server startup
and shutdown
---
tools/smoke-test.ps1 | 248 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 248 insertions(+)
create mode 100644 tools/smoke-test.ps1
diff --git a/tools/smoke-test.ps1 b/tools/smoke-test.ps1
new file mode 100644
index 00000000..e0667d78
--- /dev/null
+++ b/tools/smoke-test.ps1
@@ -0,0 +1,248 @@
+<#
+.SYNOPSIS
+ End-to-end smoke test: builds the solution, starts the server, asserts on its
+ startup log, shuts it down gracefully and asserts on shutdown.
+
+.DESCRIPTION
+ Exit codes:
+ 0 pass
+ 2 build failed
+ 3 GameRoot not found
+ 4 timed out waiting for the server to come online
+ 5 a forbidden pattern was found in the log
+ 6 the server did not shut down gracefully
+#>
+[CmdletBinding()]
+param(
+ [string] $GameRoot = $env:PERPETUUM_GAMEROOT,
+ [string] $Configuration = 'Release',
+ [int] $Timeout = 180,
+ [int] $SettleQuietSeconds = 10,
+ [int] $SettleTimeout = 120,
+ [int] $ShutdownTimeout = 120,
+ [switch] $KeepLog
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+# Patterns that must appear. Absence fails the run.
+$RequiredOnline = 'State : \[Online\]'
+$RequiredOffline = 'State : \[Off\]'
+
+# Patterns that must not appear anywhere in the startup log. Presence fails the run.
+$ForbiddenPatterns = @(
+ 'Unhandled exception',
+ 'System\.InvalidOperationException',
+ 'System\.NullReferenceException',
+ 'The current TransactionScope is already complete',
+ 'nesting level exceeded'
+)
+
+# Values that are measured and printed, never asserted. These legitimately change with
+# every content patch, so asserting on them would fail when the game works. Each entry is
+# counted by matching lines, because the server logs one line per event and never emits a
+# total. The strings are taken from a real startup log, not invented.
+$ReportedCounters = [ordered]@{
+ 'members spawned' = 'member spawned to zone'
+ 'flock batches' = 'NPCs created'
+}
+
+$repoRoot = Split-Path -Parent $PSScriptRoot
+
+function Write-Section([string] $Text) {
+ Write-Host ''
+ Write-Host "== $Text" -ForegroundColor Cyan
+}
+
+# --- Phase 0: environment -------------------------------------------------
+Write-Section 'Environment'
+if ([string]::IsNullOrWhiteSpace($GameRoot)) {
+ Write-Host 'GameRoot not supplied and PERPETUUM_GAMEROOT is not set.' -ForegroundColor Red
+ exit 3
+}
+if (-not (Test-Path -LiteralPath $GameRoot)) {
+ Write-Host "GameRoot not found: $GameRoot" -ForegroundColor Red
+ exit 3
+}
+Write-Host "GameRoot : $GameRoot"
+Write-Host "Configuration : $Configuration"
+
+# --- Phase 1: build -------------------------------------------------------
+Write-Section 'Build'
+& dotnet build (Join-Path $repoRoot 'PerpetuumServer2.sln') -c $Configuration -p:Platform=x64 --verbosity quiet
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "Build failed with exit code $LASTEXITCODE." -ForegroundColor Red
+ exit 2
+}
+
+# --- Phase 2: launch ------------------------------------------------------
+Write-Section 'Launch'
+# Perpetuum.Server has no BaseOutputPath override, so it builds to its own project folder.
+# Only Perpetuum.ServerService2.csproj redirects output to the repo-root bin\ directory, which
+# is why CLAUDE.md's blanket "Output: bin/x64/Release/net8.0" does not hold for this project.
+$serverExe = Join-Path $repoRoot "src\Perpetuum.Server\bin\x64\$Configuration\net8.0\Perpetuum.Server.exe"
+if (-not (Test-Path -LiteralPath $serverExe)) {
+ Write-Host "Server executable not found: $serverExe" -ForegroundColor Red
+ exit 2
+}
+
+$logPath = Join-Path ([System.IO.Path]::GetTempPath()) "perpetuum-smoke-$(Get-Date -Format yyyyMMdd-HHmmss).log"
+$errPath = "$logPath.err"
+Write-Host "Log : $logPath"
+
+$proc = Start-Process -FilePath $serverExe -ArgumentList "`"$GameRoot`"" `
+ -RedirectStandardOutput $logPath -RedirectStandardError $errPath `
+ -PassThru -WindowStyle Hidden
+
+# Start-Process -PassThru combined with output redirection returns a Process object whose
+# ExitCode (and Handle) cannot be read later, in this PowerShell version -- confirmed with an
+# isolated cmd.exe repro. Open our own handle now, while the process is guaranteed to still be
+# running, so Phase 5 can read the real exit code via GetExitCodeProcess instead.
+$procApiSignature = @'
+[DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId);
+[DllImport("kernel32.dll", SetLastError = true)] public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
+'@
+$procApi = Add-Type -MemberDefinition $procApiSignature -Name 'SmokeProcess' -Namespace 'Perpetuum' -PassThru
+$PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
+$procHandle = $procApi::OpenProcess($PROCESS_QUERY_LIMITED_INFORMATION, $false, [uint32] $proc.Id)
+
+# --- Phase 3: wait for online --------------------------------------------
+Write-Section 'Waiting for [Online]'
+$deadline = (Get-Date).AddSeconds($Timeout)
+$online = $false
+$startedAt = Get-Date
+while ((Get-Date) -lt $deadline) {
+ if ($proc.HasExited) { break }
+ if (Test-Path -LiteralPath $logPath) {
+ $content = Get-Content -LiteralPath $logPath -Raw -ErrorAction SilentlyContinue
+ if ($content -and $content -match $RequiredOnline) { $online = $true; break }
+ }
+ Start-Sleep -Milliseconds 500
+}
+$elapsed = [int]((Get-Date) - $startedAt).TotalSeconds
+
+if (-not $online) {
+ Write-Host "Server did not reach [Online] within $Timeout seconds." -ForegroundColor Red
+ if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }
+ Write-Host "Log kept for inspection: $logPath" -ForegroundColor Yellow
+ exit 4
+}
+Write-Host "Online after $elapsed seconds." -ForegroundColor Green
+
+# --- Phase 3b: wait for startup to drain ---------------------------------
+# The server reports [Online] well before it finishes spawning NPCs — measured at 34 seconds
+# of continued spawning past [Online] on a real run. Sending Ctrl+C at [Online] therefore
+# starts a shutdown that has to contend with the rest of startup, and it overruns any sane
+# shutdown budget. Wait until the log stops growing before shutting down.
+Write-Section 'Waiting for startup to drain'
+$settleDeadline = (Get-Date).AddSeconds($SettleTimeout)
+$settleStartedAt = Get-Date
+$lastSize = -1
+$quietSince = Get-Date
+while ((Get-Date) -lt $settleDeadline) {
+ if ($proc.HasExited) { break }
+ $size = (Get-Item -LiteralPath $logPath).Length
+ if ($size -ne $lastSize) {
+ $lastSize = $size
+ $quietSince = Get-Date
+ } elseif (((Get-Date) - $quietSince).TotalSeconds -ge $SettleQuietSeconds) {
+ break
+ }
+ Start-Sleep -Milliseconds 500
+}
+$settleElapsed = [int]((Get-Date) - $settleStartedAt).TotalSeconds
+Write-Host "Log quiet for $SettleQuietSeconds s after $settleElapsed s. Proceeding to shutdown."
+
+# --- Phase 4: assert on the startup log ----------------------------------
+Write-Section 'Startup assertions'
+$startupLog = Get-Content -LiteralPath $logPath -Raw
+$violations = @()
+foreach ($pattern in $ForbiddenPatterns) {
+ if ($startupLog -match $pattern) { $violations += $pattern }
+}
+
+Write-Host 'Reported values (not asserted):'
+foreach ($key in $ReportedCounters.Keys) {
+ $count = ([regex]::Matches($startupLog, $ReportedCounters[$key])).Count
+ Write-Host (" {0,-18}: {1}" -f $key, $count)
+}
+Write-Host (" {0,-18}: {1}s" -f 'time to online', $elapsed)
+Write-Host (" {0,-18}: {1}s" -f 'time to settle', $settleElapsed)
+
+# --- Phase 5: graceful shutdown ------------------------------------------
+Write-Section 'Shutdown'
+$signature = @'
+[DllImport("kernel32.dll", SetLastError = true)] public static extern bool AttachConsole(uint dwProcessId);
+[DllImport("kernel32.dll", SetLastError = true)] public static extern bool FreeConsole();
+[DllImport("kernel32.dll")] public static extern bool SetConsoleCtrlHandler(IntPtr handler, bool add);
+[DllImport("kernel32.dll")] public static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
+'@
+$kernel = Add-Type -MemberDefinition $signature -Name 'SmokeConsole' -Namespace 'Perpetuum' -PassThru
+
+$ATTACH_PARENT_PROCESS = [uint32]::MaxValue
+$graceful = $false
+$attached = $false
+$shutdownStartedAt = Get-Date
+try {
+ # A process that already owns a console cannot attach to another one: AttachConsole
+ # returns false with ERROR_ACCESS_DENIED (5). Release our own console first.
+ [void] $kernel::FreeConsole()
+ $attached = $kernel::AttachConsole([uint32] $proc.Id)
+ if ($attached) {
+ # MUST come before the event, or the Ctrl+C kills this PowerShell session.
+ [void] $kernel::SetConsoleCtrlHandler([IntPtr]::Zero, $true)
+ [void] $kernel::GenerateConsoleCtrlEvent(0, 0)
+ $graceful = $proc.WaitForExit($ShutdownTimeout * 1000)
+ }
+} finally {
+ # No Write-Host may run between our FreeConsole above and this restore: with no console
+ # attached, writing to the host throws.
+ [void] $kernel::FreeConsole()
+ [void] $kernel::AttachConsole($ATTACH_PARENT_PROCESS)
+ [void] $kernel::SetConsoleCtrlHandler([IntPtr]::Zero, $false)
+}
+$shutdownElapsed = [int]((Get-Date) - $shutdownStartedAt).TotalSeconds
+if (-not $attached) {
+ Write-Host 'AttachConsole failed; cannot deliver Ctrl+C.' -ForegroundColor Yellow
+}
+
+$finalLog = Get-Content -LiteralPath $logPath -Raw
+Write-Host ("Shutdown took {0}s." -f $shutdownElapsed)
+if (-not $graceful) {
+ Write-Host "Server did not exit within $ShutdownTimeout seconds of Ctrl+C. Killing it." -ForegroundColor Red
+ if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }
+ Write-Host "Log kept for inspection: $logPath" -ForegroundColor Yellow
+ exit 6
+}
+if ($finalLog -notmatch $RequiredOffline) {
+ Write-Host 'Server exited but never reported [Off].' -ForegroundColor Red
+ Write-Host "Log kept for inspection: $logPath" -ForegroundColor Yellow
+ exit 6
+}
+[uint32] $serverExitCode = 0
+[void] $procApi::GetExitCodeProcess($procHandle, [ref] $serverExitCode)
+if ($serverExitCode -ne 0) {
+ Write-Host "Server exit code was $serverExitCode, expected 0." -ForegroundColor Red
+ Write-Host "Log kept for inspection: $logPath" -ForegroundColor Yellow
+ exit 6
+}
+Write-Host 'Graceful shutdown confirmed.' -ForegroundColor Green
+
+# --- Phase 6: verdict -----------------------------------------------------
+Write-Section 'Verdict'
+if ($violations.Count -gt 0) {
+ Write-Host 'Forbidden patterns found in the log:' -ForegroundColor Red
+ foreach ($v in $violations) { Write-Host " $v" -ForegroundColor Red }
+ Write-Host "Log kept for inspection: $logPath" -ForegroundColor Yellow
+ exit 5
+}
+
+Write-Host 'SMOKE TEST PASSED' -ForegroundColor Green
+if (-not $KeepLog) {
+ Remove-Item -LiteralPath $logPath -ErrorAction SilentlyContinue
+ Remove-Item -LiteralPath $errPath -ErrorAction SilentlyContinue
+} else {
+ Write-Host "Log kept: $logPath"
+}
+exit 0
From c8d796e0828edfe84539ca14abcc7a2acf31253d Mon Sep 17 00:00:00 2001
From: Meketreve <34199654+meketreve@users.noreply.github.com>
Date: Thu, 13 Aug 2026 15:48:38 -0300
Subject: [PATCH 02/24] test: check Win32 return values and add top-level trap
for exit 7
Fixes two Important review findings on tools/smoke-test.ps1:
- OpenProcess and GetExitCodeProcess return values were unchecked; a failure
would silently leave the exit-code variable at 0 and report a graceful
shutdown that never happened. Both are now checked and route to a thrown
error with a distinct message on failure.
- No top-level trap existed, so an unhandled terminating error (missing
dotnet, unreadable log, failed P/Invoke) would exit through PowerShell's
own default code instead of a documented one. Added exit code 7 and a
trap immediately after $ErrorActionPreference = 'Stop', per the patched
plan.
---
tools/smoke-test.ps1 | 29 +++++++++++++++++++++++++----
1 file changed, 25 insertions(+), 4 deletions(-)
diff --git a/tools/smoke-test.ps1 b/tools/smoke-test.ps1
index e0667d78..abca9579 100644
--- a/tools/smoke-test.ps1
+++ b/tools/smoke-test.ps1
@@ -11,6 +11,7 @@
4 timed out waiting for the server to come online
5 a forbidden pattern was found in the log
6 the server did not shut down gracefully
+ 7 unexpected error
#>
[CmdletBinding()]
param(
@@ -26,6 +27,15 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
+# Any terminating error that no phase handles leaves through a documented code rather than
+# PowerShell's own. Without this, a missing dotnet, an unreadable log, or a failed P/Invoke
+# would exit with a code the script never documented.
+trap {
+ Write-Host "Unexpected error: $_" -ForegroundColor Red
+ Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray
+ exit 7
+}
+
# Patterns that must appear. Absence fails the run.
$RequiredOnline = 'State : \[Online\]'
$RequiredOffline = 'State : \[Off\]'
@@ -96,9 +106,12 @@ $proc = Start-Process -FilePath $serverExe -ArgumentList "`"$GameRoot`"" `
-PassThru -WindowStyle Hidden
# Start-Process -PassThru combined with output redirection returns a Process object whose
-# ExitCode (and Handle) cannot be read later, in this PowerShell version -- confirmed with an
-# isolated cmd.exe repro. Open our own handle now, while the process is guaranteed to still be
-# running, so Phase 5 can read the real exit code via GetExitCodeProcess instead.
+# ExitCode (and Handle) cannot be read later, in this PowerShell version. Open our own handle
+# now, while the process is guaranteed to still be running, so Phase 5 can read the real exit
+# code via GetExitCodeProcess. Both Win32 return values are checked below: an unchecked failure
+# here would silently leave the exit-code variable at its default and report a graceful
+# shutdown that never happened. Either failure is a failed P/Invoke, which is exactly what the
+# trap above exists to turn into a documented exit 7 instead of a default PowerShell exit.
$procApiSignature = @'
[DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId);
[DllImport("kernel32.dll", SetLastError = true)] public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
@@ -106,6 +119,10 @@ $procApiSignature = @'
$procApi = Add-Type -MemberDefinition $procApiSignature -Name 'SmokeProcess' -Namespace 'Perpetuum' -PassThru
$PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
$procHandle = $procApi::OpenProcess($PROCESS_QUERY_LIMITED_INFORMATION, $false, [uint32] $proc.Id)
+if ($procHandle -eq [IntPtr]::Zero) {
+ $lastError = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
+ throw "OpenProcess failed for PID $($proc.Id) (Win32 error $lastError). Cannot verify the server's exit code later."
+}
# --- Phase 3: wait for online --------------------------------------------
Write-Section 'Waiting for [Online]'
@@ -221,7 +238,11 @@ if ($finalLog -notmatch $RequiredOffline) {
exit 6
}
[uint32] $serverExitCode = 0
-[void] $procApi::GetExitCodeProcess($procHandle, [ref] $serverExitCode)
+$gotExitCode = $procApi::GetExitCodeProcess($procHandle, [ref] $serverExitCode)
+if (-not $gotExitCode) {
+ $lastError = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
+ throw "GetExitCodeProcess failed for PID $($proc.Id) (Win32 error $lastError). Cannot verify the server's exit code."
+}
if ($serverExitCode -ne 0) {
Write-Host "Server exit code was $serverExitCode, expected 0." -ForegroundColor Red
Write-Host "Log kept for inspection: $logPath" -ForegroundColor Yellow
From 60be7ba15b2db419be024ad4a518d973b8236c05 Mon Sep 17 00:00:00 2001
From: Meketreve <34199654+meketreve@users.noreply.github.com>
Date: Thu, 13 Aug 2026 18:33:22 -0300
Subject: [PATCH 03/24] test: add Perpetuum.Tests project with
ValueTypeExtensions coverage
---
PerpetuumServer2.sln | 7 ++++
src/Perpetuum.Tests/Perpetuum.Tests.csproj | 22 ++++++++++
.../Unit/ValueTypeExtensionsTests.cs | 42 +++++++++++++++++++
3 files changed, 71 insertions(+)
create mode 100644 src/Perpetuum.Tests/Perpetuum.Tests.csproj
create mode 100644 src/Perpetuum.Tests/Unit/ValueTypeExtensionsTests.cs
diff --git a/PerpetuumServer2.sln b/PerpetuumServer2.sln
index d9d55f4a..0d3e9974 100644
--- a/PerpetuumServer2.sln
+++ b/PerpetuumServer2.sln
@@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Perpetuum.ServerService2",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Perpetuum.AdminTool", "src\Perpetuum.AdminTool\Perpetuum.AdminTool.csproj", "{A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Perpetuum.Tests", "src\Perpetuum.Tests\Perpetuum.Tests.csproj", "{C8C45427-6E5C-496C-9446-0817EADC05DD}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
@@ -65,6 +67,10 @@ Global
{A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}.Debug|x64.Build.0 = Debug|x64
{A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}.Release|x64.ActiveCfg = Release|x64
{A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0}.Release|x64.Build.0 = Release|x64
+ {C8C45427-6E5C-496C-9446-0817EADC05DD}.Debug|x64.ActiveCfg = Debug|x64
+ {C8C45427-6E5C-496C-9446-0817EADC05DD}.Debug|x64.Build.0 = Debug|x64
+ {C8C45427-6E5C-496C-9446-0817EADC05DD}.Release|x64.ActiveCfg = Release|x64
+ {C8C45427-6E5C-496C-9446-0817EADC05DD}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -79,6 +85,7 @@ Global
{D3F8A2B1-6C9E-4D7F-A5B8-2E0C4F9A3D6B} = {0BC991A1-133C-49ED-A141-80E2A906898B}
{A2FCE930-E013-4731-B354-F7DA00322D38} = {0BC991A1-133C-49ED-A141-80E2A906898B}
{A7D1E3C5-9F4B-42E8-8A6C-B5D7F1E9C2A0} = {0BC991A1-133C-49ED-A141-80E2A906898B}
+ {C8C45427-6E5C-496C-9446-0817EADC05DD} = {0BC991A1-133C-49ED-A141-80E2A906898B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {91874B60-30B0-4B48-9155-370E40BC70E6}
diff --git a/src/Perpetuum.Tests/Perpetuum.Tests.csproj b/src/Perpetuum.Tests/Perpetuum.Tests.csproj
new file mode 100644
index 00000000..96019f2a
--- /dev/null
+++ b/src/Perpetuum.Tests/Perpetuum.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ annotations
+ x64
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Perpetuum.Tests/Unit/ValueTypeExtensionsTests.cs b/src/Perpetuum.Tests/Unit/ValueTypeExtensionsTests.cs
new file mode 100644
index 00000000..b6b17844
--- /dev/null
+++ b/src/Perpetuum.Tests/Unit/ValueTypeExtensionsTests.cs
@@ -0,0 +1,42 @@
+using Xunit;
+
+namespace Perpetuum.Tests.Unit
+{
+ public class ValueTypeExtensionsTests
+ {
+ [Theory]
+ [InlineData(5, 10, 40, 10)]
+ [InlineData(50, 10, 40, 40)]
+ [InlineData(25, 10, 40, 25)]
+ [InlineData(10, 10, 40, 10)]
+ [InlineData(40, 10, 40, 40)]
+ public void Clamp_int_bounds_the_value(int value, int lower, int upper, int expected)
+ {
+ Assert.Equal(expected, value.Clamp(lower, upper));
+ }
+
+ [Theory]
+ [InlineData(0, false)]
+ [InlineData(1, true)]
+ [InlineData(-1, false)]
+ [InlineData(int.MaxValue, true)]
+ public void ToBool_is_true_only_above_zero(int value, bool expected)
+ {
+ Assert.Equal(expected, value.ToBool());
+ }
+
+ [Fact]
+ public void Min_returns_the_lower_of_the_two()
+ {
+ Assert.Equal(3, 7.Min(3));
+ Assert.Equal(3, 3.Min(7));
+ }
+
+ [Fact]
+ public void Max_returns_the_higher_of_the_two()
+ {
+ Assert.Equal(7, 7.Max(3));
+ Assert.Equal(7, 3.Max(7));
+ }
+ }
+}
From 47c261b2a8a78bee343987c15bb9390564e9a29f Mon Sep 17 00:00:00 2001
From: Meketreve <34199654+meketreve@users.noreply.github.com>
Date: Thu, 13 Aug 2026 18:41:32 -0300
Subject: [PATCH 04/24] test: declare Windows platform support to fix CA1416
warnings
Perpetuum.Tests references Perpetuum.csproj, which is marked
SupportedOSPlatform("windows"). Every other consumer of Perpetuum.csproj
declares the same attribute itself; Perpetuum.Tests did not, which is
why the analyzer fired CA1416 on every ValueTypeExtensions call.
---
src/Perpetuum.Tests/AssemblyInfo.cs | 3 +++
1 file changed, 3 insertions(+)
create mode 100644 src/Perpetuum.Tests/AssemblyInfo.cs
diff --git a/src/Perpetuum.Tests/AssemblyInfo.cs b/src/Perpetuum.Tests/AssemblyInfo.cs
new file mode 100644
index 00000000..d04ca728
--- /dev/null
+++ b/src/Perpetuum.Tests/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.Versioning;
+
+[assembly: SupportedOSPlatform("windows")]
From 9262d6525ed9179d0af89dc66a7f6ba966dd80b6 Mon Sep 17 00:00:00 2001
From: Meketreve <34199654+meketreve@users.noreply.github.com>
Date: Thu, 13 Aug 2026 18:50:43 -0300
Subject: [PATCH 05/24] test: cover Guard throw-if extension methods
---
src/Perpetuum.Tests/Unit/GuardTests.cs | 130 +++++++++++++++++++++++++
1 file changed, 130 insertions(+)
create mode 100644 src/Perpetuum.Tests/Unit/GuardTests.cs
diff --git a/src/Perpetuum.Tests/Unit/GuardTests.cs b/src/Perpetuum.Tests/Unit/GuardTests.cs
new file mode 100644
index 00000000..22bb17bf
--- /dev/null
+++ b/src/Perpetuum.Tests/Unit/GuardTests.cs
@@ -0,0 +1,130 @@
+using Xunit;
+
+namespace Perpetuum.Tests.Unit
+{
+ // ErrorCodes lives in the Perpetuum namespace (src/Perpetuum/ErrorCodes.cs), which this
+ // file's namespace resolves to without a using directive.
+ public class GuardTests
+ {
+ [Fact]
+ public void ThrowIfZero_int_throws_on_zero()
+ {
+ PerpetuumException ex = Assert.Throws(
+ () => 0.ThrowIfZero(ErrorCodes.WTFErrorMedicalAttentionSuggested));
+
+ Assert.Equal(ErrorCodes.WTFErrorMedicalAttentionSuggested, ex.error);
+ }
+
+ [Fact]
+ public void ThrowIfZero_int_passes_on_non_zero()
+ {
+ 1.ThrowIfZero(ErrorCodes.WTFErrorMedicalAttentionSuggested);
+ }
+
+ [Fact]
+ public void ThrowIfNull_throws_on_null_and_returns_the_value_otherwise()
+ {
+ object? nothing = null;
+ _ = Assert.Throws(
+ () => nothing.ThrowIfNull(ErrorCodes.WTFErrorMedicalAttentionSuggested));
+
+ object something = new();
+ Assert.Same(something, something.ThrowIfNull(ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+
+ [Fact]
+ public void ThrowIfTrue_and_ThrowIfFalse_are_mirror_images()
+ {
+ _ = Assert.Throws(
+ () => true.ThrowIfTrue(ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ false.ThrowIfTrue(ErrorCodes.WTFErrorMedicalAttentionSuggested);
+
+ _ = Assert.Throws(
+ () => false.ThrowIfFalse(ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ true.ThrowIfFalse(ErrorCodes.WTFErrorMedicalAttentionSuggested);
+ }
+
+ [Theory]
+ [InlineData(5, 3, true)]
+ [InlineData(3, 3, false)]
+ [InlineData(1, 3, false)]
+ public void ThrowIfGreater_throws_only_when_strictly_greater(int source, int comparer, bool shouldThrow)
+ {
+ if (shouldThrow)
+ {
+ _ = Assert.Throws(
+ () => source.ThrowIfGreater(comparer, ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+ else
+ {
+ Assert.Equal(source, source.ThrowIfGreater(comparer, ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+ }
+
+ [Theory]
+ [InlineData(5, 3, false)]
+ [InlineData(3, 3, true)]
+ [InlineData(1, 3, true)]
+ public void ThrowIfLessOrEqual_throws_at_and_below_the_comparer(int source, int comparer, bool shouldThrow)
+ {
+ if (shouldThrow)
+ {
+ _ = Assert.Throws(
+ () => source.ThrowIfLessOrEqual(comparer, ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+ else
+ {
+ Assert.Equal(source, source.ThrowIfLessOrEqual(comparer, ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+ }
+
+ [Fact]
+ public void ThrowIfError_passes_NoError_through_and_throws_on_anything_else()
+ {
+ Assert.Equal(ErrorCodes.NoError, ErrorCodes.NoError.ThrowIfError());
+
+ PerpetuumException ex = Assert.Throws(
+ () => ErrorCodes.WTFErrorMedicalAttentionSuggested.ThrowIfError());
+
+ Assert.Equal(ErrorCodes.WTFErrorMedicalAttentionSuggested, ex.error);
+ }
+
+ [Fact]
+ public void ThrowIfError_invokes_the_exception_action_before_throwing()
+ {
+ bool invoked = false;
+
+ _ = Assert.Throws(
+ () => ErrorCodes.WTFErrorMedicalAttentionSuggested.ThrowIfError(_ => invoked = true));
+
+ Assert.True(invoked);
+ }
+
+ [Theory]
+ [InlineData(null, true)]
+ [InlineData("", true)]
+ [InlineData("x", false)]
+ public void ThrowIfNullOrEmpty_rejects_null_and_empty(string? text, bool shouldThrow)
+ {
+ if (shouldThrow)
+ {
+ _ = Assert.Throws(
+ () => text!.ThrowIfNullOrEmpty(ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+ else
+ {
+ Assert.Equal(text, text!.ThrowIfNullOrEmpty(ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+ }
+
+ [Fact]
+ public void ThrowIfNotType_returns_the_cast_value_and_rejects_the_wrong_type()
+ {
+ object value = "text";
+ Assert.Equal("text", value.ThrowIfNotType(ErrorCodes.WTFErrorMedicalAttentionSuggested));
+
+ _ = Assert.Throws(
+ () => value.ThrowIfNotType(ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+ }
+}
From bfda47a7ba4a43e968aa87e8f8d7448843f8d925 Mon Sep 17 00:00:00 2001
From: Meketreve <34199654+meketreve@users.noreply.github.com>
Date: Thu, 13 Aug 2026 19:00:24 -0300
Subject: [PATCH 06/24] test: cover Guard throw-if extension methods
---
src/Perpetuum.Tests/Unit/GuardTests.cs | 88 ++++++++++++++++++++++++++
1 file changed, 88 insertions(+)
diff --git a/src/Perpetuum.Tests/Unit/GuardTests.cs b/src/Perpetuum.Tests/Unit/GuardTests.cs
index 22bb17bf..3ee22263 100644
--- a/src/Perpetuum.Tests/Unit/GuardTests.cs
+++ b/src/Perpetuum.Tests/Unit/GuardTests.cs
@@ -126,5 +126,93 @@ public void ThrowIfNotType_returns_the_cast_value_and_rejects_the_wrong_type()
_ = Assert.Throws(
() => value.ThrowIfNotType(ErrorCodes.WTFErrorMedicalAttentionSuggested));
}
+
+ [Fact]
+ public void ThrowIfType_rejects_the_named_type_and_passes_anything_else()
+ {
+ object value = "text";
+
+ _ = Assert.Throws(
+ () => value.ThrowIfType(ErrorCodes.WTFErrorMedicalAttentionSuggested));
+
+ value.ThrowIfType(ErrorCodes.WTFErrorMedicalAttentionSuggested);
+ }
+
+ [Fact]
+ public void ThrowIfZero_long_throws_on_zero()
+ {
+ // Not an extension method: Guard.ThrowIfZero(long, Func) takes its source
+ // as a plain parameter, unlike the int overload above it.
+ _ = Assert.Throws(
+ () => Guard.ThrowIfZero(0L, () => PerpetuumException.Create(ErrorCodes.WTFErrorMedicalAttentionSuggested)));
+
+ Guard.ThrowIfZero(1L, () => PerpetuumException.Create(ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+
+ [Fact]
+ public void ThrowIfZero_double_rejects_values_within_epsilon_of_zero()
+ {
+ _ = Assert.Throws(
+ () => 0.0d.ThrowIfZero(() => PerpetuumException.Create(ErrorCodes.WTFErrorMedicalAttentionSuggested)));
+
+ Assert.Equal(
+ 2.5d,
+ 2.5d.ThrowIfZero(() => PerpetuumException.Create(ErrorCodes.WTFErrorMedicalAttentionSuggested)));
+ }
+
+ [Theory]
+ [InlineData(5, 3, true)]
+ [InlineData(3, 3, true)]
+ [InlineData(1, 3, false)]
+ public void ThrowIfGreaterOrEqual_throws_at_and_above_the_comparer(int source, int comparer, bool shouldThrow)
+ {
+ if (shouldThrow)
+ {
+ _ = Assert.Throws(
+ () => source.ThrowIfGreaterOrEqual(comparer, ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+ else
+ {
+ Assert.Equal(source, source.ThrowIfGreaterOrEqual(comparer, ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+ }
+
+ [Theory]
+ [InlineData(1, 3, true)]
+ [InlineData(3, 3, false)]
+ [InlineData(5, 3, false)]
+ public void ThrowIfLess_throws_only_when_strictly_less(int source, int comparer, bool shouldThrow)
+ {
+ if (shouldThrow)
+ {
+ _ = Assert.Throws(
+ () => source.ThrowIfLess(comparer, ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+ else
+ {
+ Assert.Equal(source, source.ThrowIfLess(comparer, ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+ }
+
+ [Fact]
+ public void ThrowIfNotNull_throws_when_the_value_is_present()
+ {
+ object? nothing = null;
+ nothing.ThrowIfNotNull(ErrorCodes.WTFErrorMedicalAttentionSuggested);
+
+ _ = Assert.Throws(
+ () => new object().ThrowIfNotNull(ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+
+ [Fact]
+ public void ThrowIfNotNull_invokes_the_exception_action_before_throwing()
+ {
+ bool invoked = false;
+
+ _ = Assert.Throws(
+ () => new object().ThrowIfNotNull(ErrorCodes.WTFErrorMedicalAttentionSuggested, _ => invoked = true));
+
+ Assert.True(invoked);
+ }
}
}
From 45a56f8301c7aaea9967858c37de77a484a0b666 Mon Sep 17 00:00:00 2001
From: Meketreve <34199654+meketreve@users.noreply.github.com>
Date: Thu, 13 Aug 2026 19:13:19 -0300
Subject: [PATCH 07/24] test: cover Guard throw-if extension methods
---
src/Perpetuum.Tests/Unit/GuardTests.cs | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/src/Perpetuum.Tests/Unit/GuardTests.cs b/src/Perpetuum.Tests/Unit/GuardTests.cs
index 3ee22263..f3b4f016 100644
--- a/src/Perpetuum.Tests/Unit/GuardTests.cs
+++ b/src/Perpetuum.Tests/Unit/GuardTests.cs
@@ -214,5 +214,29 @@ public void ThrowIfNotNull_invokes_the_exception_action_before_throwing()
Assert.True(invoked);
}
+
+ [Fact]
+ public void ThrowIfEqual_throws_on_equality_and_returns_the_value_otherwise()
+ {
+ // Guard.cs:110 is a separate implementation from the Func overload at
+ // Guard.cs:216 — it does not delegate to it, and nothing else in Guard reaches it.
+ // Without this test the overload has no coverage, direct or transitive, despite being
+ // used across EntityRepository, ZoneSession and EntityDefault.
+ _ = Assert.Throws(
+ () => 7.ThrowIfEqual(7, ErrorCodes.WTFErrorMedicalAttentionSuggested));
+
+ Assert.Equal(7, 7.ThrowIfEqual(3, ErrorCodes.WTFErrorMedicalAttentionSuggested));
+ }
+
+ [Fact]
+ public void ThrowIfEqual_invokes_the_exception_action_before_throwing()
+ {
+ bool invoked = false;
+
+ _ = Assert.Throws(
+ () => 7.ThrowIfEqual(7, ErrorCodes.WTFErrorMedicalAttentionSuggested, _ => invoked = true));
+
+ Assert.True(invoked);
+ }
}
}
From b695060f23868edffef8f03d0b0583d9fe08bc4b Mon Sep 17 00:00:00 2001
From: Meketreve <34199654+meketreve@users.noreply.github.com>
Date: Thu, 13 Aug 2026 19:28:38 -0300
Subject: [PATCH 08/24] test: add recording logger and static service locator
fixture
---
src/Perpetuum.Tests/Fakes/RecordingLogger.cs | 37 +++++++++++++++++++
.../AssemblyLoggerInitializer.cs | 27 ++++++++++++++
.../PerpetuumStaticsCollection.cs | 16 ++++++++
.../Infrastructure/PerpetuumStaticsFixture.cs | 36 ++++++++++++++++++
.../Unit/RecordingLoggerTests.cs | 34 +++++++++++++++++
5 files changed, 150 insertions(+)
create mode 100644 src/Perpetuum.Tests/Fakes/RecordingLogger.cs
create mode 100644 src/Perpetuum.Tests/Infrastructure/AssemblyLoggerInitializer.cs
create mode 100644 src/Perpetuum.Tests/Infrastructure/PerpetuumStaticsCollection.cs
create mode 100644 src/Perpetuum.Tests/Infrastructure/PerpetuumStaticsFixture.cs
create mode 100644 src/Perpetuum.Tests/Unit/RecordingLoggerTests.cs
diff --git a/src/Perpetuum.Tests/Fakes/RecordingLogger.cs b/src/Perpetuum.Tests/Fakes/RecordingLogger.cs
new file mode 100644
index 00000000..48992502
--- /dev/null
+++ b/src/Perpetuum.Tests/Fakes/RecordingLogger.cs
@@ -0,0 +1,37 @@
+using Perpetuum.Log;
+
+namespace Perpetuum.Tests.Fakes
+{
+ ///
+ /// Captures every log event so tests can assert on what the code under test reported.
+ /// Thread-safe: production code logs from timer and task threads.
+ ///
+ public sealed class RecordingLogger : ILogger
+ {
+ private readonly List _events = [];
+ private readonly object _gate = new();
+
+ public void Log(LogEvent logEvent)
+ {
+ lock (_gate)
+ {
+ _events.Add(logEvent);
+ }
+ }
+
+ public IReadOnlyList Events
+ {
+ get { lock (_gate) { return [.. _events]; } }
+ }
+
+ public IReadOnlyList Exceptions
+ {
+ get { lock (_gate) { return [.. _events.Where(e => e.LogType == LogType.Error && e.ThrownException != null)]; } }
+ }
+
+ public void Clear()
+ {
+ lock (_gate) { _events.Clear(); }
+ }
+ }
+}
diff --git a/src/Perpetuum.Tests/Infrastructure/AssemblyLoggerInitializer.cs b/src/Perpetuum.Tests/Infrastructure/AssemblyLoggerInitializer.cs
new file mode 100644
index 00000000..12677129
--- /dev/null
+++ b/src/Perpetuum.Tests/Infrastructure/AssemblyLoggerInitializer.cs
@@ -0,0 +1,27 @@
+using Perpetuum.Log;
+using Perpetuum.Tests.Fakes;
+
+namespace Perpetuum.Tests.Infrastructure
+{
+ ///
+ /// Logger.Current is declared as { private get; set; }, so its previous value cannot be
+ /// read and therefore cannot be restored. It is assigned once for the whole assembly.
+ ///
+ public static class AssemblyLoggerInitializer
+ {
+ public static RecordingLogger Instance { get; } = new RecordingLogger();
+
+ private static bool _installed;
+ private static readonly object Gate = new();
+
+ public static void Install()
+ {
+ lock (Gate)
+ {
+ if (_installed) return;
+ Logger.Current = Instance;
+ _installed = true;
+ }
+ }
+ }
+}
diff --git a/src/Perpetuum.Tests/Infrastructure/PerpetuumStaticsCollection.cs b/src/Perpetuum.Tests/Infrastructure/PerpetuumStaticsCollection.cs
new file mode 100644
index 00000000..6f34f53d
--- /dev/null
+++ b/src/Perpetuum.Tests/Infrastructure/PerpetuumStaticsCollection.cs
@@ -0,0 +1,16 @@
+using Xunit;
+
+namespace Perpetuum.Tests.Infrastructure
+{
+ ///
+ /// Every test class that assigns a static service locator carries
+ /// [Collection(PerpetuumStaticsCollection.Name)]. xUnit runs the classes in one collection
+ /// serially, which is what keeps static assignment from racing. Tests that touch no static
+ /// stay outside this collection and keep running in parallel.
+ ///
+ [CollectionDefinition(Name)]
+ public class PerpetuumStaticsCollection : ICollectionFixture
+ {
+ public const string Name = "Perpetuum statics";
+ }
+}
diff --git a/src/Perpetuum.Tests/Infrastructure/PerpetuumStaticsFixture.cs b/src/Perpetuum.Tests/Infrastructure/PerpetuumStaticsFixture.cs
new file mode 100644
index 00000000..02c535c2
--- /dev/null
+++ b/src/Perpetuum.Tests/Infrastructure/PerpetuumStaticsFixture.cs
@@ -0,0 +1,36 @@
+using Perpetuum.Data;
+using Perpetuum.EntityFramework;
+using Perpetuum.Tests.Fakes;
+
+namespace Perpetuum.Tests.Infrastructure
+{
+ ///
+ /// Saves the settable static service locators, installs the recording logger, and restores
+ /// the readable locators on dispose. Shared by every test class in the statics collection,
+ /// which xUnit runs serially.
+ ///
+ public sealed class PerpetuumStaticsFixture : IDisposable
+ {
+ private readonly Func _savedDbQueryFactory;
+ private readonly IEntityDefaultReader _savedDefaultReader;
+ private readonly IEntityServices _savedEntityServices;
+
+ public PerpetuumStaticsFixture()
+ {
+ _savedDbQueryFactory = Db.DbQueryFactory;
+ _savedDefaultReader = EntityDefault.Reader;
+ _savedEntityServices = Entity.Services;
+
+ AssemblyLoggerInitializer.Install();
+ }
+
+ public RecordingLogger Logger => AssemblyLoggerInitializer.Instance;
+
+ public void Dispose()
+ {
+ Db.DbQueryFactory = _savedDbQueryFactory;
+ EntityDefault.Reader = _savedDefaultReader;
+ Entity.Services = _savedEntityServices;
+ }
+ }
+}
diff --git a/src/Perpetuum.Tests/Unit/RecordingLoggerTests.cs b/src/Perpetuum.Tests/Unit/RecordingLoggerTests.cs
new file mode 100644
index 00000000..50a99ecc
--- /dev/null
+++ b/src/Perpetuum.Tests/Unit/RecordingLoggerTests.cs
@@ -0,0 +1,34 @@
+using Perpetuum.Log;
+using Perpetuum.Tests.Infrastructure;
+using Xunit;
+
+namespace Perpetuum.Tests.Unit
+{
+ [Collection(PerpetuumStaticsCollection.Name)]
+ public class RecordingLoggerTests
+ {
+ private readonly PerpetuumStaticsFixture _fixture;
+
+ public RecordingLoggerTests(PerpetuumStaticsFixture fixture)
+ {
+ _fixture = fixture;
+ _fixture.Logger.Clear();
+ }
+
+ [Fact]
+ public void Info_is_recorded()
+ {
+ Logger.Info("hello");
+
+ Assert.Contains(_fixture.Logger.Events, e => e.Message == "hello");
+ }
+
+ [Fact]
+ public void Exception_is_recorded_as_an_exception_event()
+ {
+ Logger.Exception(new InvalidOperationException("boom"));
+
+ Assert.Single(_fixture.Logger.Exceptions);
+ }
+ }
+}
From 604f51130979076de9c175c44315cb7ee5fcbe52 Mon Sep 17 00:00:00 2001
From: Meketreve <34199654+meketreve@users.noreply.github.com>
Date: Thu, 13 Aug 2026 19:44:24 -0300
Subject: [PATCH 09/24] test: add recording fake for the ADO.NET data layer
---
.../Fakes/Data/FakeDataReader.cs | 74 +++++++++++++++
src/Perpetuum.Tests/Fakes/Data/FakeDb.cs | 89 +++++++++++++++++++
.../Fakes/Data/FakeDbCommand.cs | 75 ++++++++++++++++
.../Fakes/Data/FakeDbConnection.cs | 37 ++++++++
.../Fakes/Data/FakeResultSet.cs | 28 ++++++
5 files changed, 303 insertions(+)
create mode 100644 src/Perpetuum.Tests/Fakes/Data/FakeDataReader.cs
create mode 100644 src/Perpetuum.Tests/Fakes/Data/FakeDb.cs
create mode 100644 src/Perpetuum.Tests/Fakes/Data/FakeDbCommand.cs
create mode 100644 src/Perpetuum.Tests/Fakes/Data/FakeDbConnection.cs
create mode 100644 src/Perpetuum.Tests/Fakes/Data/FakeResultSet.cs
diff --git a/src/Perpetuum.Tests/Fakes/Data/FakeDataReader.cs b/src/Perpetuum.Tests/Fakes/Data/FakeDataReader.cs
new file mode 100644
index 00000000..755a02b9
--- /dev/null
+++ b/src/Perpetuum.Tests/Fakes/Data/FakeDataReader.cs
@@ -0,0 +1,74 @@
+using System.Data;
+
+namespace Perpetuum.Tests.Fakes.Data
+{
+ public sealed class FakeDataReader(FakeResultSet resultSet) : IDataReader
+ {
+ private readonly FakeResultSet _resultSet = resultSet;
+ private int _index = -1;
+
+ private object?[] Current => _resultSet.Rows[_index];
+
+ public bool Read()
+ {
+ _index++;
+ return _index < _resultSet.Rows.Count;
+ }
+
+ public int FieldCount => _resultSet.ColumnNames.Count;
+ public string GetName(int i) => _resultSet.ColumnNames[i];
+ public int GetOrdinal(string name)
+ {
+ for (int i = 0; i < _resultSet.ColumnNames.Count; i++)
+ {
+ if (string.Equals(_resultSet.ColumnNames[i], name, StringComparison.OrdinalIgnoreCase))
+ {
+ return i;
+ }
+ }
+
+ throw new IndexOutOfRangeException(name);
+ }
+
+ public object GetValue(int i) => Current[i] ?? DBNull.Value;
+ public bool IsDBNull(int i) => Current[i] is null;
+ public object this[int i] => GetValue(i);
+ public object this[string name] => GetValue(GetOrdinal(name));
+
+ public bool GetBoolean(int i) => (bool)GetValue(i);
+ public byte GetByte(int i) => (byte)GetValue(i);
+ public char GetChar(int i) => (char)GetValue(i);
+ public DateTime GetDateTime(int i) => (DateTime)GetValue(i);
+ public decimal GetDecimal(int i) => (decimal)GetValue(i);
+ public double GetDouble(int i) => (double)GetValue(i);
+ public float GetFloat(int i) => (float)GetValue(i);
+ public Guid GetGuid(int i) => (Guid)GetValue(i);
+ public short GetInt16(int i) => (short)GetValue(i);
+ public int GetInt32(int i) => (int)GetValue(i);
+ public long GetInt64(int i) => (long)GetValue(i);
+ public string GetString(int i) => (string)GetValue(i);
+ public Type GetFieldType(int i) => Current[i]?.GetType() ?? typeof(object);
+ public string GetDataTypeName(int i) => GetFieldType(i).Name;
+
+ public int GetValues(object[] values)
+ {
+ int count = Math.Min(values.Length, FieldCount);
+ for (int i = 0; i < count; i++) { values[i] = GetValue(i); }
+ return count;
+ }
+
+ public long GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length)
+ => throw new NotSupportedException();
+ public long GetChars(int i, long fieldoffset, char[]? buffer, int bufferoffset, int length)
+ => throw new NotSupportedException();
+ public IDataReader GetData(int i) => throw new NotSupportedException();
+
+ public int Depth => 0;
+ public bool IsClosed { get; private set; }
+ public int RecordsAffected => -1;
+ public void Close() => IsClosed = true;
+ public DataTable? GetSchemaTable() => null;
+ public bool NextResult() => false;
+ public void Dispose() => Close();
+ }
+}
diff --git a/src/Perpetuum.Tests/Fakes/Data/FakeDb.cs b/src/Perpetuum.Tests/Fakes/Data/FakeDb.cs
new file mode 100644
index 00000000..2f77042b
--- /dev/null
+++ b/src/Perpetuum.Tests/Fakes/Data/FakeDb.cs
@@ -0,0 +1,89 @@
+using System.Data;
+using System.Transactions;
+using Perpetuum.Data;
+
+namespace Perpetuum.Tests.Fakes.Data
+{
+ public sealed record RecordedCommand
+ {
+ public required string CommandText { get; init; }
+ public required IReadOnlyDictionary Parameters { get; init; }
+ public required int CommandTimeout { get; init; }
+ public required bool HadAmbientTransaction { get; init; }
+ public required TransactionStatus? AmbientTransactionStatus { get; init; }
+ }
+
+ ///
+ /// Installs a fake data layer into Db.DbQueryFactory, which funnels every Db.Query() call
+ /// site in the codebase. Results are matched by substring against the command text.
+ ///
+ public sealed class FakeDb
+ {
+ private readonly List<(string Match, FakeResultSet Result)> _results = [];
+ private readonly List<(string Match, int RowsAffected)> _nonQueries = [];
+ private readonly List _commands = [];
+ private readonly object _gate = new();
+
+ public static FakeDb Install()
+ {
+ FakeDb fake = new();
+ Db.DbQueryFactory = () => new DbQuery(() => new FakeDbConnection(fake));
+ return fake;
+ }
+
+ public void When(string commandTextContains, FakeResultSet result)
+ => _results.Add((commandTextContains, result));
+
+ public void WhenNonQuery(string commandTextContains, int rowsAffected)
+ => _nonQueries.Add((commandTextContains, rowsAffected));
+
+ public IReadOnlyList Commands
+ {
+ get { lock (_gate) { return [.. _commands]; } }
+ }
+
+ public RecordedCommand? LastCommandMatching(string commandTextContains)
+ => Commands.LastOrDefault(c => c.CommandText.Contains(commandTextContains, StringComparison.OrdinalIgnoreCase));
+
+ internal int RowsAffectedFor(string commandText)
+ {
+ foreach ((string match, int rows) in _nonQueries)
+ {
+ if (commandText.Contains(match, StringComparison.OrdinalIgnoreCase)) { return rows; }
+ }
+
+ return 0;
+ }
+
+ internal FakeResultSet Record(FakeDbCommand command)
+ {
+ Dictionary parameters = [];
+ foreach (object p in command.Parameters)
+ {
+ FakeParameter parameter = (FakeParameter)p;
+ parameters[parameter.ParameterName] = parameter.Value is DBNull ? null : parameter.Value;
+ }
+
+ Transaction? ambient = command.OwnerConnection.AmbientTransactionAtOpen;
+
+ lock (_gate)
+ {
+ _commands.Add(new RecordedCommand
+ {
+ CommandText = command.CommandText,
+ Parameters = parameters,
+ CommandTimeout = command.CommandTimeout,
+ HadAmbientTransaction = ambient != null,
+ AmbientTransactionStatus = ambient?.TransactionInformation.Status,
+ });
+ }
+
+ foreach ((string match, FakeResultSet result) in _results)
+ {
+ if (command.CommandText.Contains(match, StringComparison.OrdinalIgnoreCase)) { return result; }
+ }
+
+ return FakeResultSet.Empty();
+ }
+ }
+}
diff --git a/src/Perpetuum.Tests/Fakes/Data/FakeDbCommand.cs b/src/Perpetuum.Tests/Fakes/Data/FakeDbCommand.cs
new file mode 100644
index 00000000..5b420316
--- /dev/null
+++ b/src/Perpetuum.Tests/Fakes/Data/FakeDbCommand.cs
@@ -0,0 +1,75 @@
+using System.Data;
+
+namespace Perpetuum.Tests.Fakes.Data
+{
+ public sealed class FakeDbCommand(FakeDbConnection connection) : IDbCommand
+ {
+ private readonly FakeDbConnection _connection = connection;
+
+ ///
+ /// The connection that created this command. DbQuery.ExecuteHelper never assigns the
+ /// IDbCommand.Connection property, so the fake carries its own back-reference rather
+ /// than reading one that is always null.
+ ///
+ internal FakeDbConnection OwnerConnection => _connection;
+
+ public string CommandText { get; set; } = string.Empty;
+ public int CommandTimeout { get; set; }
+ public CommandType CommandType { get; set; }
+ public IDbConnection? Connection { get; set; }
+ public IDataParameterCollection Parameters { get; } = new FakeParameterCollection();
+ public IDbTransaction? Transaction { get; set; }
+ public UpdateRowSource UpdatedRowSource { get; set; }
+
+ public void Cancel() { }
+ public IDbDataParameter CreateParameter() => new FakeParameter();
+ public void Prepare() { }
+ public void Dispose() { }
+
+ public IDataReader ExecuteReader() => new FakeDataReader(_connection.Owner.Record(this));
+ public IDataReader ExecuteReader(CommandBehavior behavior) => ExecuteReader();
+
+ public int ExecuteNonQuery()
+ {
+ _ = _connection.Owner.Record(this);
+ return _connection.Owner.RowsAffectedFor(CommandText);
+ }
+
+ public object? ExecuteScalar()
+ {
+ FakeResultSet result = _connection.Owner.Record(this);
+ return result.Rows.Count == 0 ? null : result.Rows[0][0];
+ }
+ }
+
+ public sealed class FakeParameter : IDbDataParameter
+ {
+ public byte Precision { get; set; }
+ public byte Scale { get; set; }
+ public int Size { get; set; }
+ public DbType DbType { get; set; }
+ public ParameterDirection Direction { get; set; }
+ public bool IsNullable => true;
+ public string ParameterName { get; set; } = string.Empty;
+ public string SourceColumn { get; set; } = string.Empty;
+ public DataRowVersion SourceVersion { get; set; }
+ public object? Value { get; set; }
+ }
+
+ public sealed class FakeParameterCollection : List