From b180ceca7a78bdc6b2e9dc7c12e93916c48ec3a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 19:04:25 +0000 Subject: [PATCH 1/2] [ADR-178] ICloudAsset abstraction, JsonCloudAsset, and stub file downloader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the ICloudAsset/ICloudAsset abstraction with BasicCloudAsset and JsonCloudAsset, a FileCloudAssetDownloader that reads from a stub JSON file, and dev/layout.json for both WPF and Headless hosts. CloudAssetOrchestrator now loops over IEnumerable, downloading and parsing each asset via the injected ICloudAssetDownloader rather than constructing a hardcoded CompiledLayout in C#. This exercises the same download→parse→store path the real HTTP-backed orchestrator will use. AddScopedCloudAsset now accepts an ICloudAsset instance directly and co-locates the ICloudAsset singleton registration with the scoped DI accessor. CloudAssetStoreExtensions (SetLayout/GetLayout helpers) is deleted as unused. The "Info" TiVo button in layout.json is absent from the old hardcoded stub, so E2E assertions on it prove the file is being loaded and parsed correctly. https://claude.ai/code/session_01VHZuzd1UHqNKDxtkLZu7pz --- .../CloudAssetServiceExtensions.cs | 7 +- .../Configuration/HostBuilderExtensions.cs | 7 +- .../Services/CloudAssets/BasicCloudAsset.cs | 19 +++++ .../CloudAssets/CloudAssetOrchestrator.cs | 81 +++++++------------ .../CloudAssets/CloudAssetStoreExtensions.cs | 14 ---- .../Services/CloudAssets/CloudSettings.cs | 1 + .../CloudAssets/FileCloudAssetDownloader.cs | 29 +++++++ .../Services/CloudAssets/JsonCloudAsset.cs | 20 +++++ .../AdaptiveRemote.Headless.csproj | 5 ++ .../appsettings.Development.json | 5 ++ src/AdaptiveRemote.Headless/dev/layout.json | 58 +++++++++++++ src/AdaptiveRemote/AdaptiveRemote.csproj | 1 + .../appsettings.Development.json | 3 + src/AdaptiveRemote/dev/layout.json | 58 +++++++++++++ .../FileCloudAssetDownloaderTests.cs | 59 ++++++++++++++ .../CloudAssets/JsonCloudAssetTests.cs | 51 ++++++++++++ .../Features/Layout/StubLayout.feature | 8 ++ .../Features/Shared/LayoutButtons.feature | 1 + 18 files changed, 357 insertions(+), 70 deletions(-) create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs delete mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/FileCloudAssetDownloader.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs create mode 100644 src/AdaptiveRemote.Headless/appsettings.Development.json create mode 100644 src/AdaptiveRemote.Headless/dev/layout.json create mode 100644 src/AdaptiveRemote/dev/layout.json create mode 100644 test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs create mode 100644 test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs create mode 100644 test/AdaptiveRemote.EndToEndTests.Host.Headless/Features/Layout/StubLayout.feature diff --git a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs index 3fd77152..c4134412 100644 --- a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs @@ -9,12 +9,15 @@ internal static class CloudAssetServiceExtensions internal static IServiceCollection AddCloudAssetServices(this IServiceCollection services) => services .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton(sp => sp.GetRequiredService()) .AddHostedService(sp => sp.GetRequiredService()); internal static IServiceCollection AddScopedCloudAsset( - this IServiceCollection services, string name) + this IServiceCollection services, ICloudAsset asset) where T : class - => services.AddScoped(sp => sp.GetRequiredService().Get(name)); + => services + .AddSingleton(asset) + .AddScoped(sp => sp.GetRequiredService().Get(asset.Name)); } diff --git a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs index 24ef8d16..2f4f80a5 100644 --- a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs @@ -29,7 +29,12 @@ internal static IServiceCollection AddRemoteServices(this IServiceCollection ser => services .AddApplicationLifecycleServices() .AddCloudAssetServices() - .AddScopedCloudAsset("layout") + .AddScopedCloudAsset(new JsonCloudAsset( + name: "layout", + streamUrl: "/notifications/layouts/stream", + eventName: "layout-ready", + resourcePath: "/layouts/compiled", + jsonContext: LayoutContractsJsonContext.Default)) .AddScopedLifecycleService() .AddScoped() .AddScoped() diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs b/src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs new file mode 100644 index 00000000..513ace3f --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs @@ -0,0 +1,19 @@ +namespace AdaptiveRemote.Services.CloudAssets; + +internal abstract class BasicCloudAsset : ICloudAsset +{ + public string Name { get; } + public string StreamUrl { get; } + public string EventName { get; } + public string ResourcePath { get; } + + protected BasicCloudAsset(string name, string streamUrl, string eventName, string resourcePath) + { + Name = name; + StreamUrl = streamUrl; + EventName = eventName; + ResourcePath = resourcePath; + } + + public abstract Task ParseAsync(Stream stream, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs index 9dbeceb7..e4c71e43 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs @@ -1,4 +1,3 @@ -using AdaptiveRemote.Contracts; using AdaptiveRemote.Services.Lifecycle; using Microsoft.Extensions.Hosting; @@ -6,66 +5,42 @@ namespace AdaptiveRemote.Services.CloudAssets; internal class CloudAssetOrchestrator : BackgroundService, IPreScopeInitializer { + private readonly IEnumerable _assets; + private readonly ICloudAssetDownloader _downloader; private readonly ICloudAssetStore _store; private readonly TaskCompletionSource _initCompleted = new(); - public CloudAssetOrchestrator(ICloudAssetStore store) + public CloudAssetOrchestrator( + IEnumerable assets, + ICloudAssetDownloader downloader, + ICloudAssetStore store) { + _assets = assets; + _downloader = downloader; _store = store; } - protected override Task ExecuteAsync(CancellationToken stoppingToken) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - CompiledLayout layout = new( - Id: Guid.Empty, - RawLayoutId: Guid.Empty, - UserId: "stub", - IsActive: true, - Version: 1, - Elements: - [ - new LayoutGroupDefinitionDto("DPAD", - [ - new CommandDefinitionDto(CommandType.TiVo, "Up", "Up", null, "Sent Up", "Down", "Up"), - new CommandDefinitionDto(CommandType.TiVo, "Down", "Down", null, "Sent Down", "Up", "Down"), - new CommandDefinitionDto(CommandType.TiVo, "Left", "Left", null, "Sent Left", "Right", "Left"), - new CommandDefinitionDto(CommandType.TiVo, "Right", "Right", null, "Sent Right", "Left", "Right"), - new CommandDefinitionDto(CommandType.TiVo, "Select", "Select", null, "Sent Select", null, "Select"), - new CommandDefinitionDto(CommandType.TiVo, "Back", "Back", null, "Sent Back", null, "Back"), - new CommandDefinitionDto(CommandType.IR, "Power", "Power", null, "Sent Power", "Power", "Power"), - new CommandDefinitionDto(CommandType.IR, "PowerOn", "PowerOn", null, "Sent PowerOn", "PowerOff", "PowerOn"), - new CommandDefinitionDto(CommandType.IR, "PowerOff", "PowerOff", null, "Sent PowerOff", "PowerOn", "PowerOff"), - ]), - new LayoutGroupDefinitionDto("WELL", - [ - new CommandDefinitionDto(CommandType.TiVo, "TiVo", "TiVo", null, "Sent TiVo", null, "TiVo"), - new CommandDefinitionDto(CommandType.TiVo, "Netflix", "Netflix", null, "Sent Netflix", null, "Netflix"), - new CommandDefinitionDto(CommandType.TiVo, "Guide", "Guide", null, "Sent Guide", null, "Guide"), - ]), - new LayoutGroupDefinitionDto("PLAYBACK", - [ - new CommandDefinitionDto(CommandType.TiVo, "Play", "Play", null, "Sent Play", "Pause", "Play"), - new CommandDefinitionDto(CommandType.TiVo, "Pause", "Pause", null, "Sent Pause", "Play", "Pause"), - new CommandDefinitionDto(CommandType.TiVo, "Record", "Record", null, "Sent Record", null, "Record"), - new CommandDefinitionDto(CommandType.TiVo, "Skip", "Skip", null, "Sent Skip", "Replay", "Skip"), - new CommandDefinitionDto(CommandType.TiVo, "Replay", "Replay", null, "Sent Replay", "Skip", "Replay"), - ]), - new LayoutGroupDefinitionDto("CHANNELANDVOLUME", - [ - new CommandDefinitionDto(CommandType.TiVo, "ChannelUp", "Up", null, "Sent Channel Up", "ChannelDown", "ChannelUp"), - new CommandDefinitionDto(CommandType.TiVo, "ChannelDown", "Down", null, "Sent Channel Down", "ChannelUp", "ChannelDown"), - new CommandDefinitionDto(CommandType.IR, "VolumeUp", "Up", null, "Sent Volume Up", "VolumeDown", "VolumeUp"), - new CommandDefinitionDto(CommandType.IR, "VolumeDown", "Down", null, "Sent Volume Down", "VolumeUp", "VolumeDown"), - new CommandDefinitionDto(CommandType.IR, "Mute", "Mute", null, "Sent Mute", "Mute", "Mute"), - ]), - ], - CssDefinitions: "", - CompiledAt: DateTimeOffset.UtcNow); - - _store.SetLayout(layout); - _initCompleted.SetResult(); - - return Task.CompletedTask; + try + { + foreach (ICloudAsset asset in _assets) + { + Stream stream = await _downloader.GetActiveAsync(asset.ResourcePath, stoppingToken) + ?? throw new InvalidOperationException($"Failed to download asset '{asset.Name}'."); + await using (stream) + { + object value = await asset.ParseAsync(stream, stoppingToken); + _store.Set(asset.Name, value); + } + } + _initCompleted.SetResult(); + } + catch (Exception ex) + { + _initCompleted.TrySetException(ex); + throw; + } } public Task WaitAsync(ILifecycleActivity activity, CancellationToken ct) diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs deleted file mode 100644 index 9b387a7d..00000000 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using AdaptiveRemote.Contracts; - -namespace AdaptiveRemote.Services.CloudAssets; - -internal static class CloudAssetStoreExtensions -{ - private const string LayoutAssetName = "layout"; - - public static CompiledLayout GetLayout(this ICloudAssetStore store) - => store.Get(LayoutAssetName); - - public static void SetLayout(this ICloudAssetStore store, CompiledLayout layout) - => store.Set(LayoutAssetName, layout); -} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs index b720f491..521cb15b 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs @@ -12,4 +12,5 @@ internal class CloudSettings public int IdleCooldownSeconds { get; set; } = 30; public int SseMaxConsecutiveFailures { get; set; } = 10; public string CachePath { get; set; } = @"%LocalAppData%\AdaptiveRemote\CloudAssets"; + public string StubFilePath { get; set; } = ""; } diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/FileCloudAssetDownloader.cs b/src/AdaptiveRemote.App/Services/CloudAssets/FileCloudAssetDownloader.cs new file mode 100644 index 00000000..043d3f80 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/FileCloudAssetDownloader.cs @@ -0,0 +1,29 @@ +using AdaptiveRemote.Services; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Services.CloudAssets; + +internal sealed class FileCloudAssetDownloader : ICloudAssetDownloader +{ + private readonly CloudSettings _settings; + private readonly IFileSystem _fileSystem; + + public FileCloudAssetDownloader(IOptions options, IFileSystem fileSystem) + { + _settings = options.Value; + _fileSystem = fileSystem; + } + + public Task GetActiveAsync(string resourcePath, CancellationToken ct) + { + string path = Environment.ExpandEnvironmentVariables(_settings.StubFilePath); + if (!_fileSystem.FileExists(path)) + { + return Task.FromResult(null); + } + return Task.FromResult(_fileSystem.OpenRead(path)); + } + + public Task GetByIdAsync(string resourcePath, Guid id, CancellationToken ct) + => Task.FromResult(null); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs b/src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs new file mode 100644 index 00000000..c66c6e6a --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AdaptiveRemote.Services.CloudAssets; + +internal sealed class JsonCloudAsset( + string name, + string streamUrl, + string eventName, + string resourcePath, + JsonSerializerContext jsonContext) + : BasicCloudAsset(name, streamUrl, eventName, resourcePath) +{ + public override async Task ParseAsync(Stream stream, CancellationToken ct) + { + object? result = await JsonSerializer.DeserializeAsync(stream, typeof(T), jsonContext, ct); + return result ?? throw new InvalidOperationException( + $"Deserialized null for asset '{Name}'."); + } +} diff --git a/src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj b/src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj index 695eb357..8110e8dc 100644 --- a/src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj +++ b/src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj @@ -7,6 +7,11 @@ enable + + + + + diff --git a/src/AdaptiveRemote.Headless/appsettings.Development.json b/src/AdaptiveRemote.Headless/appsettings.Development.json new file mode 100644 index 00000000..87ad47dc --- /dev/null +++ b/src/AdaptiveRemote.Headless/appsettings.Development.json @@ -0,0 +1,5 @@ +{ + "CloudSettings": { + "StubFilePath": "dev/layout.json" + } +} diff --git a/src/AdaptiveRemote.Headless/dev/layout.json b/src/AdaptiveRemote.Headless/dev/layout.json new file mode 100644 index 00000000..728534cb --- /dev/null +++ b/src/AdaptiveRemote.Headless/dev/layout.json @@ -0,0 +1,58 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "rawLayoutId": "00000000-0000-0000-0000-000000000000", + "userId": "stub", + "isActive": true, + "version": 1, + "elements": [ + { + "$type": "group", + "cssId": "DPAD", + "children": [ + { "$type": "command", "type": 1, "name": "Up", "label": "Up", "glyph": null, "speakPhrase": "Sent Up", "reverse": "Down", "cssId": "Up" }, + { "$type": "command", "type": 1, "name": "Down", "label": "Down", "glyph": null, "speakPhrase": "Sent Down", "reverse": "Up", "cssId": "Down" }, + { "$type": "command", "type": 1, "name": "Left", "label": "Left", "glyph": null, "speakPhrase": "Sent Left", "reverse": "Right", "cssId": "Left" }, + { "$type": "command", "type": 1, "name": "Right", "label": "Right", "glyph": null, "speakPhrase": "Sent Right", "reverse": "Left", "cssId": "Right" }, + { "$type": "command", "type": 1, "name": "Select", "label": "Select", "glyph": null, "speakPhrase": "Sent Select", "reverse": null, "cssId": "Select" }, + { "$type": "command", "type": 1, "name": "Back", "label": "Back", "glyph": null, "speakPhrase": "Sent Back", "reverse": null, "cssId": "Back" }, + { "$type": "command", "type": 2, "name": "Power", "label": "Power", "glyph": null, "speakPhrase": "Sent Power", "reverse": "Power", "cssId": "Power" }, + { "$type": "command", "type": 2, "name": "PowerOn", "label": "PowerOn", "glyph": null, "speakPhrase": "Sent PowerOn", "reverse": "PowerOff", "cssId": "PowerOn" }, + { "$type": "command", "type": 2, "name": "PowerOff", "label": "PowerOff", "glyph": null, "speakPhrase": "Sent PowerOff", "reverse": "PowerOn", "cssId": "PowerOff" } + ] + }, + { + "$type": "group", + "cssId": "WELL", + "children": [ + { "$type": "command", "type": 1, "name": "TiVo", "label": "TiVo", "glyph": null, "speakPhrase": "Sent TiVo", "reverse": null, "cssId": "TiVo" }, + { "$type": "command", "type": 1, "name": "Netflix", "label": "Netflix", "glyph": null, "speakPhrase": "Sent Netflix", "reverse": null, "cssId": "Netflix" }, + { "$type": "command", "type": 1, "name": "Guide", "label": "Guide", "glyph": null, "speakPhrase": "Sent Guide", "reverse": null, "cssId": "Guide" }, + { "$type": "command", "type": 1, "name": "Info", "label": "Info", "glyph": null, "speakPhrase": "Sent Info", "reverse": null, "cssId": "Info" } + ] + }, + { + "$type": "group", + "cssId": "PLAYBACK", + "children": [ + { "$type": "command", "type": 1, "name": "Play", "label": "Play", "glyph": null, "speakPhrase": "Sent Play", "reverse": "Pause", "cssId": "Play" }, + { "$type": "command", "type": 1, "name": "Pause", "label": "Pause", "glyph": null, "speakPhrase": "Sent Pause", "reverse": "Play", "cssId": "Pause" }, + { "$type": "command", "type": 1, "name": "Record", "label": "Record", "glyph": null, "speakPhrase": "Sent Record", "reverse": null, "cssId": "Record" }, + { "$type": "command", "type": 1, "name": "Skip", "label": "Skip", "glyph": null, "speakPhrase": "Sent Skip", "reverse": "Replay", "cssId": "Skip" }, + { "$type": "command", "type": 1, "name": "Replay", "label": "Replay", "glyph": null, "speakPhrase": "Sent Replay", "reverse": "Skip", "cssId": "Replay" } + ] + }, + { + "$type": "group", + "cssId": "CHANNELANDVOLUME", + "children": [ + { "$type": "command", "type": 1, "name": "ChannelUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Channel Up", "reverse": "ChannelDown", "cssId": "ChannelUp" }, + { "$type": "command", "type": 1, "name": "ChannelDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Channel Down", "reverse": "ChannelUp", "cssId": "ChannelDown" }, + { "$type": "command", "type": 2, "name": "VolumeUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Volume Up", "reverse": "VolumeDown", "cssId": "VolumeUp" }, + { "$type": "command", "type": 2, "name": "VolumeDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Volume Down", "reverse": "VolumeUp", "cssId": "VolumeDown" }, + { "$type": "command", "type": 2, "name": "Mute", "label": "Mute", "glyph": null, "speakPhrase": "Sent Mute", "reverse": "Mute", "cssId": "Mute" } + ] + } + ], + "cssDefinitions": "", + "compiledAt": "2026-04-19T00:00:00+00:00" +} diff --git a/src/AdaptiveRemote/AdaptiveRemote.csproj b/src/AdaptiveRemote/AdaptiveRemote.csproj index a9085492..fb8df2fe 100644 --- a/src/AdaptiveRemote/AdaptiveRemote.csproj +++ b/src/AdaptiveRemote/AdaptiveRemote.csproj @@ -23,6 +23,7 @@ + diff --git a/src/AdaptiveRemote/appsettings.Development.json b/src/AdaptiveRemote/appsettings.Development.json index 27804e97..65b56f7f 100644 --- a/src/AdaptiveRemote/appsettings.Development.json +++ b/src/AdaptiveRemote/appsettings.Development.json @@ -7,5 +7,8 @@ "clientSecret": "", "scope": "" } + }, + "CloudSettings": { + "StubFilePath": "dev/layout.json" } } diff --git a/src/AdaptiveRemote/dev/layout.json b/src/AdaptiveRemote/dev/layout.json new file mode 100644 index 00000000..728534cb --- /dev/null +++ b/src/AdaptiveRemote/dev/layout.json @@ -0,0 +1,58 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "rawLayoutId": "00000000-0000-0000-0000-000000000000", + "userId": "stub", + "isActive": true, + "version": 1, + "elements": [ + { + "$type": "group", + "cssId": "DPAD", + "children": [ + { "$type": "command", "type": 1, "name": "Up", "label": "Up", "glyph": null, "speakPhrase": "Sent Up", "reverse": "Down", "cssId": "Up" }, + { "$type": "command", "type": 1, "name": "Down", "label": "Down", "glyph": null, "speakPhrase": "Sent Down", "reverse": "Up", "cssId": "Down" }, + { "$type": "command", "type": 1, "name": "Left", "label": "Left", "glyph": null, "speakPhrase": "Sent Left", "reverse": "Right", "cssId": "Left" }, + { "$type": "command", "type": 1, "name": "Right", "label": "Right", "glyph": null, "speakPhrase": "Sent Right", "reverse": "Left", "cssId": "Right" }, + { "$type": "command", "type": 1, "name": "Select", "label": "Select", "glyph": null, "speakPhrase": "Sent Select", "reverse": null, "cssId": "Select" }, + { "$type": "command", "type": 1, "name": "Back", "label": "Back", "glyph": null, "speakPhrase": "Sent Back", "reverse": null, "cssId": "Back" }, + { "$type": "command", "type": 2, "name": "Power", "label": "Power", "glyph": null, "speakPhrase": "Sent Power", "reverse": "Power", "cssId": "Power" }, + { "$type": "command", "type": 2, "name": "PowerOn", "label": "PowerOn", "glyph": null, "speakPhrase": "Sent PowerOn", "reverse": "PowerOff", "cssId": "PowerOn" }, + { "$type": "command", "type": 2, "name": "PowerOff", "label": "PowerOff", "glyph": null, "speakPhrase": "Sent PowerOff", "reverse": "PowerOn", "cssId": "PowerOff" } + ] + }, + { + "$type": "group", + "cssId": "WELL", + "children": [ + { "$type": "command", "type": 1, "name": "TiVo", "label": "TiVo", "glyph": null, "speakPhrase": "Sent TiVo", "reverse": null, "cssId": "TiVo" }, + { "$type": "command", "type": 1, "name": "Netflix", "label": "Netflix", "glyph": null, "speakPhrase": "Sent Netflix", "reverse": null, "cssId": "Netflix" }, + { "$type": "command", "type": 1, "name": "Guide", "label": "Guide", "glyph": null, "speakPhrase": "Sent Guide", "reverse": null, "cssId": "Guide" }, + { "$type": "command", "type": 1, "name": "Info", "label": "Info", "glyph": null, "speakPhrase": "Sent Info", "reverse": null, "cssId": "Info" } + ] + }, + { + "$type": "group", + "cssId": "PLAYBACK", + "children": [ + { "$type": "command", "type": 1, "name": "Play", "label": "Play", "glyph": null, "speakPhrase": "Sent Play", "reverse": "Pause", "cssId": "Play" }, + { "$type": "command", "type": 1, "name": "Pause", "label": "Pause", "glyph": null, "speakPhrase": "Sent Pause", "reverse": "Play", "cssId": "Pause" }, + { "$type": "command", "type": 1, "name": "Record", "label": "Record", "glyph": null, "speakPhrase": "Sent Record", "reverse": null, "cssId": "Record" }, + { "$type": "command", "type": 1, "name": "Skip", "label": "Skip", "glyph": null, "speakPhrase": "Sent Skip", "reverse": "Replay", "cssId": "Skip" }, + { "$type": "command", "type": 1, "name": "Replay", "label": "Replay", "glyph": null, "speakPhrase": "Sent Replay", "reverse": "Skip", "cssId": "Replay" } + ] + }, + { + "$type": "group", + "cssId": "CHANNELANDVOLUME", + "children": [ + { "$type": "command", "type": 1, "name": "ChannelUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Channel Up", "reverse": "ChannelDown", "cssId": "ChannelUp" }, + { "$type": "command", "type": 1, "name": "ChannelDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Channel Down", "reverse": "ChannelUp", "cssId": "ChannelDown" }, + { "$type": "command", "type": 2, "name": "VolumeUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Volume Up", "reverse": "VolumeDown", "cssId": "VolumeUp" }, + { "$type": "command", "type": 2, "name": "VolumeDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Volume Down", "reverse": "VolumeUp", "cssId": "VolumeDown" }, + { "$type": "command", "type": 2, "name": "Mute", "label": "Mute", "glyph": null, "speakPhrase": "Sent Mute", "reverse": "Mute", "cssId": "Mute" } + ] + } + ], + "cssDefinitions": "", + "compiledAt": "2026-04-19T00:00:00+00:00" +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs new file mode 100644 index 00000000..c33a4c70 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class FileCloudAssetDownloaderTests +{ + private const string FilePath = "dev/layout.json"; + private const string ResourcePath = "/layouts/compiled"; + + private static FileCloudAssetDownloader MakeSut( + string stubFilePath, MockFileSystem fileSystem) => + new(new MockOptions(new CloudSettings { StubFilePath = stubFilePath }), + fileSystem.Object); + + [TestMethod] + public async Task FileCloudAssetDownloader_GetActiveAsync_ReturnsStreamForConfiguredPathAsync() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddFile(FilePath, "{}"); + FileCloudAssetDownloader sut = MakeSut(FilePath, fileSystem); + + // Act + Stream? result = await sut.GetActiveAsync(ResourcePath, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + } + + [TestMethod] + public async Task FileCloudAssetDownloader_GetActiveAsync_ReturnsNullWhenFileAbsentAsync() + { + // Arrange + MockFileSystem fileSystem = new(); + FileCloudAssetDownloader sut = MakeSut("nonexistent/layout.json", fileSystem); + + // Act + Stream? result = await sut.GetActiveAsync(ResourcePath, CancellationToken.None); + + // Assert + result.Should().BeNull(); + } + + [TestMethod] + public async Task FileCloudAssetDownloader_GetByIdAsync_ReturnsNullAsync() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddFile(FilePath, "{}"); + FileCloudAssetDownloader sut = MakeSut(FilePath, fileSystem); + + // Act + Stream? result = await sut.GetByIdAsync(ResourcePath, Guid.NewGuid(), CancellationToken.None); + + // Assert + result.Should().BeNull(); + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs new file mode 100644 index 00000000..d7aa3f70 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs @@ -0,0 +1,51 @@ +using System.Text; +using System.Text.Json; +using AdaptiveRemote.Contracts; +using FluentAssertions; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class JsonCloudAssetTests +{ + private static JsonCloudAsset MakeSut() => + new("layout", "/stream", "layout-ready", "/layouts/compiled", + LayoutContractsJsonContext.Default); + + [TestMethod] + public async Task JsonCloudAsset_ParseAsync_CorrectlyDeserializesCompiledLayoutAsync() + { + // Arrange + CompiledLayout expected = new( + Id: Guid.Parse("11111111-1111-1111-1111-111111111111"), + RawLayoutId: Guid.Empty, + UserId: "test-user", + IsActive: true, + Version: 42, + Elements: [ + new LayoutGroupDefinitionDto("GROUP", [ + new CommandDefinitionDto(CommandType.TiVo, "Play", "Play", null, "Sent Play", "Pause", "Play"), + ]) + ], + CssDefinitions: "body { color: red; }", + CompiledAt: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + string json = JsonSerializer.Serialize(expected, LayoutContractsJsonContext.Default.CompiledLayout); + using MemoryStream stream = new(Encoding.UTF8.GetBytes(json)); + JsonCloudAsset sut = MakeSut(); + + // Act + object result = await sut.ParseAsync(stream, CancellationToken.None); + + // Assert + result.Should().BeOfType(); + CompiledLayout layout = (CompiledLayout)result; + layout.Id.Should().Be(expected.Id); + layout.UserId.Should().Be("test-user"); + layout.Version.Should().Be(42); + layout.CssDefinitions.Should().Be("body { color: red; }"); + layout.Elements.Should().HaveCount(1); + layout.Elements[0].Should().BeOfType() + .Which.CssId.Should().Be("GROUP"); + } +} diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Headless/Features/Layout/StubLayout.feature b/test/AdaptiveRemote.EndToEndTests.Host.Headless/Features/Layout/StubLayout.feature new file mode 100644 index 00000000..94dca95d --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Host.Headless/Features/Layout/StubLayout.feature @@ -0,0 +1,8 @@ +Feature: Stub layout file loading + + Scenario: App loads layout from stub JSON file + Given the application is not running + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Info' button is enabled + And I should not see any error messages in the logs diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature index 4b35f786..ac8194b5 100644 --- a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature @@ -29,6 +29,7 @@ Scenario: All expected buttons from layout are present And I should see the 'TiVo' button is enabled And I should see the 'Netflix' button is enabled And I should see the 'Guide' button is enabled + And I should see the 'Info' button is enabled # PLAYBACK group And I should see the 'Play' button is enabled And I should see the 'Pause' button is enabled From 04f8521cf5f4e8421e2d3f9cfd82a9500d375d3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 20:07:58 +0000 Subject: [PATCH 2/2] Address PR review comments on ADR-178 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename ParseAsync → DeserializeAsync on ICloudAsset/BasicCloudAsset/JsonCloudAsset (ParseAsync was misleading; DeserializeAsync is more precise for all asset types) - Add logging to CloudAssetOrchestrator: logs each asset download (1700) and initialization failure (1701); verified in new CloudAssetOrchestratorTests - Consolidate CloudSettings with BackendSettings: remove duplicate backend credential fields (BackendBaseUrl, CognitoTokenEndpointUrl, ClientId, ClientSecret) that duplicated BackendSettings; change StubFilePath default to "dev/layout.json" so the app works without requiring ASPNETCORE_ENVIRONMENT=Development - Add [JsonConverter(typeof(JsonStringEnumConverter))] to CommandType so enum values serialize as names ("TiVo", "IR") rather than integers; update both layout.json files accordingly - Rewrite JsonCloudAssetTests to use a test-specific TestAsset record rather than CompiledLayout, making the tests robust to contract changes - Add CloudAssetOrchestratorTests covering success, null-stream, and parse-failure paths with log verification - Delete StubLayout.feature (headless-specific); the LayoutButtons.feature shared scenario already asserts the Info button present only in layout.json https://claude.ai/code/session_01VHZuzd1UHqNKDxtkLZu7pz --- .../Logging/MessageLogger.cs | 8 ++ .../Services/CloudAssets/BasicCloudAsset.cs | 2 +- .../CloudAssets/CloudAssetOrchestrator.cs | 11 ++- .../Services/CloudAssets/CloudSettings.cs | 8 +- .../Services/CloudAssets/ICloudAsset.cs | 4 +- .../Services/CloudAssets/JsonCloudAsset.cs | 2 +- src/AdaptiveRemote.Contracts/CommandType.cs | 3 + src/AdaptiveRemote.Headless/dev/layout.json | 46 ++++----- src/AdaptiveRemote/dev/layout.json | 46 ++++----- .../CloudAssetOrchestratorTests.cs | 99 +++++++++++++++++++ .../CloudAssets/JsonCloudAssetTests.cs | 50 ++++------ .../Features/Layout/StubLayout.feature | 8 -- 12 files changed, 190 insertions(+), 97 deletions(-) create mode 100644 test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs delete mode 100644 test/AdaptiveRemote.EndToEndTests.Host.Headless/Features/Layout/StubLayout.feature diff --git a/src/AdaptiveRemote.App/Logging/MessageLogger.cs b/src/AdaptiveRemote.App/Logging/MessageLogger.cs index 9e3b3224..6858032e 100644 --- a/src/AdaptiveRemote.App/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.App/Logging/MessageLogger.cs @@ -349,6 +349,14 @@ public MessageLogger(ILogger logger) // 1600–1699: CognitoTokenService + // 1700–1799: CloudAssetOrchestrator + + [LoggerMessage(EventId = 1700, Level = LogLevel.Information, Message = "Downloading asset '{AssetName}'")] + public partial void CloudAssetOrchestrator_Downloading(string assetName); + + [LoggerMessage(EventId = 1701, Level = LogLevel.Error, Message = "Failed to initialize cloud assets")] + public partial void CloudAssetOrchestrator_Failed(Exception error); + [LoggerMessage(EventId = 1600, Level = LogLevel.Information, Message = "Acquiring Cognito access token via Client Credentials flow")] public partial void CognitoTokenService_AcquiringToken(); diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs b/src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs index 513ace3f..4bdc1fda 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs @@ -15,5 +15,5 @@ protected BasicCloudAsset(string name, string streamUrl, string eventName, strin ResourcePath = resourcePath; } - public abstract Task ParseAsync(Stream stream, CancellationToken ct); + public abstract Task DeserializeAsync(Stream stream, CancellationToken ct); } diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs index e4c71e43..4d7b74fd 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs @@ -1,5 +1,7 @@ +using AdaptiveRemote.Logging; using AdaptiveRemote.Services.Lifecycle; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace AdaptiveRemote.Services.CloudAssets; @@ -8,16 +10,19 @@ internal class CloudAssetOrchestrator : BackgroundService, IPreScopeInitializer private readonly IEnumerable _assets; private readonly ICloudAssetDownloader _downloader; private readonly ICloudAssetStore _store; + private readonly MessageLogger _log; private readonly TaskCompletionSource _initCompleted = new(); public CloudAssetOrchestrator( IEnumerable assets, ICloudAssetDownloader downloader, - ICloudAssetStore store) + ICloudAssetStore store, + ILogger logger) { _assets = assets; _downloader = downloader; _store = store; + _log = new MessageLogger(logger); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -26,11 +31,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { foreach (ICloudAsset asset in _assets) { + _log.CloudAssetOrchestrator_Downloading(asset.Name); Stream stream = await _downloader.GetActiveAsync(asset.ResourcePath, stoppingToken) ?? throw new InvalidOperationException($"Failed to download asset '{asset.Name}'."); await using (stream) { - object value = await asset.ParseAsync(stream, stoppingToken); + object value = await asset.DeserializeAsync(stream, stoppingToken); _store.Set(asset.Name, value); } } @@ -38,6 +44,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (Exception ex) { + _log.CloudAssetOrchestrator_Failed(ex); _initCompleted.TrySetException(ex); throw; } diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs index 521cb15b..5d863198 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs @@ -1,16 +1,12 @@ namespace AdaptiveRemote.Services.CloudAssets; /// -/// Shared connection and auth settings for all cloud asset services. +/// Shared settings for all cloud asset services. /// internal class CloudSettings { - public string BackendBaseUrl { get; set; } = ""; - public string CognitoTokenEndpointUrl { get; set; } = ""; - public string ClientId { get; set; } = ""; - public string ClientSecret { get; set; } = ""; public int IdleCooldownSeconds { get; set; } = 30; public int SseMaxConsecutiveFailures { get; set; } = 10; public string CachePath { get; set; } = @"%LocalAppData%\AdaptiveRemote\CloudAssets"; - public string StubFilePath { get; set; } = ""; + public string StubFilePath { get; set; } = "dev/layout.json"; } diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs index 4d439864..415376c7 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs @@ -26,9 +26,9 @@ internal interface ICloudAsset string ResourcePath { get; } /// - /// Parses downloaded or cached bytes into the asset's runtime type. + /// Deserializes downloaded or cached bytes into the asset's runtime type. /// - Task ParseAsync(Stream stream, CancellationToken ct); + Task DeserializeAsync(Stream stream, CancellationToken ct); } /// diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs b/src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs index c66c6e6a..e36b4fb7 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs @@ -11,7 +11,7 @@ internal sealed class JsonCloudAsset( JsonSerializerContext jsonContext) : BasicCloudAsset(name, streamUrl, eventName, resourcePath) { - public override async Task ParseAsync(Stream stream, CancellationToken ct) + public override async Task DeserializeAsync(Stream stream, CancellationToken ct) { object? result = await JsonSerializer.DeserializeAsync(stream, typeof(T), jsonContext, ct); return result ?? throw new InvalidOperationException( diff --git a/src/AdaptiveRemote.Contracts/CommandType.cs b/src/AdaptiveRemote.Contracts/CommandType.cs index 5ea8a4f9..240b57fe 100644 --- a/src/AdaptiveRemote.Contracts/CommandType.cs +++ b/src/AdaptiveRemote.Contracts/CommandType.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace AdaptiveRemote.Contracts; // Identifies the runtime command type. The client uses this to instantiate the correct @@ -6,4 +8,5 @@ namespace AdaptiveRemote.Contracts; // TiVo — CommandId = Name.ToUpperInvariant() (existing convention) // IR — payload programmed via remote, stored in ProgrammaticSettings // Others — keyed by Name +[JsonConverter(typeof(JsonStringEnumConverter))] public enum CommandType { Lifecycle, TiVo, IR } diff --git a/src/AdaptiveRemote.Headless/dev/layout.json b/src/AdaptiveRemote.Headless/dev/layout.json index 728534cb..5f75ef12 100644 --- a/src/AdaptiveRemote.Headless/dev/layout.json +++ b/src/AdaptiveRemote.Headless/dev/layout.json @@ -9,47 +9,47 @@ "$type": "group", "cssId": "DPAD", "children": [ - { "$type": "command", "type": 1, "name": "Up", "label": "Up", "glyph": null, "speakPhrase": "Sent Up", "reverse": "Down", "cssId": "Up" }, - { "$type": "command", "type": 1, "name": "Down", "label": "Down", "glyph": null, "speakPhrase": "Sent Down", "reverse": "Up", "cssId": "Down" }, - { "$type": "command", "type": 1, "name": "Left", "label": "Left", "glyph": null, "speakPhrase": "Sent Left", "reverse": "Right", "cssId": "Left" }, - { "$type": "command", "type": 1, "name": "Right", "label": "Right", "glyph": null, "speakPhrase": "Sent Right", "reverse": "Left", "cssId": "Right" }, - { "$type": "command", "type": 1, "name": "Select", "label": "Select", "glyph": null, "speakPhrase": "Sent Select", "reverse": null, "cssId": "Select" }, - { "$type": "command", "type": 1, "name": "Back", "label": "Back", "glyph": null, "speakPhrase": "Sent Back", "reverse": null, "cssId": "Back" }, - { "$type": "command", "type": 2, "name": "Power", "label": "Power", "glyph": null, "speakPhrase": "Sent Power", "reverse": "Power", "cssId": "Power" }, - { "$type": "command", "type": 2, "name": "PowerOn", "label": "PowerOn", "glyph": null, "speakPhrase": "Sent PowerOn", "reverse": "PowerOff", "cssId": "PowerOn" }, - { "$type": "command", "type": 2, "name": "PowerOff", "label": "PowerOff", "glyph": null, "speakPhrase": "Sent PowerOff", "reverse": "PowerOn", "cssId": "PowerOff" } + { "$type": "command", "type": "TiVo", "name": "Up", "label": "Up", "glyph": null, "speakPhrase": "Sent Up", "reverse": "Down", "cssId": "Up" }, + { "$type": "command", "type": "TiVo", "name": "Down", "label": "Down", "glyph": null, "speakPhrase": "Sent Down", "reverse": "Up", "cssId": "Down" }, + { "$type": "command", "type": "TiVo", "name": "Left", "label": "Left", "glyph": null, "speakPhrase": "Sent Left", "reverse": "Right", "cssId": "Left" }, + { "$type": "command", "type": "TiVo", "name": "Right", "label": "Right", "glyph": null, "speakPhrase": "Sent Right", "reverse": "Left", "cssId": "Right" }, + { "$type": "command", "type": "TiVo", "name": "Select", "label": "Select", "glyph": null, "speakPhrase": "Sent Select", "reverse": null, "cssId": "Select" }, + { "$type": "command", "type": "TiVo", "name": "Back", "label": "Back", "glyph": null, "speakPhrase": "Sent Back", "reverse": null, "cssId": "Back" }, + { "$type": "command", "type": "IR", "name": "Power", "label": "Power", "glyph": null, "speakPhrase": "Sent Power", "reverse": "Power", "cssId": "Power" }, + { "$type": "command", "type": "IR", "name": "PowerOn", "label": "PowerOn", "glyph": null, "speakPhrase": "Sent PowerOn", "reverse": "PowerOff", "cssId": "PowerOn" }, + { "$type": "command", "type": "IR", "name": "PowerOff", "label": "PowerOff", "glyph": null, "speakPhrase": "Sent PowerOff", "reverse": "PowerOn", "cssId": "PowerOff" } ] }, { "$type": "group", "cssId": "WELL", "children": [ - { "$type": "command", "type": 1, "name": "TiVo", "label": "TiVo", "glyph": null, "speakPhrase": "Sent TiVo", "reverse": null, "cssId": "TiVo" }, - { "$type": "command", "type": 1, "name": "Netflix", "label": "Netflix", "glyph": null, "speakPhrase": "Sent Netflix", "reverse": null, "cssId": "Netflix" }, - { "$type": "command", "type": 1, "name": "Guide", "label": "Guide", "glyph": null, "speakPhrase": "Sent Guide", "reverse": null, "cssId": "Guide" }, - { "$type": "command", "type": 1, "name": "Info", "label": "Info", "glyph": null, "speakPhrase": "Sent Info", "reverse": null, "cssId": "Info" } + { "$type": "command", "type": "TiVo", "name": "TiVo", "label": "TiVo", "glyph": null, "speakPhrase": "Sent TiVo", "reverse": null, "cssId": "TiVo" }, + { "$type": "command", "type": "TiVo", "name": "Netflix", "label": "Netflix", "glyph": null, "speakPhrase": "Sent Netflix", "reverse": null, "cssId": "Netflix" }, + { "$type": "command", "type": "TiVo", "name": "Guide", "label": "Guide", "glyph": null, "speakPhrase": "Sent Guide", "reverse": null, "cssId": "Guide" }, + { "$type": "command", "type": "TiVo", "name": "Info", "label": "Info", "glyph": null, "speakPhrase": "Sent Info", "reverse": null, "cssId": "Info" } ] }, { "$type": "group", "cssId": "PLAYBACK", "children": [ - { "$type": "command", "type": 1, "name": "Play", "label": "Play", "glyph": null, "speakPhrase": "Sent Play", "reverse": "Pause", "cssId": "Play" }, - { "$type": "command", "type": 1, "name": "Pause", "label": "Pause", "glyph": null, "speakPhrase": "Sent Pause", "reverse": "Play", "cssId": "Pause" }, - { "$type": "command", "type": 1, "name": "Record", "label": "Record", "glyph": null, "speakPhrase": "Sent Record", "reverse": null, "cssId": "Record" }, - { "$type": "command", "type": 1, "name": "Skip", "label": "Skip", "glyph": null, "speakPhrase": "Sent Skip", "reverse": "Replay", "cssId": "Skip" }, - { "$type": "command", "type": 1, "name": "Replay", "label": "Replay", "glyph": null, "speakPhrase": "Sent Replay", "reverse": "Skip", "cssId": "Replay" } + { "$type": "command", "type": "TiVo", "name": "Play", "label": "Play", "glyph": null, "speakPhrase": "Sent Play", "reverse": "Pause", "cssId": "Play" }, + { "$type": "command", "type": "TiVo", "name": "Pause", "label": "Pause", "glyph": null, "speakPhrase": "Sent Pause", "reverse": "Play", "cssId": "Pause" }, + { "$type": "command", "type": "TiVo", "name": "Record", "label": "Record", "glyph": null, "speakPhrase": "Sent Record", "reverse": null, "cssId": "Record" }, + { "$type": "command", "type": "TiVo", "name": "Skip", "label": "Skip", "glyph": null, "speakPhrase": "Sent Skip", "reverse": "Replay", "cssId": "Skip" }, + { "$type": "command", "type": "TiVo", "name": "Replay", "label": "Replay", "glyph": null, "speakPhrase": "Sent Replay", "reverse": "Skip", "cssId": "Replay" } ] }, { "$type": "group", "cssId": "CHANNELANDVOLUME", "children": [ - { "$type": "command", "type": 1, "name": "ChannelUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Channel Up", "reverse": "ChannelDown", "cssId": "ChannelUp" }, - { "$type": "command", "type": 1, "name": "ChannelDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Channel Down", "reverse": "ChannelUp", "cssId": "ChannelDown" }, - { "$type": "command", "type": 2, "name": "VolumeUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Volume Up", "reverse": "VolumeDown", "cssId": "VolumeUp" }, - { "$type": "command", "type": 2, "name": "VolumeDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Volume Down", "reverse": "VolumeUp", "cssId": "VolumeDown" }, - { "$type": "command", "type": 2, "name": "Mute", "label": "Mute", "glyph": null, "speakPhrase": "Sent Mute", "reverse": "Mute", "cssId": "Mute" } + { "$type": "command", "type": "TiVo", "name": "ChannelUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Channel Up", "reverse": "ChannelDown", "cssId": "ChannelUp" }, + { "$type": "command", "type": "TiVo", "name": "ChannelDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Channel Down", "reverse": "ChannelUp", "cssId": "ChannelDown" }, + { "$type": "command", "type": "IR", "name": "VolumeUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Volume Up", "reverse": "VolumeDown", "cssId": "VolumeUp" }, + { "$type": "command", "type": "IR", "name": "VolumeDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Volume Down", "reverse": "VolumeUp", "cssId": "VolumeDown" }, + { "$type": "command", "type": "IR", "name": "Mute", "label": "Mute", "glyph": null, "speakPhrase": "Sent Mute", "reverse": "Mute", "cssId": "Mute" } ] } ], diff --git a/src/AdaptiveRemote/dev/layout.json b/src/AdaptiveRemote/dev/layout.json index 728534cb..5f75ef12 100644 --- a/src/AdaptiveRemote/dev/layout.json +++ b/src/AdaptiveRemote/dev/layout.json @@ -9,47 +9,47 @@ "$type": "group", "cssId": "DPAD", "children": [ - { "$type": "command", "type": 1, "name": "Up", "label": "Up", "glyph": null, "speakPhrase": "Sent Up", "reverse": "Down", "cssId": "Up" }, - { "$type": "command", "type": 1, "name": "Down", "label": "Down", "glyph": null, "speakPhrase": "Sent Down", "reverse": "Up", "cssId": "Down" }, - { "$type": "command", "type": 1, "name": "Left", "label": "Left", "glyph": null, "speakPhrase": "Sent Left", "reverse": "Right", "cssId": "Left" }, - { "$type": "command", "type": 1, "name": "Right", "label": "Right", "glyph": null, "speakPhrase": "Sent Right", "reverse": "Left", "cssId": "Right" }, - { "$type": "command", "type": 1, "name": "Select", "label": "Select", "glyph": null, "speakPhrase": "Sent Select", "reverse": null, "cssId": "Select" }, - { "$type": "command", "type": 1, "name": "Back", "label": "Back", "glyph": null, "speakPhrase": "Sent Back", "reverse": null, "cssId": "Back" }, - { "$type": "command", "type": 2, "name": "Power", "label": "Power", "glyph": null, "speakPhrase": "Sent Power", "reverse": "Power", "cssId": "Power" }, - { "$type": "command", "type": 2, "name": "PowerOn", "label": "PowerOn", "glyph": null, "speakPhrase": "Sent PowerOn", "reverse": "PowerOff", "cssId": "PowerOn" }, - { "$type": "command", "type": 2, "name": "PowerOff", "label": "PowerOff", "glyph": null, "speakPhrase": "Sent PowerOff", "reverse": "PowerOn", "cssId": "PowerOff" } + { "$type": "command", "type": "TiVo", "name": "Up", "label": "Up", "glyph": null, "speakPhrase": "Sent Up", "reverse": "Down", "cssId": "Up" }, + { "$type": "command", "type": "TiVo", "name": "Down", "label": "Down", "glyph": null, "speakPhrase": "Sent Down", "reverse": "Up", "cssId": "Down" }, + { "$type": "command", "type": "TiVo", "name": "Left", "label": "Left", "glyph": null, "speakPhrase": "Sent Left", "reverse": "Right", "cssId": "Left" }, + { "$type": "command", "type": "TiVo", "name": "Right", "label": "Right", "glyph": null, "speakPhrase": "Sent Right", "reverse": "Left", "cssId": "Right" }, + { "$type": "command", "type": "TiVo", "name": "Select", "label": "Select", "glyph": null, "speakPhrase": "Sent Select", "reverse": null, "cssId": "Select" }, + { "$type": "command", "type": "TiVo", "name": "Back", "label": "Back", "glyph": null, "speakPhrase": "Sent Back", "reverse": null, "cssId": "Back" }, + { "$type": "command", "type": "IR", "name": "Power", "label": "Power", "glyph": null, "speakPhrase": "Sent Power", "reverse": "Power", "cssId": "Power" }, + { "$type": "command", "type": "IR", "name": "PowerOn", "label": "PowerOn", "glyph": null, "speakPhrase": "Sent PowerOn", "reverse": "PowerOff", "cssId": "PowerOn" }, + { "$type": "command", "type": "IR", "name": "PowerOff", "label": "PowerOff", "glyph": null, "speakPhrase": "Sent PowerOff", "reverse": "PowerOn", "cssId": "PowerOff" } ] }, { "$type": "group", "cssId": "WELL", "children": [ - { "$type": "command", "type": 1, "name": "TiVo", "label": "TiVo", "glyph": null, "speakPhrase": "Sent TiVo", "reverse": null, "cssId": "TiVo" }, - { "$type": "command", "type": 1, "name": "Netflix", "label": "Netflix", "glyph": null, "speakPhrase": "Sent Netflix", "reverse": null, "cssId": "Netflix" }, - { "$type": "command", "type": 1, "name": "Guide", "label": "Guide", "glyph": null, "speakPhrase": "Sent Guide", "reverse": null, "cssId": "Guide" }, - { "$type": "command", "type": 1, "name": "Info", "label": "Info", "glyph": null, "speakPhrase": "Sent Info", "reverse": null, "cssId": "Info" } + { "$type": "command", "type": "TiVo", "name": "TiVo", "label": "TiVo", "glyph": null, "speakPhrase": "Sent TiVo", "reverse": null, "cssId": "TiVo" }, + { "$type": "command", "type": "TiVo", "name": "Netflix", "label": "Netflix", "glyph": null, "speakPhrase": "Sent Netflix", "reverse": null, "cssId": "Netflix" }, + { "$type": "command", "type": "TiVo", "name": "Guide", "label": "Guide", "glyph": null, "speakPhrase": "Sent Guide", "reverse": null, "cssId": "Guide" }, + { "$type": "command", "type": "TiVo", "name": "Info", "label": "Info", "glyph": null, "speakPhrase": "Sent Info", "reverse": null, "cssId": "Info" } ] }, { "$type": "group", "cssId": "PLAYBACK", "children": [ - { "$type": "command", "type": 1, "name": "Play", "label": "Play", "glyph": null, "speakPhrase": "Sent Play", "reverse": "Pause", "cssId": "Play" }, - { "$type": "command", "type": 1, "name": "Pause", "label": "Pause", "glyph": null, "speakPhrase": "Sent Pause", "reverse": "Play", "cssId": "Pause" }, - { "$type": "command", "type": 1, "name": "Record", "label": "Record", "glyph": null, "speakPhrase": "Sent Record", "reverse": null, "cssId": "Record" }, - { "$type": "command", "type": 1, "name": "Skip", "label": "Skip", "glyph": null, "speakPhrase": "Sent Skip", "reverse": "Replay", "cssId": "Skip" }, - { "$type": "command", "type": 1, "name": "Replay", "label": "Replay", "glyph": null, "speakPhrase": "Sent Replay", "reverse": "Skip", "cssId": "Replay" } + { "$type": "command", "type": "TiVo", "name": "Play", "label": "Play", "glyph": null, "speakPhrase": "Sent Play", "reverse": "Pause", "cssId": "Play" }, + { "$type": "command", "type": "TiVo", "name": "Pause", "label": "Pause", "glyph": null, "speakPhrase": "Sent Pause", "reverse": "Play", "cssId": "Pause" }, + { "$type": "command", "type": "TiVo", "name": "Record", "label": "Record", "glyph": null, "speakPhrase": "Sent Record", "reverse": null, "cssId": "Record" }, + { "$type": "command", "type": "TiVo", "name": "Skip", "label": "Skip", "glyph": null, "speakPhrase": "Sent Skip", "reverse": "Replay", "cssId": "Skip" }, + { "$type": "command", "type": "TiVo", "name": "Replay", "label": "Replay", "glyph": null, "speakPhrase": "Sent Replay", "reverse": "Skip", "cssId": "Replay" } ] }, { "$type": "group", "cssId": "CHANNELANDVOLUME", "children": [ - { "$type": "command", "type": 1, "name": "ChannelUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Channel Up", "reverse": "ChannelDown", "cssId": "ChannelUp" }, - { "$type": "command", "type": 1, "name": "ChannelDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Channel Down", "reverse": "ChannelUp", "cssId": "ChannelDown" }, - { "$type": "command", "type": 2, "name": "VolumeUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Volume Up", "reverse": "VolumeDown", "cssId": "VolumeUp" }, - { "$type": "command", "type": 2, "name": "VolumeDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Volume Down", "reverse": "VolumeUp", "cssId": "VolumeDown" }, - { "$type": "command", "type": 2, "name": "Mute", "label": "Mute", "glyph": null, "speakPhrase": "Sent Mute", "reverse": "Mute", "cssId": "Mute" } + { "$type": "command", "type": "TiVo", "name": "ChannelUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Channel Up", "reverse": "ChannelDown", "cssId": "ChannelUp" }, + { "$type": "command", "type": "TiVo", "name": "ChannelDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Channel Down", "reverse": "ChannelUp", "cssId": "ChannelDown" }, + { "$type": "command", "type": "IR", "name": "VolumeUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Volume Up", "reverse": "VolumeDown", "cssId": "VolumeUp" }, + { "$type": "command", "type": "IR", "name": "VolumeDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Volume Down", "reverse": "VolumeUp", "cssId": "VolumeDown" }, + { "$type": "command", "type": "IR", "name": "Mute", "label": "Mute", "glyph": null, "speakPhrase": "Sent Mute", "reverse": "Mute", "cssId": "Mute" } ] } ], diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs new file mode 100644 index 00000000..aaf847a4 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs @@ -0,0 +1,99 @@ +using AdaptiveRemote.Services.Lifecycle; +using AdaptiveRemote.TestUtilities; +using FluentAssertions; +using Moq; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class CloudAssetOrchestratorTests +{ + private const string AssetName = "test-asset"; + private const string ResourcePath = "/test"; + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private readonly Mock MockAsset = new(); + private readonly Mock MockDownloader = new(); + private readonly Mock MockStore = new(); + private readonly MockLogger MockLogger = new(); + private readonly Mock MockActivity = new(); + + private CloudAssetOrchestrator MakeSut(IEnumerable? assets = null) + => new(assets ?? [MockAsset.Object], MockDownloader.Object, MockStore.Object, MockLogger); + + [TestInitialize] + public void SetupMocks() + { + MockAsset.SetupGet(a => a.Name).Returns(AssetName); + MockAsset.SetupGet(a => a.ResourcePath).Returns(ResourcePath); + MockActivity.SetupSet(a => a.Description = It.IsAny()); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_DownloadsAndStoresAsset() + { + // Arrange + object parsedValue = new(); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(parsedValue); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeCompleteWithin(Timeout); + waitTask.Should().BeSuccessful(); + MockStore.Verify(s => s.Set(AssetName, parsedValue), Times.Once); + MockLogger.VerifyMessages(log => { log.CloudAssetOrchestrator_Downloading(AssetName); }); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_FaultsWhenDownloadReturnsNull() + { + // Arrange + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync((Stream?)null); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + InvalidOperationException expectedException = new($"Failed to download asset '{AssetName}'."); + waitTask.Should().BeFaultedWith(expectedException, within: Timeout); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Failed(expectedException); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_FaultsWhenDeserializationFails() + { + // Arrange + InvalidOperationException parseException = new("Deserialization failed"); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(parseException); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeFaultedWith(parseException, within: Timeout); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Failed(parseException); + }); + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs index d7aa3f70..c65237f9 100644 --- a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs @@ -1,51 +1,39 @@ using System.Text; using System.Text.Json; -using AdaptiveRemote.Contracts; +using System.Text.Json.Serialization; using FluentAssertions; namespace AdaptiveRemote.Services.CloudAssets; +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(TestAsset))] +internal partial class TestAssetJsonContext : JsonSerializerContext { } + +internal record TestAsset(string Name, int Value); + [TestClass] public class JsonCloudAssetTests { - private static JsonCloudAsset MakeSut() => - new("layout", "/stream", "layout-ready", "/layouts/compiled", - LayoutContractsJsonContext.Default); + private static JsonCloudAsset MakeSut() => + new("asset", "/stream", "asset-ready", "/assets", + TestAssetJsonContext.Default); [TestMethod] - public async Task JsonCloudAsset_ParseAsync_CorrectlyDeserializesCompiledLayoutAsync() + public async Task JsonCloudAsset_DeserializeAsync_CorrectlyDeserializesAsync() { // Arrange - CompiledLayout expected = new( - Id: Guid.Parse("11111111-1111-1111-1111-111111111111"), - RawLayoutId: Guid.Empty, - UserId: "test-user", - IsActive: true, - Version: 42, - Elements: [ - new LayoutGroupDefinitionDto("GROUP", [ - new CommandDefinitionDto(CommandType.TiVo, "Play", "Play", null, "Sent Play", "Pause", "Play"), - ]) - ], - CssDefinitions: "body { color: red; }", - CompiledAt: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); - - string json = JsonSerializer.Serialize(expected, LayoutContractsJsonContext.Default.CompiledLayout); + TestAsset expected = new("test-name", 42); + string json = JsonSerializer.Serialize(expected, TestAssetJsonContext.Default.TestAsset); using MemoryStream stream = new(Encoding.UTF8.GetBytes(json)); - JsonCloudAsset sut = MakeSut(); + JsonCloudAsset sut = MakeSut(); // Act - object result = await sut.ParseAsync(stream, CancellationToken.None); + object result = await sut.DeserializeAsync(stream, CancellationToken.None); // Assert - result.Should().BeOfType(); - CompiledLayout layout = (CompiledLayout)result; - layout.Id.Should().Be(expected.Id); - layout.UserId.Should().Be("test-user"); - layout.Version.Should().Be(42); - layout.CssDefinitions.Should().Be("body { color: red; }"); - layout.Elements.Should().HaveCount(1); - layout.Elements[0].Should().BeOfType() - .Which.CssId.Should().Be("GROUP"); + result.Should().BeOfType(); + TestAsset asset = (TestAsset)result; + asset.Name.Should().Be("test-name"); + asset.Value.Should().Be(42); } } diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Headless/Features/Layout/StubLayout.feature b/test/AdaptiveRemote.EndToEndTests.Host.Headless/Features/Layout/StubLayout.feature deleted file mode 100644 index 94dca95d..00000000 --- a/test/AdaptiveRemote.EndToEndTests.Host.Headless/Features/Layout/StubLayout.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: Stub layout file loading - - Scenario: App loads layout from stub JSON file - Given the application is not running - When I start the application - Then I should see the application in the Ready phase - And I should see the 'Info' button is enabled - And I should not see any error messages in the logs