Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions src/winapp-CLI/WinApp.Cli.Tests/ForegroundGuardTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
// Licensed under the MIT License.

using Microsoft.Extensions.Logging;
using Windows.Win32.Foundation;
using WinApp.Cli.Helpers;

namespace WinApp.Cli.Tests;

[TestClass]
[DoNotParallelize]
public class ForegroundGuardTests
{
[TestInitialize]
public void Initialize() => ResetSeams();

[TestCleanup]
public void Cleanup() => ForegroundGuard.ResetNativeSeams();

[TestMethod]
public void ForegroundBelongsTo_ReturnsFalseForZeroNullForegroundOrUnrelatedRoot()
{
Assert.IsFalse(ForegroundGuard.ForegroundBelongsTo(0));

ForegroundGuard.s_getForegroundWindow = () => HWND.Null;
Assert.IsFalse(ForegroundGuard.ForegroundBelongsTo(10));

ForegroundGuard.s_getForegroundWindow = () => new HWND(20);
ForegroundGuard.s_getRootAncestor = _ => HWND.Null;
Assert.IsFalse(ForegroundGuard.ForegroundBelongsTo(10));

ForegroundGuard.s_getRootAncestor = _ => new HWND(30);
Assert.IsFalse(ForegroundGuard.ForegroundBelongsTo(10));
}

[TestMethod]
public void ForegroundBelongsTo_AcceptsExactTargetOrRootAncestor()
{
ForegroundGuard.s_getForegroundWindow = () => new HWND(10);
Assert.IsTrue(ForegroundGuard.ForegroundBelongsTo(10));

ForegroundGuard.s_getForegroundWindow = () => new HWND(20);
ForegroundGuard.s_getRootAncestor = hwnd =>
{
Assert.AreEqual((nint)10, (nint)hwnd);
return new HWND(20);
};

Assert.IsTrue(ForegroundGuard.ForegroundBelongsTo(10));
}

[TestMethod]
public void NoInteractiveDesktop_ReflectsNullForeground()
{
ForegroundGuard.s_getForegroundWindow = () => HWND.Null;
Assert.IsTrue(ForegroundGuard.NoInteractiveDesktop());

ForegroundGuard.s_getForegroundWindow = () => new HWND(1);
Assert.IsFalse(ForegroundGuard.NoInteractiveDesktop());
}

[TestMethod]
public void Classify_CoversAllCombinations()
{
Assert.AreEqual(ForegroundGuard.ForegroundCheck.Proceed, ForegroundGuard.Classify(hasTarget: false, targetIsForeground: false, anyForegroundWindow: false));
Assert.AreEqual(ForegroundGuard.ForegroundCheck.Proceed, ForegroundGuard.Classify(hasTarget: true, targetIsForeground: true, anyForegroundWindow: false));
Assert.AreEqual(ForegroundGuard.ForegroundCheck.NoInteractiveDesktop, ForegroundGuard.Classify(hasTarget: true, targetIsForeground: false, anyForegroundWindow: false));
Assert.AreEqual(ForegroundGuard.ForegroundCheck.ForegroundNotTarget, ForegroundGuard.Classify(hasTarget: true, targetIsForeground: false, anyForegroundWindow: true));
}

[TestMethod]
public void TryEnsureForeground_ProceedsWhenNoTargetOrTargetMatches()
{
var logger = new CapturingLogger();
ForegroundGuard.s_getForegroundWindow = () => new HWND(10);

Assert.IsTrue(ForegroundGuard.TryEnsureForeground(0, logger, json: false, action: "click"));
Assert.IsTrue(ForegroundGuard.TryEnsureForeground(10, logger, json: false, action: "click"));
Assert.AreEqual(0, logger.Messages.Count);
}

[TestMethod]
public void TryEnsureForeground_ReportsNoInteractiveDesktop()
{
var logger = new CapturingLogger();
ForegroundGuard.s_getForegroundWindow = () => HWND.Null;

Assert.IsFalse(ForegroundGuard.TryEnsureForeground(10, logger, json: false, action: "drag"));

Assert.AreEqual(LogLevel.Error, logger.Messages.Single().Level);
StringAssert.Contains(logger.Messages.Single().Message, "No interactive desktop");
}

[TestMethod]
public void TryEnsureForeground_ReportsForegroundNotTargetWithAction()
{
var logger = new CapturingLogger();
ForegroundGuard.s_getForegroundWindow = () => new HWND(20);
ForegroundGuard.s_getRootAncestor = _ => new HWND(30);

Assert.IsFalse(ForegroundGuard.TryEnsureForeground(10, logger, json: false, action: "scroll --wheel"));

Assert.AreEqual(LogLevel.Error, logger.Messages.Single().Level);
StringAssert.Contains(logger.Messages.Single().Message, "refusing to scroll --wheel");
}

[TestMethod]
public void RealForegroundGuard_DelegatesToForegroundGuard()
{
ForegroundGuard.s_getForegroundWindow = () => new HWND(1);
var logger = new CapturingLogger();

// targetHwnd 0 => no target to verify; with a live foreground window present the guard
// classifies Proceed. A true result proves the adapter forwarded to ForegroundGuard.
var result = new RealForegroundGuard().TryEnsureForeground(0, logger, json: false, action: "click");

Assert.IsTrue(result,
"RealForegroundGuard must delegate to ForegroundGuard.TryEnsureForeground and return its result.");
}

private static void ResetSeams()
{
ForegroundGuard.s_getForegroundWindow = () => new HWND(1);
ForegroundGuard.s_getRootAncestor = _ => HWND.Null;
}
}

