Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions SysManager/SysManager.Tests/AudioMixerViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,90 @@ public async Task RefreshingDevices_KeepsEachRowsChoice_EvenWhenTheDefaultMoves(
Assert.Contains(row.SelectedOutputDevice, row.OutputDevices);
}

// ── The default entry means "follow the system default", in both directions ──────────
// The picker holds real endpoints only — there is no "System default" item — so the entry flagged
// IsDefault carries that meaning instead, and an empty endpoint id is the pivot. Reading, an empty
// id selects that entry; writing, selecting that entry sends an empty id, which is what
// SetPersistedDefaultAudioEndpoint treats as CLEAR THE OVERRIDE.
//
// Nothing pinned either direction. `value.IsDefault ? string.Empty : value.Id` reads like a needless
// special case, and simplifying it to `value.Id` compiles, keeps every existing test green, and
// silently removes the only way a user can stop routing an app — the override would stay in Windows
// forever, re-pinned to whichever device is default at the time of the pick. Same
// invisible-capability-loss class as a command bound by no XAML.
//
// Calls are captured inside the stub rather than asserted with Received(n), for the reason the
// neighbouring test gives: an NSubstitute substitute is not thread-safe.

private static IAudioMixerService RoutableService(
List<(string Session, string Device)> writes, string? route = null)
{
var service = ServiceWith(Session("s1"));
service.IsRoutingSupported.Returns(true);
service.GetRenderDevices().Returns(_ => new List<AudioDevice> { Speakers, Headset });
if (route is not null) service.GetSessionOutputDevice("s1").Returns(route);
service.SetSessionOutputDevice(Arg.Any<string>(), Arg.Any<string>()).Returns(call =>
{
writes.Add(((string)call[0], (string)call[1]));
return true;
});
return service;
}

/// <summary>
/// Routing an app to a device and then putting it back on the default must CLEAR the override, not pin
/// the app to whichever device happens to be default right now.
/// <para>Goes headset-then-default rather than selecting the default directly: the row is built with the
/// default already selected (the service's route-read is a stub returning empty), so assigning it again
/// is not a property change, the write path would never run, and the test would pass while asserting
/// nothing.</para>
/// </summary>
[Fact]
public void PuttingAnAppBackOnTheDefaultDevice_ClearsTheOverride()
{
var writes = new List<(string Session, string Device)>();
using var vm = NewVm(RoutableService(writes));
var row = vm.Sessions.Single();

row.SelectedOutputDevice = row.OutputDevices.Single(d => d.Id == "{hdst}");
row.SelectedOutputDevice = row.OutputDevices.Single(d => d.IsDefault);

Assert.Equal([("s1", "{hdst}"), ("s1", "")], writes);
}

/// <summary>
/// A real device keeps its own endpoint id on the way to the service — the clear-the-override branch
/// must not swallow an ordinary pick.
/// </summary>
[Fact]
public void RoutingAnAppToANonDefaultDevice_SendsThatDevicesEndpointId()
{
var writes = new List<(string Session, string Device)>();
using var vm = NewVm(RoutableService(writes));

vm.Sessions.Single().SelectedOutputDevice = Headset;

Assert.Equal([("s1", "{hdst}")], writes);
}

/// <summary>
/// The read mirror: a route the service cannot resolve selects the default entry, and doing so must NOT
/// write back. A refresh that echoed its own snapshot would re-assert a route on every pass, and on the
/// failure branch would report a routing error the user never caused.
/// </summary>
[Theory]
[InlineData("", "the route-read stub returns empty, so nothing is known about this app's route")]
[InlineData("{unplugged}", "the persisted route names a device that is no longer present")]
public void ARouteTheServiceCannotResolve_SelectsTheDefaultEntry_AndWritesNothing(string route, string why)
{
var writes = new List<(string Session, string Device)>();
using var vm = NewVm(RoutableService(writes, route));

var row = vm.Sessions.Single();
Assert.True(row.SelectedOutputDevice?.IsDefault, why);
Assert.Empty(writes);
}

/// <summary>
/// Devices must NOT be re-enumerated on every reconcile pass. Enumerating endpoints is COM-heavy and
/// reconcile runs at 1 Hz; trading a stale list for that every second would be a worse bug than the one
Expand Down
14 changes: 12 additions & 2 deletions SysManager/SysManager/Services/AudioPolicyConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,18 @@ private enum ERole { Console = 0, Multimedia = 1, Communications = 2 }

/// <summary>
/// Route-read is intentionally not attempted (its out-string marshaling is the most build-variant
/// slot); the UI shows "System default" and lets the user pick. Returns null. Kept as the seam so
/// a future, verified read path can slot in without changing callers.
/// slot). Returns null. Kept as the seam so a future, verified read path can slot in without
/// changing callers.
/// <para>What the null actually produces, because the previous note here said "the UI shows
/// <c>System default</c>" and that is not what happens: <c>AudioMixerService.GetSessionOutputDevice</c>
/// turns it into an empty id, and <c>AudioSessionRowViewModel.SetOutputDeviceFromService</c> resolves an
/// empty id to the entry flagged <c>IsDefault</c> — so the picker preselects the current default device
/// BY NAME. There is no "System default" item; the list holds real endpoints only. An app genuinely
/// routed elsewhere is therefore indistinguishable from one following the default (#1585).</para>
/// <para>The empty id is the pivot in both directions: selecting the <c>IsDefault</c> entry sends empty
/// back through <c>SetPersistedDefaultAudioEndpoint</c>, which CLEARS the override. That makes the
/// default entry the only way a user can stop routing an app, which is why the round trip is pinned by
/// tests rather than left to this comment.</para>
/// </summary>
public static string? GetPersistedDefaultEndpoint(object policyConfig, uint processId)
{
Expand Down