Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions Daqifi.Desktop.Test/Daqifi.Desktop.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FlaUI.Core" Version="5.0.0" />
<PackageReference Include="FlaUI.UIA3" Version="5.0.0" />
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="Moq" Version="4.20.72" />
Expand Down
186 changes: 186 additions & 0 deletions Daqifi.Desktop.Test/UITests/ConnectStreamDisconnectTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using FlaUI.Core;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Conditions;
using FlaUI.UIA3;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Daqifi.Desktop.Test.UITests;

/// <summary>
/// Phase 2 of the FlaUI UI-automation scaffold (issue #531).
///
/// Drives the full connect -> enable-channel -> stream -> disconnect happy path
/// against a real DAQiFi device. This test is intentionally [Ignore]'d until a
/// device is wired to the bench so a normal CI run never touches it; remove the
/// Ignore attribute (or run the "UI-Bench" category explicitly) once:
///
/// 1. A DAQiFi Nyquist is attached via USB (or reachable via Wi-Fi).
/// 2. The required XAML controls have been annotated with the AutomationIds
/// referenced below. Each Id has a comment in the XAML pointing back to
/// this test + issue #531 for traceability.
/// 3. The desktop app has been built (Phase 1 verifies the launch path).
///
/// Naming convention used for AutomationIds: "Daqifi.&lt;Pane&gt;.&lt;Control&gt;",
/// e.g. "Daqifi.Connection.AddDeviceButton". Keeping a stable, dotted namespace
/// makes future selectors greppable in the XAML.
/// </summary>
[TestClass]
[Ignore("Phase 2 - needs bench device. See issue #531; remove [Ignore] when a Nyquist is on the bench and XAML AutomationIds are wired up.")]
public class ConnectStreamDisconnectTest
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
{
private const string DesktopExeName = "DAQiFi.exe";
private const string DesktopProjectName = "Daqifi.Desktop";

// AutomationIds that Phase 2 expects to exist in MainWindow / dialogs.
// None of these are wired up yet; add them in the corresponding XAML with
// a comment referencing this test + #531 when enabling the test.
private const string AddDeviceButtonId = "Daqifi.Connection.AddDeviceButton";
private const string ConnectButtonId = "Daqifi.Connection.ConnectButton";
private const string DeviceListId = "Daqifi.Devices.ConnectedList";
private const string FirstChannelToggleId = "Daqifi.Channels.FirstChannelEnable";
private const string StartStreamingId = "Daqifi.Streaming.StartButton";
private const string StopStreamingId = "Daqifi.Streaming.StopButton";
private const string DisconnectButtonId = "Daqifi.Connection.DisconnectButton";
private const string LiveGraphId = "Daqifi.Graph.Live";

private static readonly TimeSpan MainWindowTimeout = TimeSpan.FromSeconds(60);
private static readonly TimeSpan DeviceAppearTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan StreamingDwellTime = TimeSpan.FromSeconds(3);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated

[TestMethod]
[TestCategory("UI-Bench")]
public void ConnectStreamDisconnect_HappyPath()
{
var exePath = TryLocateDesktopExe();
if (exePath is null)
{
Assert.Inconclusive($"Skipped: {DesktopExeName} was not found. Build the {DesktopProjectName} project first.");
}

Application? app = null;
try
{
app = Application.Launch(exePath!);
using var automation = new UIA3Automation();
var mainWindow = app.GetMainWindow(automation, MainWindowTimeout);
Assert.IsNotNull(mainWindow, "Main window did not appear.");
var cf = automation.ConditionFactory;

// ----- Connect -----
var addDevice = FindByAutomationId(mainWindow, cf, AddDeviceButtonId,
"Add-device entry point (USB/Serial picker).");
addDevice.AsButton().Invoke();

var connect = FindByAutomationId(mainWindow, cf, ConnectButtonId,
"Confirm button on the connection dialog.");
connect.AsButton().Invoke();

// Wait for the connected-devices list to show at least one row.
var deviceList = FindByAutomationId(mainWindow, cf, DeviceListId,
"Connected-devices list container.");
WaitFor(() => deviceList.FindAllChildren().Length > 0, DeviceAppearTimeout,
"Device did not appear in the connected list.");

// ----- Enable first channel -----
var firstChannel = FindByAutomationId(mainWindow, cf, FirstChannelToggleId,
"Enable-toggle on the first analog channel.");
// Toggle controls in MahApps are typically ToggleButtons; click via Invoke.
firstChannel.AsToggleButton().Toggle();

// ----- Start streaming, dwell, check graph has data -----
var start = FindByAutomationId(mainWindow, cf, StartStreamingId,
"Start-streaming command button.");
start.AsButton().Invoke();

Thread.Sleep(StreamingDwellTime);

var liveGraph = FindByAutomationId(mainWindow, cf, LiveGraphId,
"Live graph control. Should contain non-zero point count after dwell.");

// OxyPlot / LiveCharts surfaces don't expose data points through UIA, so
// we settle for proof-of-life: the control exists, is visible, and has
// a non-trivial bounding rectangle. Strengthen this once we know which
// graph library is in use (search MainWindow.xaml for oxy:/lvc:).
Assert.IsFalse(liveGraph.IsOffscreen, "Live graph was offscreen after Start.");
Assert.IsTrue(liveGraph.BoundingRectangle.Width > 0
&& liveGraph.BoundingRectangle.Height > 0,
"Live graph had zero-sized bounding box; streaming likely did not start.");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Graph non-zero data unverified 📎 Requirement gap ≡ Correctness

The Phase 2 test does not assert the graph shows non-zero data points after streaming; it only
checks visibility and a non-zero bounding rectangle. This does not satisfy the required validation
of actual streamed data.
Agent Prompt
## Issue description
The compliance requirement for the Phase 2 UI test includes asserting the graph shows non-zero data points after streaming. The current test only checks that the graph control exists/is visible and has a non-zero bounding box, which does not prove that data is streaming.

## Issue Context
UI Automation often cannot access plot data directly; this may require adding a test hook (e.g., a UI element exposing point count, a debug-only automation-accessible label, or another verifiable indicator) to validate non-zero samples.

## Fix Focus Areas
- Daqifi.Desktop.Test/UITests/ConnectStreamDisconnectTest.cs[102-112]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


// ----- Stop streaming -----
var stop = FindByAutomationId(mainWindow, cf, StopStreamingId,
"Stop-streaming command button.");
stop.AsButton().Invoke();

// ----- Disconnect -----
var disconnect = FindByAutomationId(mainWindow, cf, DisconnectButtonId,
"Disconnect command button.");
disconnect.AsButton().Invoke();

WaitFor(() => deviceList.FindAllChildren().Length == 0, DeviceAppearTimeout,
"Device was not removed from the connected list after disconnect.");
}
finally
{
if (app is not null)
{
try
{
app.Close();
// Give the app up to 5s to shut down gracefully before forcing it,
// matching the Phase 1 smoke-test teardown.
app.WaitWhileMainHandleIsMissing(TimeSpan.FromSeconds(5));
if (!app.HasExited) app.Kill();
}
catch { /* best-effort */ }
finally { app.Dispose(); }
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
}
}

private static AutomationElement FindByAutomationId(
Window window, ConditionFactory cf, string automationId, string description)
{
var element = window.FindFirstDescendant(cf.ByAutomationId(automationId));
Assert.IsNotNull(element,
$"Could not find AutomationId '{automationId}' ({description}). " +
"Add AutomationProperties.AutomationId to the XAML and reference issue #531.");
return element!;
}

private static void WaitFor(Func<bool> condition, TimeSpan timeout, string failureMessage)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
try
{
if (condition()) return;
}
catch
{
// Swallow transient UIA errors while controls are still spinning up.
}
Thread.Sleep(200);
}
Assert.Fail(failureMessage + $" (waited {timeout.TotalSeconds:F0}s)");
}