internal sealed class CapturingLogger : ILogger
{
public List<(LogLevel Level, string Message)> Messages { get; } = [];

public IDisposable? BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;

public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Messages.Add((logLevel, formatter(state, exception)));

private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
183 changes: 183 additions & 0 deletions src/winapp-CLI/WinApp.Cli.Tests/GestureTargetingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Copyright (c) Microsoft Corporation and Contributors. All rights reserved.
// Licensed under the MIT License.

using WinApp.Cli.Helpers;
using WinApp.Cli.Models;
using WinApp.Cli.Services;

namespace WinApp.Cli.Tests;

[TestClass]
public class GestureTargetingTests
{
private static readonly UiSessionInfo Session = new() { ProcessId = 1, ProcessName = "app", WindowHandle = 2 };
private static readonly SelectorExpression Selector = new() { Slug = "btn-ok" };

[TestMethod]
public async Task ResolveStableAsync_ReturnsOkWhenConfirmingReadSettlesWithinTolerance()
{
var ui = new QueueUiAutomation([Element(11, 22, 101, 52)]);
var delays = new List<int>();

var result = await GestureTargeting.ResolveStableAsync(
ui, Session, Selector, Element(10, 20, 100, 50), maxReads: 3, readDelayMs: 17,
(ms, _) => { delays.Add(ms); return Task.CompletedTask; }, CancellationToken.None);

Assert.AreEqual(TargetStatus.Ok, result.Status);
Assert.AreEqual(61, result.CenterX);
Assert.AreEqual(48, result.CenterY);
Assert.AreEqual(1, delays.Count);
Assert.AreEqual(17, delays[0]);
Assert.AreEqual(1, ui.FindCalls);
}

[TestMethod]
public async Task ResolveStableAsync_ReturnsNotFoundWhenElementVanishes()
{
var initial = Element(1, 2, 3, 4);
var result = await GestureTargeting.ResolveStableAsync(
new QueueUiAutomation([null]), Session, Selector, initial, 2, 0,
(_, _) => Task.CompletedTask, CancellationToken.None);

Assert.AreEqual(TargetStatus.NotFound, result.Status);
Assert.AreSame(initial, result.Element);
Assert.AreEqual(0, result.CenterX);
Assert.AreEqual(0, result.CenterY);
}

[TestMethod]
[DataRow(0, 10)]
[DataRow(10, 0)]
public async Task ResolveStableAsync_ReturnsZeroSizeWhenCurrentElementCollapses(double width, double height)
{
var collapsed = Element(1, 2, width, height);
var result = await GestureTargeting.ResolveStableAsync(
new QueueUiAutomation([collapsed]), Session, Selector, Element(1, 2, 10, 10), 1, 0,
(_, _) => Task.CompletedTask, CancellationToken.None);

Assert.AreEqual(TargetStatus.ZeroSize, result.Status);
Assert.AreSame(collapsed, result.Element);
}

[TestMethod]
public async Task ResolveStableAsync_ReturnsMovingWithLastKnownCenterWhenBudgetExhausted()
{
var latest = Element(30, 40, 20, 10);
var result = await GestureTargeting.ResolveStableAsync(
new QueueUiAutomation([Element(10, 10, 20, 10), latest]), Session, Selector, Element(0, 0, 20, 10), 2, 0,
(_, _) => Task.CompletedTask, CancellationToken.None);

Assert.AreEqual(TargetStatus.Moving, result.Status);
Assert.AreSame(latest, result.Element);
Assert.AreEqual(40, result.CenterX);
Assert.AreEqual(45, result.CenterY);
}

[TestMethod]
public async Task ResolveStableAsync_ZeroReadBudgetReportsInitialAsMoving()
{
var initial = Element(2, 4, 10, 20);
var result = await GestureTargeting.ResolveStableAsync(
new QueueUiAutomation([]), Session, Selector, initial, 0, 0, null, CancellationToken.None);

Assert.AreEqual(TargetStatus.Moving, result.Status);
Assert.AreSame(initial, result.Element);
Assert.AreEqual(7, result.CenterX);
Assert.AreEqual(14, result.CenterY);
}

[TestMethod]
public async Task ConfirmStillAsync_CoversNotFoundZeroSizeMovingAndOk()
{
var expected = Element(10, 20, 100, 40);

var notFound = await GestureTargeting.ConfirmStillAsync(new QueueUiAutomation([null]), Session, Selector, expected, CancellationToken.None);
Assert.AreEqual(TargetStatus.NotFound, notFound.Status);
Assert.AreSame(expected, notFound.Element);

var zero = Element(10, 20, 0, 40);
var zeroResult = await GestureTargeting.ConfirmStillAsync(new QueueUiAutomation([zero]), Session, Selector, expected, CancellationToken.None);
Assert.AreEqual(TargetStatus.ZeroSize, zeroResult.Status);
Assert.AreSame(zero, zeroResult.Element);

var movedElement = Element(20, 20, 100, 40);
var moved = await GestureTargeting.ConfirmStillAsync(new QueueUiAutomation([movedElement]), Session, Selector, expected, CancellationToken.None);
Assert.AreEqual(TargetStatus.Moving, moved.Status);
Assert.AreEqual(70, moved.CenterX);
Assert.AreEqual(40, moved.CenterY);

var okElement = Element(12, 19, 101, 39);
var ok = await GestureTargeting.ConfirmStillAsync(new QueueUiAutomation([okElement]), Session, Selector, expected, CancellationToken.None);
Assert.AreEqual(TargetStatus.Ok, ok.Status);
Assert.AreEqual(62, ok.CenterX);
Assert.AreEqual(38, ok.CenterY);
}

[TestMethod]
public void TryReport_ReturnsTrueForOkWithoutLogging()
{
var logger = new CapturingLogger();
var result = GestureTargeting.TryReport(new StableTarget(TargetStatus.Ok, Element(0, 0, 10, 10), 5, 5), logger, json: false, selector: "btn", action: "click");

Assert.IsTrue(result);
Assert.AreEqual(0, logger.Messages.Count);
}

[TestMethod]
[DataRow((int)TargetStatus.ZeroSize, "collapsed to zero size")]
[DataRow((int)TargetStatus.NotFound, "could not be re-resolved")]
[DataRow((int)TargetStatus.Moving, "still moving/resizing")]
public void TryReport_LogsSpecificAbortReason(int statusValue, string expectedMessage)
{
var logger = new CapturingLogger();

var result = GestureTargeting.TryReport(new StableTarget((TargetStatus)statusValue, Element(0, 0, 10, 10), 5, 5), logger, json: false, selector: "btn", action: "drag");

Assert.IsFalse(result);
StringAssert.Contains(logger.Messages.Single().Message, expectedMessage);
StringAssert.Contains(logger.Messages.Single().Message, "drag");
}

private static UiElement Element(double x, double y, double width, double height)
=> new() { X = x, Y = y, Width = width, Height = height, Type = "Button", Name = "OK" };

private sealed class QueueUiAutomation : IUiAutomationService
{
private readonly Queue<UiElement?> _elements;
public int FindCalls { get; private set; }

public QueueUiAutomation(IEnumerable<UiElement?> elements) => _elements = new Queue<UiElement?>(elements);

public Task<UiElement?> FindSingleElementAsync(UiSessionInfo session, SelectorExpression selector, CancellationToken ct)
{
Assert.AreSame(Session, session);
Assert.AreSame(Selector, selector);
FindCalls++;
return Task.FromResult(_elements.Dequeue());
}

public List<(nint Hwnd, int Pid, string Title)> FindWindowsByTitle(string titleQuery) => throw new NotImplementedException();
public List<(nint Hwnd, int Pid, string Title)> FindWindowsByPid(int pid) => throw new NotImplementedException();
public Task<UiElement[]> InspectAsync(UiSessionInfo session, string? elementId, int depth, CancellationToken ct) => throw new NotImplementedException();
public Task<UiElement[]> InspectAncestorsAsync(UiSessionInfo session, string elementId, CancellationToken ct) => throw new NotImplementedException();
public Task<UiElement[]> SearchAsync(UiSessionInfo session, SelectorExpression selector, int maxResults, CancellationToken ct) => throw new NotImplementedException();
public Task<Dictionary<string, object?>> GetPropertiesAsync(UiSessionInfo session, UiElement element, string? propertyName, CancellationToken ct) => throw new NotImplementedException();
public Task<(byte[] Pixels, int Width, int Height)> ScreenshotAsync(UiSessionInfo session, string? elementId, bool captureScreen, bool focus, CancellationToken ct) => throw new NotImplementedException();
public Task<RecordCaptureResult> RecordAsync(UiSessionInfo session, string? elementId, RecordOptions options, CancellationToken ct, Action? onRecordingStarted = null) => throw new NotImplementedException();
public Task<string> InvokeAsync(UiSessionInfo session, UiElement element, CancellationToken ct) => throw new NotImplementedException();
public Task SetValueAsync(UiSessionInfo session, UiElement element, string text, CancellationToken ct) => throw new NotImplementedException();
public Task FocusAsync(UiSessionInfo session, UiElement element, CancellationToken ct) => throw new NotImplementedException();
public Task ScrollIntoViewAsync(UiSessionInfo session, UiElement element, CancellationToken ct) => throw new NotImplementedException();
public Task ScrollContainerAsync(UiSessionInfo session, UiElement element, string? direction, string? to, CancellationToken ct) => throw new NotImplementedException();
public Task<UiElement?> GetFocusedElementAsync(UiSessionInfo session, CancellationToken ct) => throw new NotImplementedException();
public Task<string?> GetTextAsync(UiSessionInfo session, UiElement element, CancellationToken ct) => throw new NotImplementedException();
public bool TryGetWindowRect(long hwnd, out WinApp.Cli.Helpers.PointerRect rect)
{
rect = default;
return false;
}
}
}



Loading
Loading