test: remove ambient timing from double-click contracts

This commit is contained in:
Erik 2026-08-18 11:38:31 +02:00
parent 3684e7b5e7
commit c8c764a40e
5 changed files with 151 additions and 40 deletions

View file

@ -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.

View file

@ -13,4 +13,7 @@
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
<ProjectReference Include="..\AcDream.Runtime\AcDream.Runtime.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.UI.Abstractions.Tests" />
</ItemGroup>
</Project>

View file

@ -34,6 +34,7 @@ public sealed class InputDispatcher : IDisposable
{
private readonly IKeyboardSource _keyboard;
private readonly IMouseSource _mouse;
private readonly Func<long> _getTickCount64;
private KeyBindings _bindings;
private readonly Stack<InputScope> _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<long> 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);
/// <summary>
/// Deterministic test seam for time-sensitive input contracts. Production
/// construction always uses <see cref="Environment.TickCount64"/>.
/// </summary>
internal static InputDispatcher CreateDetached(
IKeyboardSource keyboard,
IMouseSource mouse,
KeyBindings bindings,
Func<long> 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)
{

View file

@ -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.
/// </summary>
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);
}
/// <summary>
@ -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);
}
}

View file

@ -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 = '<unconditional>'
$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 = '<unconditional>'
$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'