diff --git a/docs/reviews/2026-08-18-r3-test-truth-ledger.md b/docs/reviews/2026-08-18-r3-test-truth-ledger.md
index 09677293..abea0ba2 100644
--- a/docs/reviews/2026-08-18-r3-test-truth-ledger.md
+++ b/docs/reviews/2026-08-18-r3-test-truth-ledger.md
@@ -129,7 +129,7 @@ identifiable in the lane report.
| T-006 misleading installed-DAT reason | resolved in batch B | Shared opt-in is now `ACDREAM_RUN_INSTALLED_DAT_TESTS=1` (legacy switch retained), the reason names the lane, and all nine owners carry `Lane=InstalledDat`. |
| T-007 271 silent passing gates | lane classification complete in batch D; body cleanup active | Of 282 current candidates, 280 are true prerequisite gates and now have an explicit lane. The two unlaned methods are reviewed false positives that assert the current Windows/Linux branch before returning. Empty-return removal remains, lane by lane. |
| T-008 incapable-of-failing diagnostics | batch A plus diagnostic lane work | Delete the literal wiring smoke test; repair the GPU contract tautology; later re-home output-only apparatus. |
-| T-009 wall-clock double-click tests | open | Introduce a behavior-preserving injectable monotonic clock and deterministic fake time. |
+| T-009 wall-clock double-click tests | resolved in batch E | Four sleeps were replaced by a deterministic test clock behind an internal factory overload. The production factory still reads `Environment.TickCount64` exactly as before. |
| T-010 two useless cases | high-confidence cleanup batch A | Delete `SmokeTest.TestProject_IsWired` and `ChaseCameraTests.ImplementsICamera`; compilation already proves both claims. |
| T-011 51 output-only methods | classified in batch C | The reviewed current set is 51 methods / 70 cases. All carry `Purpose=Diagnostic`, preserving the apparatus while removing it from release pass totals. Contract-shaped names remain explicitly flagged until a stable oracle exists. |
| T-012 source-text freezes | requires semantic replacement map | Retain whole-tree dependency rules; remove exact-text freezes only when an equivalent semantic/behavioral guard is identified. |
@@ -310,3 +310,23 @@ netted minus one case; batch B removed six non-hermetic passes; batch C removed
passes; and consolidating the six Avalonia sessions into one removed five case
IDs without removing any assertion phase. All 77 former default skips are also
accounted for by the PVS deletion and batch-B lane/deletion decisions.
+
+## Batch E indirect gates and deterministic input time
+
+The inventory now follows same-file helper calls when looking for prerequisite
+returns. It found one additional path:
+`LauncherSelfUpdateProcessTests.BackupJunctionOrSymlinkAfterCanonicalCrashCannotMutateOutsideOrLaunch`
+calls `CreateDirectoryLink`, whose non-Windows branch creates the symbolic link
+and then returns before the Windows `mklink /J` implementation. This is a
+reviewed cross-platform control-flow branch, not a prerequisite gate or silent
+pass. The combined direct/indirect candidate count is therefore 283: 280 true
+lane-owned gates and three reviewed branch false positives.
+
+The four `InputDispatcherDoubleClickTests` no longer sleep for 10 or 600 real
+milliseconds. `InputDispatcher` has an internal, test-assembly-only factory
+overload accepting the same millisecond tick delegate used by double-click
+recognition. The public production factory remains wired directly to
+`Environment.TickCount64`; only the tests use a manually advanced counter.
+The focused class passes 4/4 in 17 ms with exact 10 ms and 600 ms virtual
+intervals. The complete Release build then passed with 0 warnings/errors, and
+the no-retry hermetic gate passed 14,392/14,392 with zero skips or failures.
diff --git a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj
index a52a54b4..1672b6ea 100644
--- a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj
+++ b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj
@@ -13,4 +13,7 @@
+
+
+
diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs
index 1471d35f..80cfc680 100644
--- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs
+++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs
@@ -34,6 +34,7 @@ public sealed class InputDispatcher : IDisposable
{
private readonly IKeyboardSource _keyboard;
private readonly IMouseSource _mouse;
+ private readonly Func _getTickCount64;
private KeyBindings _bindings;
private readonly Stack _scopes = new();
private InputScope? _combatScope;
@@ -67,11 +68,14 @@ public sealed class InputDispatcher : IDisposable
private InputDispatcher(
IKeyboardSource keyboard,
IMouseSource mouse,
- KeyBindings bindings)
+ KeyBindings bindings,
+ Func getTickCount64)
{
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
+ _getTickCount64 = getTickCount64
+ ?? throw new ArgumentNullException(nameof(getTickCount64));
_scopes.Push(InputScope.Always); // bottom of the stack
_scopes.Push(InputScope.Game); // default top for normal play
@@ -87,7 +91,18 @@ public sealed class InputDispatcher : IDisposable
IKeyboardSource keyboard,
IMouseSource mouse,
KeyBindings bindings) =>
- new(keyboard, mouse, bindings);
+ new(keyboard, mouse, bindings, static () => Environment.TickCount64);
+
+ ///
+ /// Deterministic test seam for time-sensitive input contracts. Production
+ /// construction always uses .
+ ///
+ internal static InputDispatcher CreateDetached(
+ IKeyboardSource keyboard,
+ IMouseSource mouse,
+ KeyBindings bindings,
+ Func getTickCount64) =>
+ new(keyboard, mouse, bindings, getTickCount64);
public bool IsDisposalComplete =>
_sourceAttached.All(static attached => !attached);
@@ -570,7 +585,7 @@ public sealed class InputDispatcher : IDisposable
// -> additionally fire ActivationType.DoubleClick for any matching
// binding. Press has already fired for the second click (same as a
// single click); DoubleClick is the *additional* signal.
- long nowMs = Environment.TickCount64;
+ long nowMs = _getTickCount64();
if (_lastMouseDownButton == button
&& nowMs - _lastMouseDownTickMs <= DoubleClickThresholdMs)
{
diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs
index 891cf049..040dcbc4 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Threading;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
@@ -19,7 +18,7 @@ public class InputDispatcherDoubleClickTests
/// Build a dispatcher wired with LMB Press → SelectLeft,
/// LMB DoubleClick → SelectDblLeft, and RMB Press → SelectRight.
///
- private static (InputDispatcher dispatcher, FakeMouseSource mouse, List<(InputAction, ActivationType)> fired)
+ private static (InputDispatcher dispatcher, FakeMouseSource mouse, ManualTickClock clock, List<(InputAction, ActivationType)> fired)
Build()
{
var kb = new FakeKeyboardSource();
@@ -33,11 +32,12 @@ public class InputDispatcherDoubleClickTests
bindings.Add(new Binding(lmbChord, InputAction.SelectDblLeft, ActivationType.DoubleClick));
bindings.Add(new Binding(rmbChord, InputAction.SelectRight));
- var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
+ var clock = new ManualTickClock();
+ var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings, clock.Read);
dispatcher.Attach();
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (a, t) => fired.Add((a, t));
- return (dispatcher, mouse, fired);
+ return (dispatcher, mouse, clock, fired);
}
///
@@ -47,10 +47,10 @@ public class InputDispatcherDoubleClickTests
[Fact]
public void SecondClick_WithinThreshold_FiresDoubleClick()
{
- var (_, mouse, fired) = Build();
+ var (_, mouse, clock, fired) = Build();
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
- Thread.Sleep(10);
+ clock.Advance(10);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
// Two SelectLeft Press events (one per click).
@@ -67,10 +67,10 @@ public class InputDispatcherDoubleClickTests
[Fact]
public void SecondClick_BeyondThreshold_DoesNotFireDoubleClick()
{
- var (_, mouse, fired) = Build();
+ var (_, mouse, clock, fired) = Build();
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
- Thread.Sleep(600);
+ clock.Advance(600);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Assert.Equal(2, fired.FindAll(e => e == (InputAction.SelectLeft, ActivationType.Press)).Count);
@@ -83,10 +83,10 @@ public class InputDispatcherDoubleClickTests
[Fact]
public void DifferentButtons_DoNotFireDoubleClick()
{
- var (_, mouse, fired) = Build();
+ var (_, mouse, clock, fired) = Build();
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
- Thread.Sleep(10);
+ clock.Advance(10);
mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None);
Assert.Empty(fired.FindAll(e => e.Item2 == ActivationType.DoubleClick));
@@ -100,12 +100,12 @@ public class InputDispatcherDoubleClickTests
[Fact]
public void ThirdClick_AfterDoubleClick_RequiresFreshPair()
{
- var (_, mouse, fired) = Build();
+ var (_, mouse, clock, fired) = Build();
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); // click 1
- Thread.Sleep(10);
+ clock.Advance(10);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); // click 2 → DoubleClick fires, state reset
- Thread.Sleep(10);
+ clock.Advance(10);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); // click 3 → no DoubleClick (fresh pair started)
// Three Press events total.
@@ -114,4 +114,14 @@ public class InputDispatcherDoubleClickTests
// Exactly one DoubleClick (between clicks 1 and 2).
Assert.Single(fired.FindAll(e => e == (InputAction.SelectDblLeft, ActivationType.DoubleClick)));
}
+
+ private sealed class ManualTickClock
+ {
+ private long _tickCount64;
+
+ public long Read() => _tickCount64;
+
+ public void Advance(long milliseconds) =>
+ _tickCount64 = checked(_tickCount64 + milliseconds);
+ }
}
diff --git a/tools/audit-test-inventory.ps1 b/tools/audit-test-inventory.ps1
index a0ef134a..c53ef498 100644
--- a/tools/audit-test-inventory.ps1
+++ b/tools/audit-test-inventory.ps1
@@ -6,9 +6,9 @@
Parses tracked C# files under tests/ with the Roslyn assemblies bundled in
the active .NET SDK. The report is evidence for the R3 test-truth audit: it
records test attributes and traits, static skips, empty-return sites,
- assertion/throw signals (including same-file helper calls), diagnostic
- output signals, wall-clock waits, environment-variable dependencies, and
- source-text reads.
+ prerequisite gates reached through same-file helper calls, assertion/throw
+ signals (including same-file helper calls), diagnostic output signals,
+ wall-clock waits, environment-variable dependencies, and source-text reads.
This is a candidate generator, not a semantic proof. In particular, an
output-only candidate can call a failure-capable helper declared in another
@@ -107,6 +107,35 @@ function Get-ReturnGateKind {
return $null
}
+function Get-EmptyReturnSites {
+ param(
+ [Parameter(Mandatory)]$Tree,
+ [Parameter(Mandatory)]$Method
+ )
+
+ $sites = [Collections.Generic.List[object]]::new()
+ foreach ($returnNode in @($Method.DescendantNodes() | Where-Object {
+ $_.GetType().Name -eq 'ReturnStatementSyntax' -and
+ $null -eq $_.Expression
+ })) {
+ $condition = ''
+ $cursor = $returnNode.Parent
+ while ($null -ne $cursor -and $cursor -ne $Method) {
+ if ($cursor.GetType().Name -eq 'IfStatementSyntax') {
+ $condition = $cursor.Condition.ToString()
+ break
+ }
+ $cursor = $cursor.Parent
+ }
+ $sites.Add([ordered]@{
+ Line = Get-NodeLine $Tree $returnNode
+ Condition = $condition
+ GateKind = Get-ReturnGateKind $condition
+ })
+ }
+ return @($sites)
+}
+
function Get-InvocationName {
param([Parameter(Mandatory)]$Invocation)
@@ -188,6 +217,44 @@ function Test-RecursiveSignal {
return $false
}
+function Get-RecursivePrerequisiteGateSites {
+ param(
+ [Parameter(Mandatory)][string]$MethodKey,
+ [Parameter(Mandatory)][hashtable]$MethodMap,
+ [Parameter(Mandatory)][hashtable]$GateMap,
+ [Parameter(Mandatory)][hashtable]$CallMap,
+ [Parameter(Mandatory)][hashtable]$Visited
+ )
+
+ if ($Visited.ContainsKey($MethodKey)) {
+ return @()
+ }
+ $Visited[$MethodKey] = $true
+
+ $sites = [Collections.Generic.List[object]]::new()
+ foreach ($calledName in @($CallMap[$MethodKey])) {
+ foreach ($candidateKey in @($MethodMap.Keys | Where-Object {
+ $_.EndsWith("::$calledName", [StringComparison]::Ordinal)
+ })) {
+ foreach ($gate in @($GateMap[$candidateKey] | Where-Object {
+ $null -ne $_.GateKind
+ })) {
+ $sites.Add([ordered]@{
+ Helper = $candidateKey
+ Line = $gate.Line
+ Condition = $gate.Condition
+ GateKind = $gate.GateKind
+ })
+ }
+ foreach ($nestedSite in @(Get-RecursivePrerequisiteGateSites `
+ $candidateKey $MethodMap $GateMap $CallMap $Visited)) {
+ $sites.Add($nestedSite)
+ }
+ }
+ }
+ return @($sites | Sort-Object Helper, Line, Condition -Unique)
+}
+
$trackedFiles = @(& git -C $repoRoot ls-files tests | Where-Object {
$_.EndsWith('.cs', [StringComparison]::OrdinalIgnoreCase)
})
@@ -214,6 +281,7 @@ foreach ($relativePath in $trackedFiles) {
$methodMap = @{}
$failureMap = @{}
$outputMap = @{}
+ $gateMap = @{}
$callMap = @{}
foreach ($method in $allMethods) {
@@ -222,6 +290,7 @@ foreach ($relativePath in $trackedFiles) {
$methodMap[$key] = $method
$failureMap[$key] = Test-DirectFailureSignal $method
$outputMap[$key] = Test-DirectOutputSignal $method
+ $gateMap[$key] = @(Get-EmptyReturnSites $tree $method)
$callMap[$key] = @($method.DescendantNodes() |
Where-Object { $_.GetType().Name -eq 'InvocationExpressionSyntax' } |
ForEach-Object { Get-InvocationName $_ } |
@@ -247,26 +316,9 @@ foreach ($relativePath in $trackedFiles) {
$hasOutputSignal = Test-RecursiveSignal `
$key $methodMap $outputMap $callMap @{}
- $emptyReturns = [Collections.Generic.List[object]]::new()
- foreach ($returnNode in @($method.DescendantNodes() | Where-Object {
- $_.GetType().Name -eq 'ReturnStatementSyntax' -and
- $null -eq $_.Expression
- })) {
- $condition = ''
- $cursor = $returnNode.Parent
- while ($null -ne $cursor -and $cursor -ne $method) {
- if ($cursor.GetType().Name -eq 'IfStatementSyntax') {
- $condition = $cursor.Condition.ToString()
- break
- }
- $cursor = $cursor.Parent
- }
- $emptyReturns.Add([ordered]@{
- Line = Get-NodeLine $tree $returnNode
- Condition = $condition
- GateKind = Get-ReturnGateKind $condition
- })
- }
+ $emptyReturns = @($gateMap[$key])
+ $helperPrerequisiteReturns = @(Get-RecursivePrerequisiteGateSites `
+ $key $methodMap $gateMap $callMap @{})
$traits = [Collections.Generic.List[string]]::new()
$staticSkip = $null
@@ -315,9 +367,11 @@ foreach ($relativePath in $trackedFiles) {
Traits = @($traits)
StaticSkip = $staticSkip
EmptyReturns = @($emptyReturns)
+ HelperPrerequisiteReturns = @($helperPrerequisiteReturns)
PrerequisiteReturnCandidate = @($emptyReturns | Where-Object {
$null -ne $_.GateKind
}).Count -gt 0
+ HelperPrerequisiteReturnCandidate = $helperPrerequisiteReturns.Count -gt 0
HasFailureSignal = $hasFailureSignal
HasOutputSignal = $hasOutputSignal
OutputOnlyCandidate = $hasOutputSignal -and -not $hasFailureSignal
@@ -347,6 +401,15 @@ $summary = [ordered]@{
PrerequisiteReturnCandidates = @($orderedRecords | Where-Object {
$_.PrerequisiteReturnCandidate
}).Count
+ HelperPrerequisiteReturnCandidates = @($orderedRecords | Where-Object {
+ $_.HelperPrerequisiteReturnCandidate
+ }).Count
+ HelperPrerequisiteReturnSites = @($orderedRecords |
+ ForEach-Object { $_.HelperPrerequisiteReturns }).Count
+ AnyPrerequisiteReturnCandidates = @($orderedRecords | Where-Object {
+ $_.PrerequisiteReturnCandidate -or
+ $_.HelperPrerequisiteReturnCandidate
+ }).Count
OutputOnlyCandidates = @($orderedRecords | Where-Object { $_.OutputOnlyCandidate }).Count
DiagnosticMethods = @($orderedRecords | Where-Object {
$_.Traits -contains 'Purpose, Diagnostic'