diff --git a/AGENTS.md b/AGENTS.md index 077ac9b..dc74269 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,8 @@ src/SystemMedia/ thumbnail history, "any app" picker, instance ids, remembered apps, settings, YouTube correction Events/ event definitions, change detection, the once-a-second watcher - Variables/ deck variables for the current session and the default sound devices + Variables/ deck variables for the current session and the default sound devices, and a + catalog with each known app's own (pushed while subscribed) Platform/ IMediaPlatform.cs the interface every OS backend implements IAudioDevices.cs the default sound devices, read and switched per OS @@ -45,7 +46,8 @@ Look there instead of guessing. **Public API.** These are persisted in users' widgets, templates and automations. Renaming one breaks them: -- instance ids (`InstanceIds.ForApp`, `InstanceIds.Current`; a test pins the exact output) +- instance ids (`InstanceIds.ForApp`, `InstanceIds.Current`; a test pins the exact output) and the per-app catalog + variable ids built from them (`system-media--`), which the host keeps for linked variables - event ids, event parameter names and variable names (`system_media_*`) - player app ids, `player:` (`player:mpv`, `player:smplayer`) - the data files `known-apps.json` (its `appUserModelId` field is the 1.x name), `settings.json` and @@ -172,6 +174,9 @@ dotnet test SystemMedia.slnx -p:SystemMediaWindows=false # the non-Windows vari make conformance # after changing capability shape, cancellation or the manifest ``` +`make conformance` runs in demo mode: the suite runs actions and writes variables, which would otherwise +pause, skip, seek or switch whatever is playing on the machine. + Some tests run against the machine's real media sessions and only assert what holds whether or not something plays. To see real apps, use `make run` while a browser or Spotify plays. diff --git a/Makefile b/Makefile index 03514b1..beebe7b 100644 --- a/Makefile +++ b/Makefile @@ -65,8 +65,9 @@ screenshots: pack: $(PACK) +# Demo mode: the suite runs actions and writes variables, which would pause, skip or seek what is playing. conformance: - macrodeck-plugin test --project $(PROJECT) --report markdown --output conformance.md + export SYSTEM_MEDIA_DEMO="$(CURDIR)/demo" && macrodeck-plugin test --project $(PROJECT) --report markdown --output conformance.md update: dotnet package update diff --git a/README.md b/README.md index 31a3860..7c33885 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,11 @@ For whatever is playing right now (the same as *Any app*): | `system_media_volume` | The app's volume in percent; can be set | | `system_media_shuffle_enabled`, `system_media_repeat_mode` | `repeat_mode` is `off`, `track` or `context` | +Every app listed as an instance also has its own copy of these, except the app name, so a "Now Playing" text +can follow one player while another plays too. They are in a folder per app in the variable picker, e.g. +*Spotify: Artist*, and named after the app, e.g. `system_media_vlc_media_player_artist`. Two apps with the +same name get a short code each, e.g. `system_media_firefox_1a2b3c4d_artist`. + ### Sound devices Whatever is playing, these name the system's default sound devices: diff --git a/src/SystemMedia/Core/InstanceIds.cs b/src/SystemMedia/Core/InstanceIds.cs index acbd1eb..c5c9a57 100644 --- a/src/SystemMedia/Core/InstanceIds.cs +++ b/src/SystemMedia/Core/InstanceIds.cs @@ -19,7 +19,7 @@ internal static string ForApp(string appUserModelId) return slug.Length == 0 ? AppPrefix + hash : $"{AppPrefix}{slug}-{hash}"; } - private static string Slug(string value) + internal static string Slug(string value) { var builder = new StringBuilder(Math.Min(value.Length, MaxSlugLength)); foreach (var character in value) diff --git a/src/SystemMedia/Localization/Strings.resx b/src/SystemMedia/Localization/Strings.resx index eedeeb6..6bbeab3 100644 --- a/src/SystemMedia/Localization/Strings.resx +++ b/src/SystemMedia/Localization/Strings.resx @@ -97,6 +97,39 @@ Repeat mode + + {app}: Track name + + + {app}: Artist + + + {app}: Album + + + {app}: Playback state + + + {app}: Is playing + + + {app}: Position + + + {app}: Duration + + + {app}: Progress + + + {app}: App volume + + + {app}: Shuffle enabled + + + {app}: Repeat mode + Default output device diff --git a/src/SystemMedia/PluginIntegration.cs b/src/SystemMedia/PluginIntegration.cs index 4b9f191..7d43087 100644 --- a/src/SystemMedia/PluginIntegration.cs +++ b/src/SystemMedia/PluginIntegration.cs @@ -1,3 +1,4 @@ +using System.Globalization; using MacroDeck.Plugin.Hosting; using MacroDeck.Plugin.Hosting.Integrations.HostApis; using MacroDeck.Plugin.Protocol.Handshake; @@ -56,6 +57,7 @@ public sealed class PluginIntegration private readonly IReadOnlyList _currentOptions; private readonly StateSnapshotCache _currentState; private readonly MediaEventWatcher _events; + private readonly AppVariablePublisher _appVariables; private readonly Lock _appsGate = new(); private volatile KnownApp[] _apps = []; @@ -66,9 +68,11 @@ public PluginIntegration(IPluginCatalogNotifier catalog, IOptions(); var dataDirectory = hostOptions.Value.DataDirectory; + var demo = false; #if DEBUG - // Made-up apps must not be remembered as real ones. - if (Environment.GetEnvironmentVariable(Demo.DemoMediaPlatform.EnvironmentVariable) is { Length: > 0 }) + // Made-up apps must not be remembered as real ones, and conformance runs here must not control a real player. + demo = Environment.GetEnvironmentVariable(Demo.DemoMediaPlatform.EnvironmentVariable) is { Length: > 0 }; + if (demo) { dataDirectory = null; } @@ -79,13 +83,12 @@ public PluginIntegration(IPluginCatalogNotifier catalog, IOptions Variables => SystemMediaVariables.All; + // Each known app's own variables, one folder per app. A catalog has no limit on how many it offers. + public bool SupportsCatalog => true; + + public bool SupportsPush => true; + + // Without search the host flattens the catalog and lists no folders. + public bool SupportsSearch => true; + public IReadOnlyList EventDefinitions => MediaEvents.All; public bool RequiresConfiguration => false; @@ -135,7 +146,7 @@ public async Task InitializeAsync(IIntegrationContext context) if (RememberActiveApps() || changed) { - _catalog.CatalogChanged(CapabilityKinds.MusicPlayer, reason: "media apps discovered"); + AppsChanged("media apps discovered"); } _events.Start(context.Events); @@ -148,12 +159,14 @@ public async Task ShutdownAsync() { _platform.SessionsChanged -= OnSessionsChanged; await _events.StopAsync(); + await _appVariables.StopAsync(); } public void Dispose() { _platform.SessionsChanged -= OnSessionsChanged; _events.Dispose(); + _appVariables.Dispose(); _platform.Dispose(); _youTube.Dispose(); _youTubeLookup.Dispose(); @@ -181,19 +194,113 @@ public async ValueTask ReadAsync(string localId, CancellationTo : VariableReading.Unavailable; } + if (FindAppVariable(localId) is var (app, currentId)) + { + return SystemMediaVariables.Read(currentId, await app.State.GetAsync(cancellationToken)); + } + var state = await _currentState.GetAsync(cancellationToken); return SystemMediaVariables.Read(localId, state); } - public ValueTask SetValueAsync(string localId, object? value, CancellationToken cancellationToken = default) => - SystemMediaVariables.DeviceRole(localId) is { } role - ? SetDefaultAudioDeviceAsync(role, value, cancellationToken) - : localId switch + public ValueTask SetValueAsync(string localId, object? value, CancellationToken cancellationToken = default) + { + if (SystemMediaVariables.DeviceRole(localId) is { } role) + { + return SetDefaultAudioDeviceAsync(role, value, cancellationToken); + } + + IMusicPlayer player = _current; + if (FindAppVariable(localId) is var (app, currentId)) + { + player = app.Player; + localId = currentId; + } + + return localId switch + { + SystemMediaVariables.VolumeId => MusicPlayerVariableWrites.SetVolumeAsync(player, value, cancellationToken), + SystemMediaVariables.PositionId => MusicPlayerVariableWrites.SeekAsync(player, value, cancellationToken), + _ => ValueTask.FromResult(VariableWriteResult.NotWritable()), + }; + } + + public ValueTask DiscoverAsync(VariableCatalogQuery query, CancellationToken cancellationToken = default) + { + var apps = _apps; + IReadOnlyList items = query switch + { + { ParentId: null, Search: { Length: > 0 } search } => + [.. apps.SelectMany(app => AppVariablesOf(apps, app).Where(variable => SystemMediaVariables.Matches(variable, app.DisplayName, search)))], + { ParentId: null } => [.. apps.Select(app => SystemMediaVariables.AppFolder(app.InstanceId, app.DisplayName))], + _ => Array.Find(apps, app => SystemMediaVariables.AppFolderId(app.InstanceId) == query.ParentId) is { } parent + ? AppVariablesOf(apps, parent) + : [], + }; + + var offset = int.TryParse(query.ContinuationToken, CultureInfo.InvariantCulture, out var token) ? Math.Max(token, 0) : 0; + var next = offset + query.PageSize; + return ValueTask.FromResult(new VariableCatalogPage + { + Items = [.. items.Skip(offset).Take(query.PageSize)], + ContinuationToken = next < items.Count ? next.ToString(CultureInfo.InvariantCulture) : null, + }); + } + + public ValueTask ResolveAsync(string localId, CancellationToken cancellationToken = default) + { + var apps = _apps; + return ValueTask.FromResult(apps + .SelectMany(app => (IEnumerable)[SystemMediaVariables.AppFolder(app.InstanceId, app.DisplayName), .. AppVariablesOf(apps, app)]) + .FirstOrDefault(definition => definition.Id == localId)); + } + + public async ValueTask> SubscribeAsync(IReadOnlyCollection localIds, CancellationToken cancellationToken = default) + { + var values = await ReadAppVariablesAsync(localIds, cancellationToken); + _appVariables.Subscribe(localIds, values); + return values; + } + + // The host keeps the catalog it browsed last, even across a plugin restart, until told to reload it. + public Task OnAttachedAsync(IVariableSink sink, CancellationToken cancellationToken = default) + { + _appVariables.Start(sink); + return sink.InvalidateCatalogAsync(cancellationToken); + } + + private async Task> ReadAppVariablesAsync(IReadOnlyCollection localIds, CancellationToken cancellationToken) + { + var values = new List(); + foreach (var localId in localIds) + { + if (FindAppVariable(localId) is var (app, currentId)) { - SystemMediaVariables.VolumeId => MusicPlayerVariableWrites.SetVolumeAsync(_current, value, cancellationToken), - SystemMediaVariables.PositionId => MusicPlayerVariableWrites.SeekAsync(_current, value, cancellationToken), - _ => ValueTask.FromResult(VariableWriteResult.NotWritable()), - }; + values.Add(VariableValue.Of(localId, SystemMediaVariables.Read(currentId, await app.State.GetAsync(cancellationToken)))); + } + } + + return values; + } + + private static IReadOnlyList AppVariablesOf(KnownApp[] apps, KnownApp app) => + SystemMediaVariables.ForApp( + app.InstanceId, + app.DisplayName, + SystemMediaVariables.NameKeys(apps.Select(known => (known.InstanceId, known.DisplayName)))[app.InstanceId]); + + private (KnownApp App, string CurrentId)? FindAppVariable(string localId) + { + foreach (var app in _apps) + { + if (SystemMediaVariables.CurrentIdOf(localId, app.InstanceId) is { } currentId) + { + return (app, currentId); + } + } + + return null; + } private async ValueTask SetDefaultAudioDeviceAsync(AudioDeviceRole role, object? value, CancellationToken cancellationToken) { @@ -232,7 +339,7 @@ public async Task ResolveIssueAsync(string issueId, Cancellatio var resolution = await _platform.ResolveIssueAsync(issueId, cancellationToken); if (resolution is { Success: true } && RememberActiveApps()) { - _catalog.CatalogChanged(CapabilityKinds.MusicPlayer, reason: "media issue resolved"); + AppsChanged("media issue resolved"); } return resolution ?? IssueResolution.Failed(Strings.Issues.Unknown()); @@ -354,10 +461,16 @@ private void OnSessionsChanged(object? sender, EventArgs e) { if (RememberActiveApps()) { - _catalog.CatalogChanged(CapabilityKinds.MusicPlayer, reason: "media apps changed"); + AppsChanged("media apps changed"); } } + private void AppsChanged(string reason) + { + _catalog.CatalogChanged(CapabilityKinds.MusicPlayer, reason: reason); + _ = _appVariables.Sink?.InvalidateCatalogAsync(); + } + private async Task TryStartPlatformAsync(CancellationToken cancellationToken) { try @@ -424,11 +537,13 @@ private bool Merge(IReadOnlyList apps, bool active) continue; } + var player = CreatePlayer(app.AppId); merged.Add(new KnownApp( InstanceIds.ForApp(app.AppId), app.AppId, name ?? app.Name ?? _platform.ResolveApp(app.AppId).DisplayName, - CreatePlayer(app.AppId))); + player, + new StateSnapshotCache(player.GetStateAsync, TimeProvider.System))); changed = true; } @@ -443,5 +558,5 @@ private bool Merge(IReadOnlyList apps, bool active) private SessionMusicPlayer CreatePlayer(string? appId, TimeSpan cycleInterval = default) => new(_platform, _selection, appId, TimeProvider.System, _youTube, _thumbnails, cycleInterval); - private sealed record KnownApp(string InstanceId, string AppId, string DisplayName, SessionMusicPlayer Player); + private sealed record KnownApp(string InstanceId, string AppId, string DisplayName, SessionMusicPlayer Player, StateSnapshotCache State); } diff --git a/src/SystemMedia/Variables/AppVariablePublisher.cs b/src/SystemMedia/Variables/AppVariablePublisher.cs new file mode 100644 index 0000000..3ee38b2 --- /dev/null +++ b/src/SystemMedia/Variables/AppVariablePublisher.cs @@ -0,0 +1,132 @@ +using System.Collections.Concurrent; +using MacroDeck.Sdk.Logging; +using MacroDeck.Sdk.Variables; +using Serilog; + +namespace SystemMedia.Variables; + +// Catalog variables reach the host by push, so the subscribed ones are read every second and sent when they change. +internal sealed class AppVariablePublisher( + Func, CancellationToken, Task>> read, + ILogger logger) : IDisposable +{ + private static readonly TimeSpan _interval = TimeSpan.FromSeconds(1); + + private readonly ConcurrentDictionary _published = new(StringComparer.Ordinal); + private readonly FailureEpisodeTracker _failures = new(); + private readonly Lock _gate = new(); + + private volatile IVariableSink? _sink; + private volatile IReadOnlyCollection _subscribed = []; + private CancellationTokenSource? _stopping; + private Task? _loop; + + internal IVariableSink? Sink => _sink; + + internal void Start(IVariableSink sink) + { + _sink = sink; + lock (_gate) + { + if (_loop is not null) + { + return; + } + + _stopping = new CancellationTokenSource(); + _loop = Task.Run(() => RunAsync(_stopping.Token)); + } + } + + internal async Task StopAsync() + { + Task? loop; + lock (_gate) + { + loop = _loop; + _stopping?.Cancel(); + _loop = null; + } + + if (loop is not null) + { + await loop.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } + + public void Dispose() + { + lock (_gate) + { + _stopping?.Cancel(); + _stopping?.Dispose(); + _stopping = null; + } + } + + // The host receives these values with its subscription, so they count as published. + internal void Subscribe(IReadOnlyCollection ids, IReadOnlyList current) + { + _subscribed = ids; + foreach (var id in _published.Keys.Except(ids, StringComparer.Ordinal)) + { + _published.TryRemove(id, out _); + } + + foreach (var value in current) + { + _published[value.Id] = value.Reading; + } + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(_interval); + try + { + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + await TickAsync(cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private async Task TickAsync(CancellationToken cancellationToken) + { + var ids = _subscribed; + if (ids.Count == 0 || _sink is not { } sink) + { + return; + } + + try + { + var changed = (await read(ids, cancellationToken)) + .Where(value => !_published.TryGetValue(value.Id, out var last) || last != value.Reading) + .ToList(); + if (changed.Count > 0) + { + await sink.PublishAsync(changed, cancellationToken); + foreach (var value in changed) + { + _published[value.Id] = value.Reading; + } + } + + if (_failures.RecordSuccess() is { } episode) + { + logger.Information("App variable publisher recovered after {Duration} ({Failures} failed rounds).", episode.Duration, episode.Failures); + } + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + if (_failures.RecordFailure(exception.Message).Kind == FailureEpisodeSignalKind.Onset) + { + logger.Warning(exception, "App variable publisher could not read the media sessions; app variables stay as they are until it can."); + } + } + } +} diff --git a/src/SystemMedia/Variables/SystemMediaVariables.cs b/src/SystemMedia/Variables/SystemMediaVariables.cs index b0892e4..ced7635 100644 --- a/src/SystemMedia/Variables/SystemMediaVariables.cs +++ b/src/SystemMedia/Variables/SystemMediaVariables.cs @@ -1,6 +1,7 @@ using MacroDeck.Localization; using MacroDeck.Sdk.MusicPlayer; using MacroDeck.Sdk.Variables; +using SystemMedia.Core; using SystemMedia.Platform; namespace SystemMedia.Variables; @@ -11,6 +12,8 @@ internal static class SystemMediaVariables internal const string PositionId = "system-media-position"; internal const string VolumeId = "system-media-volume"; + private const string IdPrefix = "system-media-"; + private static readonly TimeSpan _fast = TimeSpan.FromSeconds(1); private static readonly TimeSpan _normal = TimeSpan.FromSeconds(2); private static readonly TimeSpan _slow = TimeSpan.FromSeconds(5); @@ -66,6 +69,82 @@ internal static class SystemMediaVariables internal static IReadOnlyList All { get; } = [.. _entries.Select(entry => entry.Definition), .. _devices.Select(device => device.Definition)]; + // One string per variable: a localized argument inside another does not reach the host. No app name, the app is in the name. + private static readonly Dictionary> _appDisplayNames = new(StringComparer.Ordinal) + { + ["system_media_track_name"] = Strings.Variables.App.TrackName, + ["system_media_artist"] = Strings.Variables.App.Artist, + ["system_media_album"] = Strings.Variables.App.Album, + ["system_media_playback_state"] = Strings.Variables.App.PlaybackState, + ["system_media_is_playing"] = Strings.Variables.App.IsPlaying, + ["system_media_position"] = Strings.Variables.App.Position, + ["system_media_duration"] = Strings.Variables.App.Duration, + ["system_media_progress_percentage"] = Strings.Variables.App.ProgressPercentage, + ["system_media_volume"] = Strings.Variables.App.Volume, + ["system_media_shuffle_enabled"] = Strings.Variables.App.ShuffleEnabled, + ["system_media_repeat_mode"] = Strings.Variables.App.RepeatMode, + }; + + private static readonly Entry[] _appEntries = [.. _entries.Where(entry => _appDisplayNames.ContainsKey(entry.Definition.Name!))]; + + internal static string AppFolderId(string instanceId) => IdPrefix + instanceId; + + internal static VariableDefinition AppFolder(string instanceId, string appName) => + VariableDefinition.OnDemand(AppFolderId(instanceId), VariableType.Text) with + { + DisplayName = appName, + IsContainer = true, + IsBindable = false, + }; + + // The host keeps the id of a linked variable, not its name, so a name may take the hash only while two apps clash. + internal static IReadOnlyDictionary NameKeys(IEnumerable<(string InstanceId, string AppName)> apps) + { + var slugs = apps.Select(app => (app.InstanceId, Slug: InstanceIds.Slug(app.AppName).Replace('-', '_'))).ToList(); + var shared = slugs.GroupBy(app => app.Slug, StringComparer.Ordinal).Where(group => group.Count() > 1).Select(group => group.Key).ToHashSet(StringComparer.Ordinal); + return slugs.ToDictionary( + app => app.InstanceId, + app => app.Slug.Length > 0 && !shared.Contains(app.Slug) ? app.Slug : WithHash(app.Slug, app.InstanceId), + StringComparer.Ordinal); + } + + internal static IReadOnlyList ForApp(string instanceId, string appName, string nameKey) => + [ + .. _appEntries.Select(entry => + { + var id = AppVariableId(instanceId, entry.Definition.ResolvedId!); + return entry.Definition with + { + Id = id, + Name = $"system_media_{nameKey}_{entry.Definition.Name!["system_media_".Length..]}", + Materialization = VariableMaterialization.OnDemand, + ParentId = AppFolderId(instanceId), + DisplayName = _appDisplayNames[entry.Definition.Name!](appName), + }; + }), + ]; + + // Every word typed has to appear in the variable's name or its app's name, so "vlc artist" finds VLC's artist. + internal static bool Matches(VariableDefinition variable, string appName, string search) + { + var text = $"{variable.Name} {appName}"; + return search.Split([' ', '_', '.'], StringSplitOptions.RemoveEmptyEntries) + .All(word => text.Contains(word, StringComparison.OrdinalIgnoreCase)); + } + + // The id of the "any app" variable that an app's variable reads like, or null if it is none of that app's. + internal static string? CurrentIdOf(string localId, string instanceId) + { + var prefix = AppFolderId(instanceId) + "-"; + if (!localId.StartsWith(prefix, StringComparison.Ordinal)) + { + return null; + } + + var currentId = IdPrefix + localId[prefix.Length..]; + return Array.Exists(_appEntries, entry => entry.Definition.ResolvedId == currentId) ? currentId : null; + } + internal static AudioDeviceRole? DeviceRole(string localId) => Array.Find(_devices, candidate => string.Equals(candidate.Definition.ResolvedId, localId, StringComparison.Ordinal))?.Role; @@ -94,6 +173,15 @@ internal static VariableReading Read(string localId, MusicPlayerState state) private static Entry Text(string name, LocalizedText displayName, Func read, TimeSpan? refresh = null) => new(VariableDefinition.Eager(name, VariableType.Text, refreshInterval: refresh ?? _normal) with { DisplayName = displayName }, read); + private static string WithHash(string slug, string instanceId) + { + var hash = instanceId[(instanceId.LastIndexOf('-') + 1)..]; + return slug.Length == 0 ? hash : $"{slug}_{hash}"; + } + + private static string AppVariableId(string instanceId, string currentId) => + $"{AppFolderId(instanceId)}-{currentId[IdPrefix.Length..]}"; + private sealed record Entry(VariableDefinition Definition, Func Read); private static DeviceEntry Device(string name, LocalizedText displayName, AudioDeviceRole role) => diff --git a/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs b/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs index 62cd3c5..fc32e3f 100644 --- a/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs +++ b/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs @@ -1,5 +1,8 @@ +using MacroDeck.Localization; using MacroDeck.Sdk.MusicPlayer; +using MacroDeck.Sdk.Variables; using NUnit.Framework; +using SystemMedia.Core; using SystemMedia.Platform; using SystemMedia.Variables; @@ -70,6 +73,93 @@ public void The_position_slider_ends_at_the_track_duration() Assert.That(reading.Max, Is.EqualTo(256)); } + [TestCase("Spotify.exe")] + [TestCase("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic.Very.Long.Application.Identifier.That.Exceeds.Limits")] + [TestCase("---")] + public void Every_app_variable_is_an_on_demand_variable_in_its_app_folder(string appId) + { + var instanceId = InstanceIds.ForApp(appId); + var variables = SystemMediaVariables.ForApp(instanceId, "App", "app"); + + Assert.That(variables.Select(variable => variable.Id), Has.All.Matches(InstanceIdsTests.LocalIdPattern).And.All.Length.LessThanOrEqualTo(256)); + Assert.That(variables.Select(variable => variable.Id), Is.Unique); + Assert.That(variables.Select(variable => variable.Id), Has.None.AnyOf(SystemMediaVariables.All.Select(variable => variable.ResolvedId).ToArray())); + Assert.That(variables.Select(variable => variable.Materialization), Has.All.EqualTo(VariableMaterialization.OnDemand)); + Assert.That(variables.Select(variable => variable.ParentId), Has.All.EqualTo(SystemMediaVariables.AppFolderId(instanceId))); + } + + // Pinned: the host keeps the id of every linked variable. + [Test] + public void An_app_variable_is_identified_by_the_app_instance_and_named_after_the_app() + { + var keys = SystemMediaVariables.NameKeys([("app-vlc-exe-2900ecd1", "VLC media player")]); + var artist = SystemMediaVariables.ForApp("app-vlc-exe-2900ecd1", "VLC media player", keys["app-vlc-exe-2900ecd1"]) + .Single(variable => variable.Id!.EndsWith("-artist", StringComparison.Ordinal)); + + Assert.That(artist.Id, Is.EqualTo("system-media-app-vlc-exe-2900ecd1-artist")); + Assert.That(artist.Name, Is.EqualTo("system_media_vlc_media_player_artist")); + } + + [Test] + public void Apps_with_the_same_name_are_told_apart_by_their_hash() + { + var keys = SystemMediaVariables.NameKeys([("app-firefox-11111111", "Firefox"), ("app-firefox-profile-22222222", "Firefox"), ("app-vlc-exe-33333333", "VLC")]); + + Assert.That(keys["app-firefox-11111111"], Is.EqualTo("firefox_11111111")); + Assert.That(keys["app-firefox-profile-22222222"], Is.EqualTo("firefox_22222222")); + Assert.That(keys["app-vlc-exe-33333333"], Is.EqualTo("vlc")); + } + + [Test] + public void An_app_name_without_letters_falls_back_to_the_hash() + { + Assert.That(SystemMediaVariables.NameKeys([("app-44444444", "音楽")])["app-44444444"], Is.EqualTo("44444444")); + } + + [TestCase("system-media-app-spotify-exe-b527fa0a-track-name", "system-media-track-name")] + [TestCase("system-media-app-spotify-exe-b527fa0a-volume", SystemMediaVariables.VolumeId)] + [TestCase("system-media-app-spotify-exe-b527fa0a", null)] + [TestCase("system-media-app-spotify-exe-b527fa0a-app-name", null)] + [TestCase("system-media-app-vlc-exe-0011aabb-artist", null)] + [TestCase(SystemMediaVariables.VolumeId, null)] + public void An_app_variable_reads_like_its_any_app_variable(string localId, string? currentId) + { + Assert.That(SystemMediaVariables.CurrentIdOf(localId, "app-spotify-exe-b527fa0a"), Is.EqualTo(currentId)); + } + + // The host resolves the text itself and drops a localized argument nested inside it. + [Test] + public void An_app_variable_name_carries_the_app_as_plain_text_only() + { + var names = SystemMediaVariables.ForApp("app-spotify-exe-b527fa0a", "Spotify", "spotify").Select(variable => variable.DisplayName.Localized!.Value); + + Assert.That(names.Select(name => name.Arguments.Values.Single()), Has.All.EqualTo((LocalizedText)"Spotify")); + } + + [TestCase("vlc artist", true)] + [TestCase("VLC Media", true)] + [TestCase("system_media_vlc_media_player_artist", true)] + [TestCase("player", true)] + [TestCase("firefox artist", false)] + [TestCase("vlc album", false)] + public void A_search_finds_an_app_variable_by_its_name_or_its_app(string search, bool found) + { + var artist = SystemMediaVariables.ForApp("app-vlc-exe-2900ecd1", "VLC media player", "vlc_media_player") + .Single(variable => variable.Id!.EndsWith("-artist", StringComparison.Ordinal)); + + Assert.That(SystemMediaVariables.Matches(artist, "VLC media player", search), Is.EqualTo(found)); + } + + [Test] + public void An_app_folder_holds_variables_but_is_none() + { + var folder = SystemMediaVariables.AppFolder("app-spotify-exe-b527fa0a", "Spotify"); + + Assert.That(folder.Id, Is.EqualTo("system-media-app-spotify-exe-b527fa0a")); + Assert.That(folder.IsContainer, Is.True); + Assert.That(folder.IsBindable, Is.False); + } + [Test] public void A_stream_without_a_timeline_has_no_progress() {