private static string? TryLocateDesktopExe()
{
var testAssemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
?? Directory.GetCurrentDirectory();
var repoRoot = Path.GetFullPath(Path.Combine(testAssemblyDir, "..", "..", "..", ".."));
var configs = new[] { "Debug", "Release" };
var tfms = new[] { "net10.0-windows", "net9.0-windows" };
return (from c in configs
from t in tfms
let p = Path.Combine(repoRoot, DesktopProjectName, "bin", c, t, DesktopExeName)
where File.Exists(p)
select p).FirstOrDefault();
}
}
153 changes: 153 additions & 0 deletions Daqifi.Desktop.Test/UITests/MainWindowSmokeTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using System;
using System.ComponentModel;
using System.IO;
using System.Reflection;
using FlaUI.Core;
using FlaUI.Core.AutomationElements;
using FlaUI.UIA3;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Daqifi.Desktop.Test.UITests;

/// <summary>
/// Phase 1 of the FlaUI UI-automation scaffold (issue #531).
///
/// Launches the DAQiFi Desktop executable, asserts the main window appears and
/// has a sensible title, then closes the app cleanly. Requires the WPF app to
/// already be built; the test is marked Inconclusive (skipped) if the exe is
/// not on disk so a clean CI run without a built desktop is informative rather
/// than red.
///
/// This is intentionally minimal: it proves FlaUI can drive the app under
/// MSTest. Phase 2 builds the connect/stream/disconnect happy path on top of
/// the helpers established here.
/// </summary>
[TestClass]
public class MainWindowSmokeTest
{
// Assembly name is "DAQiFi" per Daqifi.Desktop.csproj <AssemblyName>; the
// produced exe is therefore DAQiFi.exe under the desktop project's bin dir.
private const string DesktopExeName = "DAQiFi.exe";
private const string DesktopProjectName = "Daqifi.Desktop";
private const string ExpectedTitleFragment = "DAQiFi";

// Allow up to 60s for the main window to appear; WPF cold-start can be slow
// on first launch (JIT, MEF composition, MahApps theme load).
private static readonly TimeSpan MainWindowTimeout = TimeSpan.FromSeconds(60);

[TestMethod]
[TestCategory("UI")]
public void MainWindow_Launches_And_HasExpectedTitle()
{
var exePath = TryLocateDesktopExe();
if (exePath is null)
{
Assert.Inconclusive(
$"Skipped: {DesktopExeName} was not found. Build the {DesktopProjectName} " +
"project (Debug or Release, net10.0-windows) before running this UI test. " +
"See issue #531 for the full FlaUI scaffold rollout plan.");
}

Application? app = null;
try
{
try
{
app = Application.Launch(exePath!);
}
catch (Win32Exception ex) when (ex.NativeErrorCode == 740 /* ERROR_ELEVATION_REQUIRED */)
{
// Daqifi.Desktop/app.manifest declares requestedExecutionLevel="requireAdministrator",
// so Process.Start fails with error 740 unless the test runner is elevated.
// FlaUI cannot drive UAC consent, so the working approaches are:
// 1. Run `dotnet test` from an elevated terminal / elevated CI agent, OR
// 2. Build a non-elevated test target of the app (manifest = asInvoker) - see #531.
// Mark Inconclusive so an un-elevated dev box reports SKIPPED rather than FAILED.
Assert.Inconclusive(
"Skipped: DAQiFi.exe requires administrator elevation (app.manifest sets " +
"requestedExecutionLevel=requireAdministrator). Run the test from an elevated " +
"terminal, or switch the manifest to asInvoker for the UI-test build. See #531.");
return; // unreachable; Assert.Inconclusive throws.
}

using var automation = new UIA3Automation();
var mainWindow = app.GetMainWindow(automation, MainWindowTimeout);

Assert.IsNotNull(mainWindow, "Main window was not found within the timeout.");

// The MainWindow code-behind sets Title = $"DAQiFi v{Major}.{Minor}.{Build}".
// Case-insensitive substring match keeps the assertion resilient to
// version-string changes.
var title = mainWindow.Title ?? string.Empty;
Assert.IsTrue(
title.Contains(ExpectedTitleFragment, StringComparison.OrdinalIgnoreCase),
$"Expected window title to contain '{ExpectedTitleFragment}', but was '{title}'.");
}
finally
{
if (app is not null)
{
try
{
app.Close();
// Close() requests graceful shutdown; give the app up to 5s
// to exit on its own. If it hasn't, force it so the next
// test run starts from a clean slate.
app.WaitWhileMainHandleIsMissing(TimeSpan.FromSeconds(5));
if (!app.HasExited)
{
app.Kill();
}
}
catch
{
// Best-effort teardown - never let cleanup mask the real failure.
}
finally
{
app.Dispose();
}
}
}
}

/// <summary>
/// Tries common build-output locations for DAQiFi.exe. Returns null if none
/// of them exist; the caller turns that into Assert.Inconclusive.
/// </summary>
private static string? TryLocateDesktopExe()
{
// The test binary lands under
// <repo>/Daqifi.Desktop.Test/bin/<config>/<tfm>/Daqifi.Desktop.Test.dll
// so the repo root is four levels up.
var testAssemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
?? Directory.GetCurrentDirectory();
var repoRoot = Path.GetFullPath(Path.Combine(testAssemblyDir, "..", "..", "..", ".."));

// Match the same configuration we were built with, then fall back to
// any sibling config so a Release test run can still drive a Debug app
// build (or vice versa).
#if DEBUG
var preferredConfigs = new[] { "Debug", "Release" };
#else
var preferredConfigs = new[] { "Release", "Debug" };
#endif

var tfms = new[] { "net10.0-windows", "net9.0-windows" };

foreach (var config in preferredConfigs)
{
foreach (var tfm in tfms)
{
var candidate = Path.Combine(
repoRoot, DesktopProjectName, "bin", config, tfm, DesktopExeName);
if (File.Exists(candidate))
{
return candidate;
}
}
}

return null;
}
}
Loading