Tests: persistence tripwire + cue resolution + audit/gate hardening, group 3 (review Phase 2)

- Profile persistence TRIPWIRE: reflection over every public writable Profile field -
  each must round-trip ApplyProfile->CopyTo or appear on an explicit declared list
  (control-owned or legacy, each with its reason). Add a field without wiring it and
  this FAILS naming the field - closing the three-parallel-lists drift trap for good.
  The declared list is itself checked against real properties so it can't rot.
  (Immediately proved itself: enumerated the 14 legacy v4.4 hotkey fields.)
- Cue variant resolution: 'Sound N' labels, numeric ordering, the drop-bare-when-
  numbered dedupe (the duplicate-row NVDA trap), chosen-default honoured + fallback
  when the chosen file is gone.
- Accessibility audit now includes UpdateInstallNoticeDialog (it was the one
  Form-derived dialog missing from the audit list).
- Skipped self-test steps are now LOUD: the summary names every skipped step and
  run-tests.ps1 prints a yellow warning - a gate pass with steps skipped no longer
  reads as full coverage.

Gate 58/58.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-23 16:04:38 +01:00
co-authored by Claude Fable 5
parent 354d2b4fdb
commit e356ee46dd
2 changed files with 178 additions and 0 deletions
+9
View File
@@ -122,6 +122,15 @@ foreach ($line in ($st.Text -split "`r?`n")) {
if ($line -match '\[(PASS|FAIL|SKIP)\]|^RESULT:') { Write-Host " $($line.Trim())" }
}
if ($st.Code -eq 0) { Pass "self-test passed (exit 0)" } else { Fail "self-test failed (exit $($st.Code))" }
# Skipped steps are coverage that did NOT run. The gate still passes (some skips are legitimate on a
# headless/agent box), but they must be impossible to miss in the output - a release verified with
# steps skipped is only as verified as what actually ran.
$skipLine = ($st.Text -split "`r?`n") | Where-Object { $_ -match '^SKIPPED STEPS' } | Select-Object -First 1
if ($skipLine) {
Write-Host ""
Write-Host " WARNING: $($skipLine.Trim())" -ForegroundColor Yellow
Write-Host " These steps did not run on this machine - their coverage is NOT verified." -ForegroundColor Yellow
}
Write-Host "`nResource sanity (handle/memory leak check):" -ForegroundColor Cyan
$pf = Invoke-RsCli @('--perftest', '--seconds', '12')
+169
View File
@@ -65,6 +65,8 @@ internal static class SelfTest
RunStep(results, "Receiver allow-list gate (stranger rejected, no session)", ReceiverAllowGate);
RunStep(results, "Receiver decryptor: wrong key yields silence, not noise", DecryptorWrongKey);
RunStep(results, "Heartbeat wire round-trip + ping→pong echo", HeartbeatEcho);
RunStep(results, "Profile persistence tripwire (every field wired or declared)", ProfileDriftTripwire);
RunStep(results, "Cue variant resolution (dedupe, order, chosen-default fallback)", CueVariantResolution);
RunStep(results, "App settings save and reload", SettingsRoundTrip);
RunStep(results, "Per-peer shaping DSP", PeerShapingDsp);
RunStep(results, "Multi-output fan-out (both lanes)", FanOutToBothOutputs);
@@ -113,6 +115,11 @@ internal static class SelfTest
var passed = results.Count(r => r.Status == "PASS");
Console.WriteLine();
// Skips must be LOUD: a skipped step is coverage that did not run, and a gate that quietly
// passes with steps skipped reads as "everything was checked" when it wasn't. Name them so
// run-tests.ps1 (and a human reading the log) can see exactly what went unexercised.
if (skipped > 0)
Console.WriteLine($"SKIPPED STEPS ({skipped}): {string.Join(", ", results.Where(r => r.Status == "SKIP").Select(r => r.Name))}");
if (failed == 0)
{
Console.WriteLine($"RESULT: PASS - {passed} passed{(skipped > 0 ? $", {skipped} skipped" : "")} of {results.Count}.");
@@ -955,6 +962,166 @@ internal static class SelfTest
return "follower flagged + sentinel shared with the app; service resolves it to the live default render endpoint";
}
/// <summary>Cue variant resolution feeds the Preferences sound picker AND which WAV actually plays.
/// Wrong resolution = the wrong sound (or a duplicate 'Sound 1' row NVDA can't disambiguate). Pins:
/// numbered ordering, the drop-bare-when-numbered-exists dedupe, the 'Sound N' labels, and the
/// chosen-default falling back to the first variant when the chosen file is gone.</summary>
private static string? CueVariantResolution()
{
// Label mapping is pure string logic — pin it with literals first.
Check(CueSounds.VariantLabel("connect.wav", "connect 2.wav") == "Sound 2", "'connect 2.wav' must label as Sound 2");
Check(CueSounds.VariantLabel("connect.wav", "connect.wav") == "Sound 1", "the bare legacy name must label as Sound 1");
Check(CueSounds.VariantLabel("connect.wav", "connect 11.wav") == "Sound 11", "double-digit variants must label correctly");
// Resolution against the real shipped sounds folder.
var variants = CueSounds.Variants("connect.wav");
if (variants.Count == 0) return Skip("no connect cue variants on disk (sounds not seeded in this environment)");
var hasNumbered = variants.Any(v => !v.Equals("connect.wav", StringComparison.OrdinalIgnoreCase));
if (hasNumbered)
{
Check(!variants.Any(v => v.Equals("connect.wav", StringComparison.OrdinalIgnoreCase)),
"when numbered variants exist the bare file must be dropped (it would duplicate 'Sound 1')");
}
var numbers = variants.Select(v => int.TryParse(
Path.GetFileNameWithoutExtension(v).Split(' ').Last(), out var n) ? n : 0).ToList();
Check(numbers.SequenceEqual(numbers.OrderBy(n => n)), "variants must be ordered by their number");
var cfg = new AppConfig(); // scratch, never saved
Check(CueSounds.ResolveDefaultFileName("connect", "connect.wav", cfg) == variants[0],
"with no machine-wide pick, the FIRST variant must resolve");
cfg.DefaultCueSounds["connect"] = variants[^1];
Check(CueSounds.ResolveDefaultFileName("connect", "connect.wav", cfg) == variants[^1],
"a valid machine-wide pick must be honoured");
cfg.DefaultCueSounds["connect"] = "connect 999.wav";
Check(CueSounds.ResolveDefaultFileName("connect", "connect.wav", cfg) == variants[0],
"a pick whose file is GONE must fall back to the first variant, not silence");
return $"labels exact; {variants.Count} variants ordered + deduped; chosen-default honoured with fallback";
}
/// <summary>THE PERSISTENCE TRIPWIRE. A profile setting only survives save/load if it's wired through
/// the settings cache (ApplyProfile + CopyTo) or read straight off a control by BuildCurrentProfile —
/// three parallel lists that have silently drifted before. This test sets a distinctive value on EVERY
/// public writable Profile property via reflection, pushes it through ApplyProfile → CopyTo, and
/// requires each property to either round-trip or appear on the DECLARED list below with a reason.
/// Add a Profile field without wiring it and this fails, naming the field. When that happens: wire it
/// into RemSoundSettingsStore.ApplyProfile + CopyTo (and BuildCurrentProfile if control-driven), or
/// consciously add it to the declared list with a reason. Do not just add it to the list to go green.</summary>
private static string? ProfileDriftTripwire()
{
// Properties the STORE layer legitimately does not round-trip, each with why. Two kinds:
// control-owned (BuildCurrentProfile reads the live control / form state instead — several of
// those are pinned by the MainWindowProfileRoundTrip step) and legacy/vestigial (kept only so
// old JSON deserialises; nothing should read them again).
var declared = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Title"] = "profile identity — carried by ProfileStore/BuildCurrentProfile(title), not the cache",
["ReadOnly"] = "control-owned: File → Lock profile state, applied outside the settings cache",
["Password"] = "control-owned: held as plain text in the form, re-obfuscated on save",
["ReceiveAudioOn"] = "control-owned: main-window checkbox (pinned by MainWindowProfileRoundTrip)",
["SendAudioOn"] = "control-owned: main-window checkbox (pinned by MainWindowProfileRoundTrip)",
["Volume"] = "control-owned: volume slider (pinned by MainWindowProfileRoundTrip)",
["Muted"] = "control-owned: mute state (pinned by MainWindowProfileRoundTrip)",
["SelectedWasapiReceiveOutputs"] = "control-owned: device tick list",
["SelectedAsioReceiveOutputs"] = "control-owned: device tick list",
["SelectedWasapiSendOutputs"] = "control-owned: device tick list",
["SelectedWasapiSendInputs"] = "control-owned: device tick list",
["SelectedAsioSendInputs"] = "control-owned: device tick list",
["WasapiSendMode"] = "control-owned: send-mode listbox (pinned by MainWindowProfileRoundTrip)",
["SelectedSendApplications"] = "control-owned: app tick list (pinned by MainWindowProfileRoundTrip)",
["RememberedPeers"] = "control-owned: peers lists (global remembered store + ticks)",
["SelectedConnectedPeers"] = "control-owned: peers lists",
["PeerShaping"] = "control-owned: pan/EQ editor state (pinned by MainWindowProfileRoundTrip)",
["EnableAllPeerShaping"] = "control-owned: shaping master switch (pinned by MainWindowProfileRoundTrip)",
["AudioPort"] = "DEAD FIELD: nothing reads it (the live port is the RemPacket constant). Wiring a custom-port UI to it requires adding real persistence first",
["SendAllApplications"] = "vestigial (send-all removed 2026-07-17); kept for old JSON only",
["EnablePanForPeers"] = "legacy pre-collapse switch; one-way migrated into EnableAllPeerShaping",
["EnableEqForPeers"] = "legacy pre-collapse switch; one-way migrated into EnableAllPeerShaping",
};
// The 14 per-profile hotkey fields: legacy since v4.4 (hotkeys are machine-wide in AppConfig now).
// They exist ONLY so old profile JSON deserialises for the one-time shortcut-import offer.
foreach (var legacyHotkey in new[]
{
"ReceiveMuteHotkey", "SendMuteHotkey", "TrayHotkey", "VolumeUpHotkey", "VolumeDownHotkey",
"ToggleRecordingHotkey", "RemoteVolumeUpHotkey", "RemoteVolumeDownHotkey", "RemoteMuteToggleHotkey",
"SystemVolumeUpHotkey", "SystemVolumeDownHotkey", "SystemMuteToggleHotkey",
"QuickProfileSwitchHotkey", "SpeakStatusLineHotkey",
})
{
declared[legacyHotkey] = "legacy v4.4 per-profile hotkey — kept only for the one-time import of old JSON";
}
var store = new RemSoundSettingsStore("RemSound");
var backup = new Profile();
store.CopyTo(backup); // the cache is process-wide — snapshot it so this test doesn't pollute later steps
try
{
var input = new Profile();
var defaults = new Profile();
var props = typeof(Profile).GetProperties()
.Where(p => p.CanRead && p.CanWrite && p.GetSetMethod() is not null)
.Where(p => !p.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonIgnoreAttribute), false).Any())
.ToArray();
foreach (var p in props) SetDistinctive(input, p);
store.ApplyProfile(input);
var output = new Profile();
store.CopyTo(output);
var opts = new System.Text.Json.JsonSerializerOptions();
var failures = new List<string>();
var roundTripped = 0;
foreach (var p in props)
{
var inJson = System.Text.Json.JsonSerializer.Serialize(p.GetValue(input), p.PropertyType, opts);
var outJson = System.Text.Json.JsonSerializer.Serialize(p.GetValue(output), p.PropertyType, opts);
var survives = string.Equals(inJson, outJson, StringComparison.Ordinal);
if (survives && p.GetValue(input) is not null
&& string.Equals(inJson, System.Text.Json.JsonSerializer.Serialize(p.GetValue(defaults), p.PropertyType, opts), StringComparison.Ordinal))
{
// The distinctive setter failed to move it off the default — the comparison proves
// nothing. Treat as a test bug, not coverage.
failures.Add($"{p.Name} (test could not set a distinctive value)");
continue;
}
if (survives) { roundTripped++; continue; }
if (!declared.ContainsKey(p.Name))
failures.Add($"{p.Name} does NOT survive ApplyProfile→CopyTo and is not declared — wire it or declare it");
}
// The declared list must not rot either: every entry must name a real property.
foreach (var name in declared.Keys)
if (props.All(p => p.Name != name))
failures.Add($"declared entry '{name}' no longer matches any Profile property — remove it");
Check(failures.Count == 0, string.Join("; ", failures));
return $"{roundTripped} fields round-trip the settings cache; {declared.Count} declared control-owned/legacy; none undeclared";
}
finally { store.ApplyProfile(backup); }
static void SetDistinctive(Profile target, System.Reflection.PropertyInfo p)
{
var t = p.PropertyType;
object? v =
p.Name == "WasapiSendMode" ? "applications" :
p.Name == "CodecRaw" ? (int)AudioTransportCodec.Opus :
p.Name == "OpusFrameSamplesPerChannel" ? 960 :
p.Name == "SendRateRaw" ? (int)SendRate.Tight :
t == typeof(bool) ? !(bool)p.GetValue(target)! :
t == typeof(bool?) ? ((bool?)p.GetValue(target) is true ? false : true) :
t == typeof(int) ? (int)p.GetValue(target)! + 3 :
t == typeof(string) ? ((string?)p.GetValue(target) ?? "") + "-drift" :
t == typeof(List<string>) ? new List<string> { "drift-a", "drift-b" } :
t == typeof(Dictionary<string, string>) ? new Dictionary<string, string> { ["connect"] = @"C:\drift.wav" } :
t == typeof(Dictionary<string, PeerShaping>) ? new Dictionary<string, PeerShaping> { ["drift-peer"] = new PeerShaping() } :
t == typeof(RecordingSettings) ? new RecordingSettings { Source = RecordingSource.SentOnly } :
t == typeof(HotkeyRecord) ? new HotkeyRecord { Key = "F9", Control = true } :
null;
if (v is not null) p.SetValue(target, v);
// A type with no rule here leaves the default in place; the caller then flags the
// property as "test could not set a distinctive value" so the gap is visible, not silent.
}
}
/// <summary>Multi-part PCM reassembly: on a lossy network a mis-assembly means audible corruption
/// while packets still flow. Pins byte-exact in-order assembly, rejection of out-of-order/oversize/
/// zero-part input, discard-counting of half-finished frames, and recovery after every failure.</summary>
@@ -1615,6 +1782,8 @@ internal static class SelfTest
("Add EQ band", () => new AddBandDialog()),
("Rename peer", () => new RenamePeerDialog("TestMachine", null)),
("Keyboard shortcut import", () => new KeyboardShortcutImportDialog(Array.Empty<string>())),
("Update install notice", () => new UpdateInstallNoticeDialog(
new UpdateInfo("v9.9", new Version(9, 9, 0), "https://example.invalid/x.zip", "notes", "https://example.invalid/rel"))),
("Profile selection", () => new ProfileSelectionDialog(new ProfileStore(
Path.Combine(Path.GetTempPath(), "remsound-selftest-picker-" + Guid.NewGuid().ToString("N"))))),
};