diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 237350f..0b2d995 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -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 // 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. - 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 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" }; @@ -624,6 +624,13 @@ public sealed class MainForm : Form // Program.cs after the form closes; non-null means "user clicked Switch in Manage profiles — // re-launch the form under that profile." 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; // 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 @@ -741,9 +748,10 @@ public sealed class MainForm : Form 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.headless = headless; currentProfileTitle = loadedTitle; // 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), @@ -1367,7 +1375,7 @@ public sealed class MainForm : Form // the moment we start announcing, those addresses get directly contacted (bridges // Tailscale/VPN where broadcast doesn't traverse). PushDiscoveryUnicastHints(); - hotkeyController.Initialize(this); + if (!headless) hotkeyController.Initialize(this); // 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. UpdateHotkeyAnnouncements(); @@ -1570,6 +1578,10 @@ public sealed class MainForm : Form 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(); // 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 // classic mode this row is hidden via UpdateBothIndependentVisibility and the WASAPI // 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); SelectAllOnFocus(maxLatencyAsioBox); 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; try { - sender.SetAudioMode(resolvedMode, asioDriverArg); - receiver.SetAudioMode(resolvedMode, asioDriverArg); + // In a headless test build, don't switch the real audio backends (which can open an ASIO + // 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 ? "audio backend: WASAPI only (fast path)" : $"audio backend: WASAPI + ASIO driver \"{asioDriverArg}\" (independent lanes, no mix)"); diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index c813176..9b61380 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -73,6 +73,7 @@ internal static class SelfTest RunStep(results, "Diagnostics report privacy", DiagnosticsPrivacy); RunStep(results, "Bundled resources present", ResourcesPresent); 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 skipped = results.Count(r => r.Status == "SKIP"); @@ -966,6 +967,39 @@ internal static class SelfTest return detail; } + /// 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. + 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(); + 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 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 violations) { var all = new List();