test: remove ambient timing from double-click contracts
This commit is contained in:
parent
3684e7b5e7
commit
c8c764a40e
5 changed files with 151 additions and 40 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue