Test suite: richer diagnostics, headless accessibility audit, perf/leak sanity
Andre's three "bigger ideas" from RemSound-smoke-test-agent-brief.md: - Richer diagnostics: --diagnostics now includes a live localhost audio self-check (PCM + Opus, with packet/underrun/drop/buffer/latency counters), the most recent session snapshot parsed from the log (codec, send/receive state, buffer, drops, heartbeat), and a recent-warnings/errors digest from the log. BuildDiagnosticsReport gained a runLiveAudioProbe flag so the self-test's privacy check stays fast. - Headless accessibility audit: new --selftest step constructs the dialogs that can be built without hardware (Startup behaviour, Recording settings, Preferences) and checks every actionable control announces a name and that Alt-key mnemonics are unique within a container. MainForm is out of scope (its constructor opens audio/hotkeys/sockets). Dialogs that won't construct are skipped, not failed. Currently audits 3, no violations. - Perf/leak sanity: new --perftest command runs several audio-loopback cycles and reports whether handle/memory/thread counts stay bounded (handles ratcheting up cycle-on-cycle is the leak fingerprint, given RemSound's handle-leak history). Lenient thresholds; logs the numbers for build-to-build comparison. Wired into run-tests.ps1. - Shared AudioLoopback helper (used by the self-test, diagnostics and perf test) so all three exercise the identical real capture/encode/network/decode path on test port 47929. - csproj: the four previously-unconditional cue Content items are now Exists-guarded like the rest, so a mid-edit sounds\ folder doesn't break the dev build; the gate still enforces the required cues before release. Help + manual updated (--perftest, --smoke-test, --config-dir, richer --selftest). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
832ed40bf7
commit
ad66fe5364
@@ -0,0 +1,77 @@
|
||||
using System.Net;
|
||||
using RemSound.Core;
|
||||
using RemSound.Receiver;
|
||||
using RemSound.Sender;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// A localhost audio loopback used by the self-test, the diagnostics report and the perf check:
|
||||
/// capture the default output (as loopback) → encode → send to 127.0.0.1 → receive → decode, for a
|
||||
/// few seconds, with the receiver rendering to nothing (so no sound is ever produced). Exposes the
|
||||
/// runtime counters afterwards. Shared so all three callers exercise the identical real audio path.
|
||||
/// </summary>
|
||||
internal static class AudioLoopback
|
||||
{
|
||||
/// <summary>A dedicated test port, separate from the live DefaultPort (47830), so a loopback
|
||||
/// never clashes with a RemSound instance the user already has running.</summary>
|
||||
internal const int TestPort = 47929;
|
||||
|
||||
internal sealed record Result(
|
||||
bool Ran, string Codec,
|
||||
long PacketsSent, long PacketsReceived, long BytesReceived,
|
||||
long Underruns, long Drops, int BufferMs, int TargetLatencyMs,
|
||||
string? SkipReason)
|
||||
{
|
||||
public bool Flowed => Ran && PacketsSent > 0 && PacketsReceived > 0;
|
||||
}
|
||||
|
||||
private static Result Skipped(string codec, string why) =>
|
||||
new(false, codec, 0, 0, 0, 0, 0, 0, 0, why);
|
||||
|
||||
/// <summary>Run the loopback for <paramref name="seconds"/> and return the counters. Never
|
||||
/// throws for an environment reason (no output device, port busy) - it returns a Result with
|
||||
/// <see cref="Result.Ran"/> = false and a <see cref="Result.SkipReason"/> instead, so callers
|
||||
/// can treat "no audio hardware" as a skip rather than a failure.</summary>
|
||||
internal static Result Run(bool opus, int seconds, int port = TestPort)
|
||||
{
|
||||
var codec = opus ? "Opus" : "PCM";
|
||||
|
||||
IReadOnlyList<AudioDeviceChoice> outputs;
|
||||
try { outputs = AudioDeviceCatalog.LoadOutputs(); }
|
||||
catch (Exception ex) { return Skipped(codec, "could not enumerate outputs: " + ex.Message); }
|
||||
var dev = outputs.FirstOrDefault(o => o.DeviceId is not null);
|
||||
if (dev?.DeviceId is not { } deviceId) return Skipped(codec, "no usable output device to capture from");
|
||||
|
||||
using var receiver = new AudioReceiver();
|
||||
using var sender = new AudioSender();
|
||||
try
|
||||
{
|
||||
try { receiver.Start(port); }
|
||||
catch (Exception ex) { return Skipped(codec, $"could not bind test port {port}: {ex.Message}"); }
|
||||
receiver.SetOutputDevices(Array.Empty<string>()); // decode only - never make sound
|
||||
sender.ConfigureCodec(opus ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm);
|
||||
sender.Configure(new[] { new CaptureSourceSpec(deviceId, CaptureKind.Loopback, dev.Name) });
|
||||
sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, port) });
|
||||
sender.Start();
|
||||
Thread.Sleep(Math.Max(1, seconds) * 1000);
|
||||
|
||||
// Read the counters while still running (before the finally stops the engines).
|
||||
return new Result(
|
||||
Ran: true, Codec: codec,
|
||||
PacketsSent: sender.PacketsSent,
|
||||
PacketsReceived: receiver.PacketsReceived,
|
||||
BytesReceived: receiver.BytesReceived,
|
||||
Underruns: receiver.Underruns,
|
||||
Drops: receiver.Drops,
|
||||
BufferMs: receiver.CurrentBufferMs,
|
||||
TargetLatencyMs: receiver.TargetLatencyMs,
|
||||
SkipReason: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { sender.Stop(); } catch { /* ignore */ }
|
||||
try { receiver.Stop(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,8 @@ internal static class CommandLine
|
||||
return WithConsole(() => { WriteDevices(Console.Out); return 0; });
|
||||
case "--selftest": case "--self-test": case "--smoke-test": case "--smoketest":
|
||||
return WithConsole(() => SelfTest.Run(args));
|
||||
case "--perftest": case "--perf-test":
|
||||
return WithConsole(() => RunPerfTest(args));
|
||||
case "--diagnostics": case "--diag":
|
||||
return WithConsole(() => RunDiagnostics(ValueAfter(args, raw)));
|
||||
case "--log":
|
||||
@@ -150,7 +152,9 @@ internal static class CommandLine
|
||||
Console.WriteLine(" with their formats and device ids.");
|
||||
Console.WriteLine(" --selftest [--seconds N] Run the built-in self-test - a localhost audio");
|
||||
Console.WriteLine(" (or --smoke-test) round-trip plus checks of encryption, the wire format,");
|
||||
Console.WriteLine(" settings, profiles and bundled files - and report PASS/FAIL.");
|
||||
Console.WriteLine(" settings, profiles, dialog accessibility and bundled files.");
|
||||
Console.WriteLine(" --perftest [--seconds N] Run repeated audio cycles and report whether handle,");
|
||||
Console.WriteLine(" memory and thread counts stay bounded (leak sanity check).");
|
||||
Console.WriteLine(" --diagnostics [path] Write a diagnostics report (version, config, profiles,");
|
||||
Console.WriteLine(" devices, mic-privacy check, recent log) and exit. With");
|
||||
Console.WriteLine(" no path, it is saved in the user settings and logs folder.");
|
||||
@@ -225,6 +229,62 @@ internal static class CommandLine
|
||||
w.WriteLine();
|
||||
}
|
||||
|
||||
/// <summary>Resource-sanity check: run several short audio-loopback cycles and watch this
|
||||
/// process's handle / memory / thread counts. Each cycle builds and tears down the audio
|
||||
/// engine, so a handle or thread count that ratchets up cycle on cycle is the fingerprint of a
|
||||
/// leak (RemSound has a handle-leak history). Lenient thresholds - it flags obvious runaway, not
|
||||
/// normal fluctuation - and logs the numbers so builds can be compared. Note: the loopback
|
||||
/// renders to nothing (no real output device, to stay silent), so it exercises the
|
||||
/// capture/encode/network/decode path, not the WASAPI render path.</summary>
|
||||
private static int RunPerfTest(string[] args)
|
||||
{
|
||||
var seconds = int.TryParse(ValueAfter(args, "--seconds"), out var s) && s is >= 6 and <= 120 ? s : 15;
|
||||
const int cycles = 3;
|
||||
var perCycle = Math.Max(2, seconds / cycles);
|
||||
var proc = System.Diagnostics.Process.GetCurrentProcess();
|
||||
|
||||
Console.WriteLine($"RemSound perf sanity: {cycles} x {perCycle}s audio loopback, watching handles/memory/threads...");
|
||||
Console.WriteLine();
|
||||
|
||||
static (int Handles, long WorkingSetMb, long PrivateMb, int Threads) Measure(System.Diagnostics.Process p)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
p.Refresh();
|
||||
return (p.HandleCount, p.WorkingSet64 / (1024 * 1024), p.PrivateMemorySize64 / (1024 * 1024), p.Threads.Count);
|
||||
}
|
||||
|
||||
var baseline = Measure(proc);
|
||||
Console.WriteLine($" baseline: handles={baseline.Handles} workingSet={baseline.WorkingSetMb}MB private={baseline.PrivateMb}MB threads={baseline.Threads}");
|
||||
|
||||
for (var i = 1; i <= cycles; i++)
|
||||
{
|
||||
var r = AudioLoopback.Run(opus: true, perCycle);
|
||||
if (!r.Ran)
|
||||
{
|
||||
Console.WriteLine($" RESULT: SKIP - {r.SkipReason} (no audio device to exercise).");
|
||||
return 0;
|
||||
}
|
||||
var m = Measure(proc);
|
||||
Console.WriteLine($" cycle {i}/{cycles}: handles={m.Handles} workingSet={m.WorkingSetMb}MB private={m.PrivateMb}MB threads={m.Threads} (sent={r.PacketsSent}, received={r.PacketsReceived})");
|
||||
}
|
||||
|
||||
var final = Measure(proc);
|
||||
var handleGrowth = final.Handles - baseline.Handles;
|
||||
var threadGrowth = final.Threads - baseline.Threads;
|
||||
var memGrowth = final.WorkingSetMb - baseline.WorkingSetMb;
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($" net change over {cycles} cycles: handles {handleGrowth:+#;-#;0}, threads {threadGrowth:+#;-#;0}, workingSet {memGrowth:+#;-#;0}MB");
|
||||
|
||||
// Lenient: flag obvious runaway (e.g. a handle leak ratcheting up each cycle), not noise.
|
||||
var runaway = handleGrowth > 1500 || threadGrowth > 100 || final.WorkingSetMb > 1500;
|
||||
Console.WriteLine(runaway
|
||||
? " RESULT: FAIL - resource use looks like it is running away (possible leak); compare the per-cycle trend above."
|
||||
: " RESULT: PASS - handles, threads and memory stayed bounded across cycles.");
|
||||
return runaway ? 1 : 0;
|
||||
}
|
||||
|
||||
private static int SetLogging(string? value)
|
||||
{
|
||||
var on = value is not null && value.ToLowerInvariant() is "on" or "true" or "1" or "enable" or "enabled" or "yes";
|
||||
@@ -256,7 +316,7 @@ internal static class CommandLine
|
||||
/// <summary>Build the support diagnostics report text for a given config (version, settings,
|
||||
/// profiles, devices, mic-privacy, recent log). Shared by <c>--diagnostics</c> and the
|
||||
/// self-test's privacy check. Lists profile titles only - never their contents.</summary>
|
||||
internal static string BuildDiagnosticsReport(AppConfig cfg)
|
||||
internal static string BuildDiagnosticsReport(AppConfig cfg, bool runLiveAudioProbe = true)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"RemSound diagnostics");
|
||||
@@ -295,6 +355,21 @@ internal static class CommandLine
|
||||
sb.AppendLine($" {DescribeMicPrivacy()}");
|
||||
sb.AppendLine();
|
||||
|
||||
if (runLiveAudioProbe)
|
||||
{
|
||||
sb.AppendLine("Live audio self-check (localhost loopback, no sound output):");
|
||||
AppendLiveAudioCheck(sb);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("Most recent session snapshot (from the log):");
|
||||
sb.AppendLine($" {LastSessionSnapshot()}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("Recent warnings and errors (from the log):");
|
||||
sb.AppendLine(RecentLogProblems(15));
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("Most recent log (tail):");
|
||||
sb.AppendLine(TailNewestLog(40));
|
||||
sb.AppendLine();
|
||||
@@ -302,6 +377,70 @@ internal static class CommandLine
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Run a short localhost loopback for each codec and report the live counters - proves
|
||||
/// this machine's capture/encode/network/decode path works, with real packet/underrun/drop
|
||||
/// numbers. Skips (no failure) when there's no audio device.</summary>
|
||||
private static void AppendLiveAudioCheck(StringBuilder sb)
|
||||
{
|
||||
foreach (var opus in new[] { false, true })
|
||||
{
|
||||
var r = AudioLoopback.Run(opus, 2);
|
||||
if (!r.Ran) { sb.AppendLine($" {r.Codec}: skipped ({r.SkipReason})"); continue; }
|
||||
sb.AppendLine($" {r.Codec}: sent={r.PacketsSent} pkts, received={r.PacketsReceived} pkts ({r.BytesReceived} bytes), "
|
||||
+ $"underruns={r.Underruns}, drops={r.Drops}, buffer={r.BufferMs}ms, target latency={r.TargetLatencyMs}ms "
|
||||
+ $"-> {(r.Flowed ? "OK" : "NO AUDIO")}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The last one-second SNAP row from the newest log, as a readable line: the real
|
||||
/// running session's codec, send/receive state, buffer, underruns, drops and heartbeat. These
|
||||
/// are the live values a static report can't otherwise show. Empty when logging is off.</summary>
|
||||
private static string LastSessionSnapshot()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = AppConfig.LogsDirectory;
|
||||
if (!Directory.Exists(dir)) return "(no logs - logging may be off)";
|
||||
var newest = new DirectoryInfo(dir).GetFiles("*.log").OrderByDescending(f => f.LastWriteTimeUtc).FirstOrDefault();
|
||||
if (newest is null) return "(no log files - logging may be off)";
|
||||
var lines = File.ReadAllLines(newest.FullName);
|
||||
var header = lines.FirstOrDefault(l => l.StartsWith("Kind\t") || l.Contains("\tCodec\t"));
|
||||
var lastSnap = lines.LastOrDefault(l => l.StartsWith("SNAP\t"));
|
||||
if (header is null || lastSnap is null) return "(no session snapshots in the newest log yet)";
|
||||
var cols = header.Split('\t');
|
||||
var vals = lastSnap.Split('\t');
|
||||
string Col(string name) { var i = Array.IndexOf(cols, name); return i >= 0 && i < vals.Length ? vals[i] : "?"; }
|
||||
return $"Connected={Col("Connected")}, Send={Col("SendRunning")}, Receive={Col("ReceiveRunning")}, "
|
||||
+ $"Codec={Col("Codec")}, Buffer={Col("BufferMs")}ms, Underruns={Col("Underruns")}, "
|
||||
+ $"Drops={Col("Drops")}, Heartbeat={Col("Heartbeat")}, TargetLatency={Col("TargetLatencyMs")}ms";
|
||||
}
|
||||
catch (Exception ex) { return $"(could not read snapshot: {ex.Message})"; }
|
||||
}
|
||||
|
||||
/// <summary>The recent WARN/ERROR/exception-style lines from the newest log, so a support
|
||||
/// reader sees the problems without scrolling the whole file. Last <paramref name="max"/>.</summary>
|
||||
private static string RecentLogProblems(int max)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = AppConfig.LogsDirectory;
|
||||
if (!Directory.Exists(dir)) return " (no logs - logging may be off)";
|
||||
var newest = new DirectoryInfo(dir).GetFiles("*.log").OrderByDescending(f => f.LastWriteTimeUtc).FirstOrDefault();
|
||||
if (newest is null) return " (no log files - logging may be off)";
|
||||
var problems = File.ReadLines(newest.FullName)
|
||||
.Where(l => l.Contains("error", StringComparison.OrdinalIgnoreCase)
|
||||
|| l.Contains("warn", StringComparison.OrdinalIgnoreCase)
|
||||
|| l.Contains("exception", StringComparison.OrdinalIgnoreCase)
|
||||
|| l.Contains("unreachable", StringComparison.OrdinalIgnoreCase)
|
||||
|| l.Contains("failed", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (problems.Count == 0) return " (none in the newest log)";
|
||||
var tail = problems.Count <= max ? problems : problems.GetRange(problems.Count - max, max);
|
||||
return string.Join(Environment.NewLine, tail.Select(l => " " + l));
|
||||
}
|
||||
catch (Exception ex) { return $" (could not scan log: {ex.Message})"; }
|
||||
}
|
||||
|
||||
/// <summary>Write a support-friendly diagnostics report and exit. Always writes a file so it
|
||||
/// works even when launched without a terminal; prints the path if a console is attached.</summary>
|
||||
private static int RunDiagnostics(string? pathArg)
|
||||
|
||||
@@ -62,19 +62,23 @@
|
||||
user-supplied custom cue paths set in Preferences override the defaults at runtime.
|
||||
Filenames containing a space (e.g. "record start.wav") are preserved verbatim on
|
||||
copy so the load-by-filename path in TryLoadCueSound finds them exactly as written. -->
|
||||
<Content Include="..\..\sounds\connect.wav">
|
||||
<!-- Exists-guarded like the newer cues below: a missing source WAV (e.g. while the project owner
|
||||
is swapping in new cue sounds) must not break the dev build. The release gate (run-tests.ps1
|
||||
and the self-test "Bundled resources present" step) enforces that the required cues are
|
||||
actually present before a release ships, so guarding here loses no safety. -->
|
||||
<Content Include="..\..\sounds\connect.wav" Condition="Exists('..\..\sounds\connect.wav')">
|
||||
<Link>sounds\connect.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="..\..\sounds\disconnect.wav">
|
||||
<Content Include="..\..\sounds\disconnect.wav" Condition="Exists('..\..\sounds\disconnect.wav')">
|
||||
<Link>sounds\disconnect.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="..\..\sounds\record start.wav">
|
||||
<Content Include="..\..\sounds\record start.wav" Condition="Exists('..\..\sounds\record start.wav')">
|
||||
<Link>sounds\record start.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="..\..\sounds\record stop.wav">
|
||||
<Content Include="..\..\sounds\record stop.wav" Condition="Exists('..\..\sounds\record stop.wav')">
|
||||
<Link>sounds\record stop.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
|
||||
@@ -3,9 +3,8 @@ using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Forms;
|
||||
using RemSound.Core;
|
||||
using RemSound.Receiver;
|
||||
using RemSound.Sender;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
@@ -60,6 +59,7 @@ internal static class SelfTest
|
||||
RunStep(results, "Profile save and reload", ProfileRoundTrip);
|
||||
RunStep(results, "Diagnostics report privacy", DiagnosticsPrivacy);
|
||||
RunStep(results, "Bundled resources present", ResourcesPresent);
|
||||
RunStep(results, "Dialog accessibility (names + mnemonics)", AccessibilityAudit);
|
||||
|
||||
var failed = results.Count(r => r.Status == "FAIL");
|
||||
var skipped = results.Count(r => r.Status == "SKIP");
|
||||
@@ -99,38 +99,10 @@ internal static class SelfTest
|
||||
/// box) so the suite stays green where there's simply nothing to capture.</summary>
|
||||
private static string? AudioRoundTrip(bool opus, int seconds)
|
||||
{
|
||||
IReadOnlyList<AudioDeviceChoice> outputs;
|
||||
try { outputs = AudioDeviceCatalog.LoadOutputs(); }
|
||||
catch (Exception ex) { return Skip("could not enumerate outputs: " + ex.Message); }
|
||||
var dev = outputs.FirstOrDefault(o => o.DeviceId is not null);
|
||||
if (dev?.DeviceId is not { } deviceId) return Skip("no usable output device to capture from");
|
||||
|
||||
// A dedicated test port, separate from the live DefaultPort (47830), so the self-test
|
||||
// doesn't clash with a RemSound instance the user already has running.
|
||||
const int testPort = 47929;
|
||||
using var receiver = new AudioReceiver();
|
||||
using var sender = new AudioSender();
|
||||
try
|
||||
{
|
||||
try { receiver.Start(testPort); }
|
||||
catch (Exception ex) { return Skip($"could not bind test port {testPort}: {ex.Message}"); }
|
||||
receiver.SetOutputDevices(Array.Empty<string>()); // decode only - never make sound during a test
|
||||
sender.ConfigureCodec(opus ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm);
|
||||
sender.Configure(new[] { new CaptureSourceSpec(deviceId, CaptureKind.Loopback, dev.Name) });
|
||||
sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, testPort) });
|
||||
sender.Start();
|
||||
Thread.Sleep(seconds * 1000);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { sender.Stop(); } catch { /* ignore */ }
|
||||
try { receiver.Stop(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
var sent = sender.PacketsSent;
|
||||
var got = receiver.PacketsReceived;
|
||||
Check(sent > 0 && got > 0, $"audio did not flow end-to-end (sent={sent}, received={got})");
|
||||
return $"sent={sent}, received={got}";
|
||||
var r = AudioLoopback.Run(opus, seconds);
|
||||
if (!r.Ran) return Skip(r.SkipReason ?? "audio loopback unavailable");
|
||||
Check(r.Flowed, $"audio did not flow end-to-end (sent={r.PacketsSent}, received={r.PacketsReceived})");
|
||||
return $"sent={r.PacketsSent}, received={r.PacketsReceived}";
|
||||
}
|
||||
|
||||
/// <summary>Audio encryption: the right password decrypts to the original, the wrong one fails
|
||||
@@ -301,7 +273,7 @@ internal static class SelfTest
|
||||
p.Password = canaryPassword;
|
||||
store.Save(p);
|
||||
|
||||
var report = CommandLine.BuildDiagnosticsReport(new AppConfig { ProfilesDirectory = temp });
|
||||
var report = CommandLine.BuildDiagnosticsReport(new AppConfig { ProfilesDirectory = temp }, runLiveAudioProbe: false);
|
||||
Check(report.Contains("RemSound diagnostics") && report.Contains(Environment.MachineName),
|
||||
"the diagnostics report must contain its basic header");
|
||||
Check(report.Contains(canaryTitle), "the diagnostics report should list the profile title");
|
||||
@@ -340,6 +312,89 @@ internal static class SelfTest
|
||||
return "manual, cue sounds, native Opus";
|
||||
}
|
||||
|
||||
/// <summary>Headless accessibility audit of the dialogs that can be built without hardware: every
|
||||
/// actionable control announces a name to a screen reader, and the Alt-key mnemonic letters are
|
||||
/// unique within a container so keyboard navigation is never ambiguous. The main window can't be
|
||||
/// built headlessly (its constructor opens audio devices, registers hotkeys and binds sockets),
|
||||
/// so it's out of scope here. A dialog that won't construct in this context is skipped, not
|
||||
/// failed.</summary>
|
||||
private static string? AccessibilityAudit()
|
||||
{
|
||||
var factories = new (string Name, Func<Form> Make)[]
|
||||
{
|
||||
("Startup behaviour", () => new StartupBehaviourDialog(null)),
|
||||
("Recording settings", () => new RecordingSettingsDialog(new RecordingSettings())),
|
||||
("Preferences", () => new PreferencesDialog(
|
||||
new RemSoundSettingsStore("RemSound"), null,
|
||||
() => false, _ => { }, () => { }, () => { }, () => { }, _ => { },
|
||||
() => (default(RouterMappingStatus), (IPEndPoint?)null, ""),
|
||||
_ => { }, _ => { })),
|
||||
};
|
||||
|
||||
var audited = new List<string>();
|
||||
var skipped = new List<string>();
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (var (name, make) in factories)
|
||||
{
|
||||
Form? form = null;
|
||||
try { form = make(); }
|
||||
catch (Exception ex) { skipped.Add($"{name} ({ex.GetType().Name})"); continue; }
|
||||
try { AuditForm(name, form, violations); audited.Add(name); }
|
||||
finally { try { form.Dispose(); } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
if (audited.Count == 0) return Skip("no dialog could be constructed in this context");
|
||||
Check(violations.Count == 0, string.Join("; ", violations));
|
||||
var detail = $"audited {audited.Count} ({string.Join(", ", audited)})";
|
||||
if (skipped.Count > 0) detail += $"; skipped {skipped.Count}";
|
||||
return detail;
|
||||
}
|
||||
|
||||
private static void AuditForm(string formName, Form form, List<string> violations)
|
||||
{
|
||||
var all = new List<Control>();
|
||||
void Walk(Control parent) { foreach (Control c in parent.Controls) { all.Add(c); Walk(c); } }
|
||||
Walk(form);
|
||||
|
||||
// Mnemonic uniqueness, per immediate container (the practical Alt-key scope).
|
||||
foreach (var group in all.Where(c => TryMnemonic(c.Text, out _)).GroupBy(c => c.Parent))
|
||||
{
|
||||
var counts = new Dictionary<char, int>();
|
||||
foreach (var c in group)
|
||||
{
|
||||
if (TryMnemonic(c.Text, out var letter))
|
||||
counts[letter] = counts.TryGetValue(letter, out var n) ? n + 1 : 1;
|
||||
}
|
||||
foreach (var dup in counts.Where(kv => kv.Value > 1))
|
||||
violations.Add($"{formName}: Alt+{char.ToUpperInvariant(dup.Key)} is used by {dup.Value} controls in one group");
|
||||
}
|
||||
|
||||
// Self-labelling controls (buttons, check boxes, radio buttons) must announce something.
|
||||
foreach (var c in all.Where(c => c is ButtonBase))
|
||||
{
|
||||
var name = !string.IsNullOrWhiteSpace(c.AccessibleName) ? c.AccessibleName : c.Text;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
violations.Add($"{formName}: a {c.GetType().Name} has no accessible name or text");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Extract the Alt mnemonic letter from a WinForms caption ('&X' marks X; '&&'
|
||||
/// is a literal ampersand). Returns false when there is no mnemonic.</summary>
|
||||
private static bool TryMnemonic(string? text, out char letter)
|
||||
{
|
||||
letter = '\0';
|
||||
if (string.IsNullOrEmpty(text)) return false;
|
||||
for (var i = 0; i < text.Length - 1; i++)
|
||||
{
|
||||
if (text[i] != '&') continue;
|
||||
if (text[i + 1] == '&') { i++; continue; } // escaped "&&" is a literal ampersand
|
||||
letter = char.ToLowerInvariant(text[i + 1]);
|
||||
return char.IsLetterOrDigit(letter);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------- helper ----------------
|
||||
|
||||
private static string? ValueAfter(string[] args, string flag)
|
||||
|
||||
Reference in New Issue
Block a user