Tests: headless main-window coverage (all tabs + controls) + fix Alt+L clash
Local checkpoint - NOT for public release. Closes the UI-coverage gap Ed pushed on. - MainForm gets a `headless` ctor flag (defaults false → real startup path byte-for-byte unchanged). When true it builds the WHOLE window — every tab, control and menu — but skips the OS touches: global-hotkey registration, the status/device-refresh timers, the device-change notifier, and the audio-backend mode switch in ApplyAsioMode. The disruptive startup work (Connect + sockets, UPnP, update check) already lives in the Shown handler, which never fires when a test constructs the form without showing it — so a headless construction is naturally side-effect-free. - New self-test "Main window coverage": constructs the headless main window and audits the lot — accessible names present, Alt mnemonics unique per group, tab order forms no cycle. 4 tabs, 41 interactive controls. - It immediately EARNED ITS KEEP: caught a real Alt+L collision — the WASAPI latency and ASIO latency labels both claimed Alt+L in the same panel, so in WASAPI+ASIO mode the ASIO field was unreachable by its shortcut. Moved ASIO latency to Alt+I; WASAPI keeps Alt+L. Gate 23/23. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
fd981211c0
commit
c8fed26115
@@ -273,7 +273,7 @@ public sealed class MainForm : Form
|
|||||||
// become the WASAPI-lane controls (Alt+W / Alt+Y) and these new ASIO controls take over
|
// become the WASAPI-lane controls (Alt+W / Alt+Y) and these new ASIO controls take over
|
||||||
// the simpler Alt+L / Alt+T mnemonics — ASIO is the "headline" lane in the new mode
|
// the simpler Alt+L / Alt+T mnemonics — ASIO is the "headline" lane in the new mode
|
||||||
// (the reason a user picked it) so it gets the more memorable shortcuts.
|
// (the reason a user picked it) so it gets the more memorable shortcuts.
|
||||||
private readonly NumericUpDown maxLatencyAsioBox = new() { Minimum = 1, Maximum = 500, Increment = 1, Value = 10, Width = 90, AccessibleName = "ASIO latency in milliseconds (Alt+L)" };
|
private readonly NumericUpDown maxLatencyAsioBox = new() { Minimum = 1, Maximum = 500, Increment = 1, Value = 10, Width = 90, AccessibleName = "ASIO latency in milliseconds (Alt+I)" };
|
||||||
private readonly AccessibleCheckBox continuousTuneAsioBox = new() { Text = "Continuous auto-tune ASIO latency", AutoSize = true };
|
private readonly AccessibleCheckBox continuousTuneAsioBox = new() { Text = "Continuous auto-tune ASIO latency", AutoSize = true };
|
||||||
private readonly ListBox smoothnessBox = new() { Width = 420, Height = 200, IntegralHeight = false, AccessibleName = "Buffer smoothness (Alt+B)" };
|
private readonly ListBox smoothnessBox = new() { Width = 420, Height = 200, IntegralHeight = false, AccessibleName = "Buffer smoothness (Alt+B)" };
|
||||||
private readonly ListBox artefactBox = new() { Width = 420, Height = 60, IntegralHeight = false, AccessibleName = "Artefact sound type (Alt+A) — controls how audio gaps sound" };
|
private readonly ListBox artefactBox = new() { Width = 420, Height = 60, IntegralHeight = false, AccessibleName = "Artefact sound type (Alt+A) — controls how audio gaps sound" };
|
||||||
@@ -624,6 +624,13 @@ public sealed class MainForm : Form
|
|||||||
// Program.cs after the form closes; non-null means "user clicked Switch in Manage profiles —
|
// Program.cs after the form closes; non-null means "user clicked Switch in Manage profiles —
|
||||||
// re-launch the form under that profile."
|
// re-launch the form under that profile."
|
||||||
private ProfileStore? profileStore;
|
private ProfileStore? profileStore;
|
||||||
|
// Headless/test construction: when true the constructor builds the full window (every tab, control
|
||||||
|
// and menu) but SKIPS the calls that touch the OS — registering global hotkeys, starting the status /
|
||||||
|
// device-refresh timers, the device-change notifier, and the audio-backend mode switch. The
|
||||||
|
// disruptive startup work (Connect + sockets, UPnP, update check) already lives in the Shown handler,
|
||||||
|
// which never fires when a test constructs the form without showing it. Defaults false, so the real
|
||||||
|
// app's startup path is byte-for-byte unchanged. Lets the self-test audit the whole main window.
|
||||||
|
private readonly bool headless;
|
||||||
private string? currentProfileTitle;
|
private string? currentProfileTitle;
|
||||||
// True when the active profile has its ReadOnly flag set. Drives three behaviours:
|
// True when the active profile has its ReadOnly flag set. Drives three behaviours:
|
||||||
// * The window title gets a " (read-only)" suffix so NVDA / sighted users see
|
// * The window title gets a " (read-only)" suffix so NVDA / sighted users see
|
||||||
@@ -741,9 +748,10 @@ public sealed class MainForm : Form
|
|||||||
|
|
||||||
public MainForm() : this(null, null, null, null) { }
|
public MainForm() : this(null, null, null, null) { }
|
||||||
|
|
||||||
public MainForm(ProfileStore? profileStore, Profile? profile, string? loadedTitle, string? loadedPath = null)
|
public MainForm(ProfileStore? profileStore, Profile? profile, string? loadedTitle, string? loadedPath = null, bool headless = false)
|
||||||
{
|
{
|
||||||
this.profileStore = profileStore;
|
this.profileStore = profileStore;
|
||||||
|
this.headless = headless;
|
||||||
currentProfileTitle = loadedTitle;
|
currentProfileTitle = loadedTitle;
|
||||||
// Resolve the active profile's full path from whichever bit of info Program.cs
|
// Resolve the active profile's full path from whichever bit of info Program.cs
|
||||||
// passed in. If a path was explicitly given (Open-from-arbitrary-folder flow),
|
// passed in. If a path was explicitly given (Open-from-arbitrary-folder flow),
|
||||||
@@ -1367,7 +1375,7 @@ public sealed class MainForm : Form
|
|||||||
// the moment we start announcing, those addresses get directly contacted (bridges
|
// the moment we start announcing, those addresses get directly contacted (bridges
|
||||||
// Tailscale/VPN where broadcast doesn't traverse).
|
// Tailscale/VPN where broadcast doesn't traverse).
|
||||||
PushDiscoveryUnicastHints();
|
PushDiscoveryUnicastHints();
|
||||||
hotkeyController.Initialize(this);
|
if (!headless) hotkeyController.Initialize(this);
|
||||||
// Announce each configurable global hotkey on the control / menu item it drives, so NVDA
|
// Announce each configurable global hotkey on the control / menu item it drives, so NVDA
|
||||||
// reads "… press Control+Shift+Alt+R anywhere" when you land on it.
|
// reads "… press Control+Shift+Alt+R anywhere" when you land on it.
|
||||||
UpdateHotkeyAnnouncements();
|
UpdateHotkeyAnnouncements();
|
||||||
@@ -1570,6 +1578,10 @@ public sealed class MainForm : Form
|
|||||||
BeginInvoke(new Action(RunStartupNotices));
|
BeginInvoke(new Action(RunStartupNotices));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Headless test build stops here: no timers, no OS device-change registration. Everything above
|
||||||
|
// has built the full window (tabs, controls, menus) for the self-test to walk.
|
||||||
|
if (headless) return;
|
||||||
|
|
||||||
statusTimer.Start();
|
statusTimer.Start();
|
||||||
|
|
||||||
// Hot-plug detection is event-driven (see the deviceRefreshTimer comment): register for
|
// Hot-plug detection is event-driven (see the deviceRefreshTimer comment): register for
|
||||||
@@ -4395,7 +4407,7 @@ public sealed class MainForm : Form
|
|||||||
// (Alt+W)" / "Continuous auto-tune WASAPI (Alt+Y)", surrendering L/T to ASIO. In every
|
// (Alt+W)" / "Continuous auto-tune WASAPI (Alt+Y)", surrendering L/T to ASIO. In every
|
||||||
// classic mode this row is hidden via UpdateBothIndependentVisibility and the WASAPI
|
// classic mode this row is hidden via UpdateBothIndependentVisibility and the WASAPI
|
||||||
// row keeps the original "Audio latency (Alt+L)" labels.
|
// row keeps the original "Audio latency (Alt+L)" labels.
|
||||||
asioLatencyLabel = new Label { Text = "ASIO latency in milliseconds (Alt+&L)", AutoSize = true, Anchor = AnchorStyles.Left };
|
asioLatencyLabel = new Label { Text = "AS&IO latency in milliseconds (Alt+I)", AutoSize = true, Anchor = AnchorStyles.Left };
|
||||||
asioLatencyLabel.Click += (_, _) => FocusControl(maxLatencyAsioBox);
|
asioLatencyLabel.Click += (_, _) => FocusControl(maxLatencyAsioBox);
|
||||||
SelectAllOnFocus(maxLatencyAsioBox);
|
SelectAllOnFocus(maxLatencyAsioBox);
|
||||||
maxLatencyAsioBox.Value = Math.Clamp(settings.LoadMaxLatencyMsAsio(), (int)maxLatencyAsioBox.Minimum, (int)maxLatencyAsioBox.Maximum);
|
maxLatencyAsioBox.Value = Math.Clamp(settings.LoadMaxLatencyMsAsio(), (int)maxLatencyAsioBox.Minimum, (int)maxLatencyAsioBox.Maximum);
|
||||||
@@ -6590,8 +6602,13 @@ public sealed class MainForm : Form
|
|||||||
var asioDriverArg = ModeUsesAsio(resolvedMode) ? driver : null;
|
var asioDriverArg = ModeUsesAsio(resolvedMode) ? driver : null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
sender.SetAudioMode(resolvedMode, asioDriverArg);
|
// In a headless test build, don't switch the real audio backends (which can open an ASIO
|
||||||
receiver.SetAudioMode(resolvedMode, asioDriverArg);
|
// driver); the list visibility below is UI-only and still runs so the mode toggle is testable.
|
||||||
|
if (!headless)
|
||||||
|
{
|
||||||
|
sender.SetAudioMode(resolvedMode, asioDriverArg);
|
||||||
|
receiver.SetAudioMode(resolvedMode, asioDriverArg);
|
||||||
|
}
|
||||||
logFile.Event(resolvedMode == AudioMode.WasapiOnly
|
logFile.Event(resolvedMode == AudioMode.WasapiOnly
|
||||||
? "audio backend: WASAPI only (fast path)"
|
? "audio backend: WASAPI only (fast path)"
|
||||||
: $"audio backend: WASAPI + ASIO driver \"{asioDriverArg}\" (independent lanes, no mix)");
|
: $"audio backend: WASAPI + ASIO driver \"{asioDriverArg}\" (independent lanes, no mix)");
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ internal static class SelfTest
|
|||||||
RunStep(results, "Diagnostics report privacy", DiagnosticsPrivacy);
|
RunStep(results, "Diagnostics report privacy", DiagnosticsPrivacy);
|
||||||
RunStep(results, "Bundled resources present", ResourcesPresent);
|
RunStep(results, "Bundled resources present", ResourcesPresent);
|
||||||
RunStep(results, "Dialog accessibility (names + mnemonics)", AccessibilityAudit);
|
RunStep(results, "Dialog accessibility (names + mnemonics)", AccessibilityAudit);
|
||||||
|
RunStep(results, "Main window coverage (all tabs + controls)", MainWindowCoverage);
|
||||||
|
|
||||||
var failed = results.Count(r => r.Status == "FAIL");
|
var failed = results.Count(r => r.Status == "FAIL");
|
||||||
var skipped = results.Count(r => r.Status == "SKIP");
|
var skipped = results.Count(r => r.Status == "SKIP");
|
||||||
@@ -966,6 +967,39 @@ internal static class SelfTest
|
|||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Constructs the ENTIRE main window in headless mode (no audio backend, no hotkeys, no
|
||||||
|
/// timers, no sockets — see MainForm's `headless` flag) and audits every tab and control: accessible
|
||||||
|
/// names present, Alt mnemonics unique per group, and the tab order forms no cycle. This is the
|
||||||
|
/// "check all the tabs" coverage — the whole main-window surface, proven on every build.</summary>
|
||||||
|
private static string? MainWindowCoverage()
|
||||||
|
{
|
||||||
|
Form? form;
|
||||||
|
try { form = new MainForm(null, RemSound.Core.Profile.NewBlank(), null, null, headless: true); }
|
||||||
|
catch (Exception ex) { return Skip($"headless MainForm could not be constructed: {ex.GetType().Name}: {ex.Message}"); }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var violations = new List<string>();
|
||||||
|
AuditForm("Main window", form, violations);
|
||||||
|
Check(violations.Count == 0, string.Join("; ", violations));
|
||||||
|
|
||||||
|
var tabs = CountControls(form, c => c is TabPage);
|
||||||
|
var interactive = CountControls(form, c => c is CheckBox or Button or ComboBox or ListBox or TrackBar or TextBox);
|
||||||
|
Check(tabs >= 3, $"the main window's tabs should be present (found {tabs})");
|
||||||
|
Check(interactive >= 15, $"the main window's interactive controls should be present (found {interactive})");
|
||||||
|
return $"audited the whole main window: {tabs} tabs, {interactive} interactive controls — names, mnemonics and tab order clean";
|
||||||
|
}
|
||||||
|
finally { try { form.Dispose(); } catch { /* ignore */ } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountControls(Control root, Func<Control, bool> predicate)
|
||||||
|
{
|
||||||
|
var n = 0;
|
||||||
|
void Walk(Control p) { foreach (Control c in p.Controls) { if (predicate(c)) n++; Walk(c); } }
|
||||||
|
Walk(root);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
private static void AuditForm(string formName, Form form, List<string> violations)
|
private static void AuditForm(string formName, Form form, List<string> violations)
|
||||||
{
|
{
|
||||||
var all = new List<Control>();
|
var all = new List<Control>();
|
||||||
|
|||||||
Reference in New Issue
Block a user