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/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 new file mode 100644 index 00000000..4bdc1fda --- /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 DeserializeAsync(Stream stream, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs index 9dbeceb7..4d7b74fd 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs @@ -1,71 +1,53 @@ -using AdaptiveRemote.Contracts; +using AdaptiveRemote.Logging; using AdaptiveRemote.Services.Lifecycle; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace AdaptiveRemote.Services.CloudAssets; 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(ICloudAssetStore store) + public CloudAssetOrchestrator( + IEnumerable assets, + ICloudAssetDownloader downloader, + ICloudAssetStore store, + ILogger logger) { + _assets = assets; + _downloader = downloader; _store = store; + _log = new MessageLogger(logger); } - 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) + { + _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.DeserializeAsync(stream, stoppingToken); + _store.Set(asset.Name, value); + } + } + _initCompleted.SetResult(); + } + catch (Exception ex) + { + _log.CloudAssetOrchestrator_Failed(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..5d863198 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs @@ -1,15 +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; } = "dev/layout.json"; } 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/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 new file mode 100644 index 00000000..e36b4fb7 --- /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 DeserializeAsync(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.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/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..5f75ef12 --- /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": "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": "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": "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": "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" } + ] + } + ], + "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..5f75ef12 --- /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": "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": "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": "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": "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" } + ] + } + ], + "cssDefinitions": "", + "compiledAt": "2026-04-19T00:00:00+00:00" +} 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/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..c65237f9 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs @@ -0,0 +1,39 @@ +using System.Text; +using System.Text.Json; +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("asset", "/stream", "asset-ready", "/assets", + TestAssetJsonContext.Default); + + [TestMethod] + public async Task JsonCloudAsset_DeserializeAsync_CorrectlyDeserializesAsync() + { + // Arrange + 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(); + + // Act + object result = await sut.DeserializeAsync(stream, CancellationToken.None); + + // Assert + 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.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