acdream/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs
Erik 242ce706ef feat(B.4b): InputDispatcher detects double-clicks
Visual test of the B.4b handler revealed the dispatcher never fired
SelectDblLeft. OnMouseDown was only looking up Press and Hold
activations — DoubleClick bindings in KeyBindings.cs were effectively
dead code.

Adds 500ms-threshold double-click detection: tracks last-mouse-down
button + Environment.TickCount64 timestamp; a same-button press within
the threshold additionally fires ActivationType.DoubleClick for the
matching binding (Press still fires normally for the second click).
Clears the pair-state after firing so a triple-click doesn't produce
a second DoubleClick.

Tests cover same-button within threshold, beyond threshold (no fire),
different-button (no fire), and triple-click (fresh pair required).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:10:25 +02:00

116 lines
4.7 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
/// <summary>
/// Tests for double-click detection added to <see cref="InputDispatcher.OnMouseDown"/>
/// in Phase B.4b. The dispatcher tracks the most-recent mouse-down button +
/// timestamp; a same-button press within DoubleClickThresholdMs (500 ms)
/// additionally fires <see cref="ActivationType.DoubleClick"/> for the matching
/// binding on top of the normal <see cref="ActivationType.Press"/>.
/// </summary>
public class InputDispatcherDoubleClickTests
{
/// <summary>
/// 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)
Build()
{
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var bindings = new KeyBindings();
var lmbChord = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1);
var rmbChord = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1);
bindings.Add(new Binding(lmbChord, InputAction.SelectLeft));
bindings.Add(new Binding(lmbChord, InputAction.SelectDblLeft, ActivationType.DoubleClick));
bindings.Add(new Binding(rmbChord, InputAction.SelectRight));
var dispatcher = new InputDispatcher(kb, mouse, bindings);
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (a, t) => fired.Add((a, t));
return (dispatcher, mouse, fired);
}
/// <summary>
/// Two LMB clicks in rapid succession (~10 ms) → Press fires twice AND
/// DoubleClick fires once (on the second click).
/// </summary>
[Fact]
public void SecondClick_WithinThreshold_FiresDoubleClick()
{
var (_, mouse, fired) = Build();
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Thread.Sleep(10);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
// Two SelectLeft Press events (one per click).
Assert.Equal(2, fired.FindAll(e => e == (InputAction.SelectLeft, ActivationType.Press)).Count);
// One SelectDblLeft DoubleClick event on the second click.
Assert.Single(fired, e => e == (InputAction.SelectDblLeft, ActivationType.DoubleClick));
}
/// <summary>
/// Two LMB clicks 600 ms apart → Press fires twice but NO DoubleClick
/// (interval exceeds the 500 ms threshold).
/// </summary>
[Fact]
public void SecondClick_BeyondThreshold_DoesNotFireDoubleClick()
{
var (_, mouse, fired) = Build();
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Thread.Sleep(600);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Assert.Equal(2, fired.FindAll(e => e == (InputAction.SelectLeft, ActivationType.Press)).Count);
Assert.Empty(fired.FindAll(e => e.Item2 == ActivationType.DoubleClick));
}
/// <summary>
/// LMB then RMB in rapid succession → no DoubleClick (different buttons).
/// </summary>
[Fact]
public void DifferentButtons_DoNotFireDoubleClick()
{
var (_, mouse, fired) = Build();
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Thread.Sleep(10);
mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None);
Assert.Empty(fired.FindAll(e => e.Item2 == ActivationType.DoubleClick));
}
/// <summary>
/// Three rapid LMB clicks → exactly one DoubleClick (between clicks 1 and 2).
/// The third click resets the pair-state, so it acts as the "first click" of
/// a new potential double-click rather than firing a second DoubleClick.
/// </summary>
[Fact]
public void ThirdClick_AfterDoubleClick_RequiresFreshPair()
{
var (_, mouse, fired) = Build();
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); // click 1
Thread.Sleep(10);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); // click 2 → DoubleClick fires, state reset
Thread.Sleep(10);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None); // click 3 → no DoubleClick (fresh pair started)
// Three Press events total.
Assert.Equal(3, fired.FindAll(e => e == (InputAction.SelectLeft, ActivationType.Press)).Count);
// Exactly one DoubleClick (between clicks 1 and 2).
Assert.Single(fired.FindAll(e => e == (InputAction.SelectDblLeft, ActivationType.DoubleClick)));
}
}