Tests: split-track recording coverage (per-peer + own send)

Local checkpoint - NOT for public release. Closes the second test gap.

- RecordingController gets an internal SettingsSourceForTest seam so a self-test can drive
  a split (multi-track) recording without writing to the real shared settings store.
- New self-test "Recording split tracks": with SplitTracks on + one connected peer, starts
  the real controller, feeds the "your send" track through the tap it wires onto the sender,
  and asserts it wrote a FOLDER of tracks (one per peer plus your own) with content - Ed's
  multi-track feature, now proven on every build.

Gate 24/24. Both previously-deferred test gaps (split-track + main-window tabs) now closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-12 19:02:02 +01:00
co-authored by Claude Opus 4.8
parent c8fed26115
commit 499e9644d7
2 changed files with 64 additions and 1 deletions
+6 -1
View File
@@ -44,6 +44,11 @@ internal sealed class RecordingController
/// recording starts, so one track file is created per peer. Set by MainForm.</summary>
public Func<IReadOnlyList<(IPAddress Address, string Name)>>? ConnectedPeersProvider { get; set; }
/// <summary>Test seam: overrides where <see cref="Start"/> reads the recording settings from, so a
/// self-test can drive a split (multi-track) recording without writing to the real shared settings
/// store. Null (the default) = read from the store as normal.</summary>
internal Func<RecordingSettings>? SettingsSourceForTest { get; set; }
public RecordingController(AudioSender sender, AudioReceiver receiver, RemSoundSettingsStore settings, Action<string> diagnostic)
{
this.sender = sender;
@@ -71,7 +76,7 @@ internal sealed class RecordingController
public void Start()
{
if (IsRecording) return;
var s = settings.LoadRecordingSettings();
var s = (SettingsSourceForTest ?? settings.LoadRecordingSettings)();
var now = DateTime.Now;
try
{
+58
View File
@@ -66,6 +66,7 @@ internal static class SelfTest
RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream);
RunStep(results, "Service registration args", ServiceRegistrationArgs);
RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine);
RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks);
RunStep(results, "Recording churn / soak", RecordingChurn);
RunStep(results, "v5 settings and shaping round-trip", V5ConfigRoundTrip);
RunStep(results, "Profile save and reload", ProfileRoundTrip);
@@ -534,6 +535,63 @@ internal static class SelfTest
finally { try { Directory.Delete(temp, recursive: true); } catch { /* best-effort */ } }
}
/// <summary>Split-track (multi-track) recording: with SplitTracks on and one connected peer, the
/// recorder must write a FOLDER of tracks — one per peer plus your own send — not a single mixed file.
/// Drives the real RecordingController via a settings-injection seam (so it never touches the shared
/// settings store), feeds the "your send" track through the tap the controller wires onto the sender,
/// and asserts the track files land with content.</summary>
private static string? RecordingSplitTracks()
{
var temp = Path.Combine(Path.GetTempPath(), "remsound-split-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temp);
using var receiver = new AudioReceiver();
using var sender = new RemSound.Sender.AudioSender();
try
{
var controller = new RecordingController(sender, receiver, new RemSoundSettingsStore("RemSound"), _ => { })
{
SettingsSourceForTest = () => new RecordingSettings
{
SplitTracks = true,
Source = RecordingSource.Both,
FileFormat = RecordingFileFormat.Wav,
Folder = temp,
},
ConnectedPeersProvider = () => new[] { (IPAddress.Loopback, "TestPeer") },
};
controller.Start();
Check(controller.IsRecording, "split recording should be running after Start");
// Feed the "your send" track through the tap Start wired onto the sender.
var tap = sender.OnSentSamples;
if (tap is not null)
{
var chunk = new float[480 * 2];
var phase = 0.0;
for (var c = 0; c < 60; c++)
{
for (var i = 0; i < chunk.Length; i += 2)
{
var s = (float)(0.2 * Math.Sin(phase));
phase += 2 * Math.PI * 440 / 48000;
chunk[i] = s; chunk[i + 1] = s;
}
tap(chunk.AsMemory(), RenderRoute.Mixed);
Thread.Sleep(2);
}
}
controller.Stop();
for (var i = 0; i < 40 && Directory.GetFiles(temp, "*.wav", SearchOption.AllDirectories).Length == 0; i++) Thread.Sleep(25);
var files = Directory.GetFiles(temp, "*.wav", SearchOption.AllDirectories);
Check(files.Length >= 2, $"split recording must make one file per peer plus your own (found {files.Length})");
Check(files.Any(f => new FileInfo(f).Length > 200), "at least one split track (your own send) must have real content");
return $"split recording made {files.Length} track files, one with content";
}
finally { try { Directory.Delete(temp, recursive: true); } catch { /* best-effort */ } }
}
/// <summary>Load/soak: rapidly start, feed, stop and dispose recordings across every format, checking
/// nothing leaks handles across the churn — catches recorder/encoder lifecycle leaks and races that a
/// single recording wouldn't surface. Set the env var REMSOUND_TEST_SOAK=&lt;seconds&gt; to keep