diff --git a/scripts/validate-tests.cmd b/scripts/validate-tests.cmd index aa738179..90617970 100644 --- a/scripts/validate-tests.cmd +++ b/scripts/validate-tests.cmd @@ -2,6 +2,6 @@ pushd %~dp0.. dotnet test --no-build "%~dp0validate-unit-tests.proj" if %ERRORLEVEL% neq 0 ( popd & exit /b %ERRORLEVEL% ) -dotnet test --no-build "%~dp0validate-e2e-tests.proj" +dotnet test --no-build "%~dp0validate-e2e-tests.proj" -m:1 if %ERRORLEVEL% neq 0 ( popd & exit /b %ERRORLEVEL% ) popd diff --git a/scripts/validate-tests.sh b/scripts/validate-tests.sh index 8894f734..5e39717d 100644 --- a/scripts/validate-tests.sh +++ b/scripts/validate-tests.sh @@ -5,4 +5,4 @@ cd "$SCRIPT_DIR/.." echo 'Testing unit test projects...' dotnet test --no-build "$SCRIPT_DIR/validate-unit-tests.proj" echo 'Testing E2E test projects...' -dotnet test --no-build "$SCRIPT_DIR/validate-e2e-tests.proj" +dotnet test --no-build "$SCRIPT_DIR/validate-e2e-tests.proj" -m:1 diff --git a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs index 4f18ac6c..2f18b664 100644 --- a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs @@ -1,5 +1,7 @@ +using AdaptiveRemote.Models.CloudAssets; using AdaptiveRemote.Services; using AdaptiveRemote.Services.CloudAssets; +using AdaptiveRemote.Services.IdleDetection; using AdaptiveRemote.Services.Lifecycle; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -12,7 +14,12 @@ internal static IServiceCollection AddCloudAssetServices(this IServiceCollection => services .AddSingleton() .AddSingleton() - .AddSingleton() + .AddScopedLifecycleService() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(sp => sp.GetRequiredService()) + .AddHostedService(sp => sp.GetRequiredService()) .AddSingleton() .AddSingleton(sp => sp.GetRequiredService()) .AddHostedService(sp => sp.GetRequiredService()) diff --git a/src/AdaptiveRemote.App/Configuration/ConversationHostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/ConversationHostBuilderExtensions.cs index 0593a61f..034ecef3 100644 --- a/src/AdaptiveRemote.App/Configuration/ConversationHostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/ConversationHostBuilderExtensions.cs @@ -23,7 +23,7 @@ internal static IServiceCollection AddConversationServices(this IServiceCollecti .AddScoped(GetConversationViewModel) .AddSingleton() .AddSingleton() - .AddScoped(); + .AddScoped(); internal static IServiceCollection AddConversationServices(this IServiceCollection services, IConfiguration config) => services diff --git a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs index 0316a349..453da4a5 100644 --- a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs @@ -1,6 +1,6 @@ using AdaptiveRemote.Contracts; +using AdaptiveRemote.Models.CloudAssets; using AdaptiveRemote.Services; -using AdaptiveRemote.Services.CloudAssets; using AdaptiveRemote.Services.Commands; using AdaptiveRemote.Services.Layout; using AdaptiveRemote.Services.Lifecycle; @@ -35,13 +35,8 @@ internal static IServiceCollection AddRemoteServices(this IServiceCollection ser eventName: "layout-ready", resourcePath: "/layouts/compiled", jsonContext: LayoutContractsJsonContext.Default)) - .AddScopedLifecycleService() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddSingleton() - .Configure(configuration.GetSection(SettingsKeys.ProgrammaticSettings)); + .AddCommandSystemServices() + .AddProgrammaticSettingsServices(configuration.GetSection(SettingsKeys.ProgrammaticSettings)); internal static IServiceCollection AddScopedLifecycleService(this IServiceCollection services) where ServiceType : class, IScopedLifecycle @@ -62,5 +57,18 @@ private static IServiceCollection AddApplicationLifecycleServices(this IServiceC .AddScoped() .AddSingleton() .AddSingleton(sp => (IApplicationScopeProvider)sp.GetRequiredService()) - .AddSingleton(); + .AddSingleton() + .AddScopedLifecycleService() + .AddScoped(); + + private static IServiceCollection AddCommandSystemServices(this IServiceCollection services) + => services + .AddScoped() + .AddScoped() + .AddScoped(); + + private static IServiceCollection AddProgrammaticSettingsServices(this IServiceCollection services, IConfiguration configuration) + => services + .AddSingleton() + .Configure(configuration); } diff --git a/src/AdaptiveRemote.App/Logging/MessageLogger.cs b/src/AdaptiveRemote.App/Logging/MessageLogger.cs index d0487c97..121e44b3 100644 --- a/src/AdaptiveRemote.App/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.App/Logging/MessageLogger.cs @@ -359,6 +359,15 @@ public MessageLogger(ILogger logger) // 1600–1699: CognitoTokenService + [LoggerMessage(EventId = 1600, Level = LogLevel.Information, Message = "Acquiring Cognito access token via Client Credentials flow")] + public partial void CognitoTokenService_AcquiringToken(); + + [LoggerMessage(EventId = 1601, Level = LogLevel.Information, Message = "Cognito access token acquired successfully")] + public partial void CognitoTokenService_TokenAcquired(); + + [LoggerMessage(EventId = 1602, Level = LogLevel.Error, Message = "Failed to acquire Cognito access token")] + public partial void CognitoTokenService_AcquireTokenFailed(Exception exception); + // 1700–1799: CloudAssetOrchestrator [LoggerMessage(EventId = 1700, Level = LogLevel.Information, Message = "Downloading asset '{AssetName}'")] @@ -367,12 +376,27 @@ public MessageLogger(ILogger logger) [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(); + [LoggerMessage(EventId = 1702, Level = LogLevel.Information, Message = "Loaded asset '{AssetName}' from cache")] + public partial void CloudAssetOrchestrator_LoadedFromCache(string assetName); - [LoggerMessage(EventId = 1601, Level = LogLevel.Information, Message = "Cognito access token acquired successfully")] - public partial void CognitoTokenService_TokenAcquired(); + [LoggerMessage(EventId = 1703, Level = LogLevel.Information, Message = "Asset '{AssetName}' is up to date")] + public partial void CloudAssetOrchestrator_AssetUpToDate(string assetName); - [LoggerMessage(EventId = 1602, Level = LogLevel.Error, Message = "Failed to acquire Cognito access token")] - public partial void CognitoTokenService_AcquireTokenFailed(Exception exception); + [LoggerMessage(EventId = 1704, Level = LogLevel.Information, Message = "Asset '{AssetName}' updated from server; scheduling recycle")] + public partial void CloudAssetOrchestrator_AssetUpdated(string assetName); + + [LoggerMessage(EventId = 1705, Level = LogLevel.Warning, Message = "Failed to download latest '{AssetName}' from server; keeping cached version")] + public partial void CloudAssetOrchestrator_BackgroundFetchFailed(string assetName, Exception? exception); + + [LoggerMessage(EventId = 1706, Level = LogLevel.Information, Message = "Layout service reported a change; re-downloading asset '{AssetName}'")] + public partial void CloudAssetOrchestrator_FileChangeDetected(string assetName); + + [LoggerMessage(EventId = 1707, Level = LogLevel.Warning, Message = "Received change notification for unknown asset '{AssetName}'; ignoring")] + public partial void CloudAssetOrchestrator_UnknownAssetChange(string assetName); + + [LoggerMessage(EventId = 1708, Level = LogLevel.Information, Message = "Asset '{AssetName}' not found in cache")] + public partial void CloudAssetOrchestrator_NotFoundInCache(string assetName); + + [LoggerMessage(EventId = 1709, Level = LogLevel.Information, Message = "Downloaded asset '{AssetName}'")] + public partial void CloudAssetOrchestrator_Downloaded(string assetName); } diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs b/src/AdaptiveRemote.App/Models/CloudAssets/BasicCloudAsset.cs similarity index 91% rename from src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs rename to src/AdaptiveRemote.App/Models/CloudAssets/BasicCloudAsset.cs index 4bdc1fda..8905d8d1 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs +++ b/src/AdaptiveRemote.App/Models/CloudAssets/BasicCloudAsset.cs @@ -1,4 +1,4 @@ -namespace AdaptiveRemote.Services.CloudAssets; +namespace AdaptiveRemote.Models.CloudAssets; internal abstract class BasicCloudAsset : ICloudAsset { diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs b/src/AdaptiveRemote.App/Models/CloudAssets/ICloudAsset.cs similarity index 95% rename from src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs rename to src/AdaptiveRemote.App/Models/CloudAssets/ICloudAsset.cs index 415376c7..63d57b6c 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs +++ b/src/AdaptiveRemote.App/Models/CloudAssets/ICloudAsset.cs @@ -1,4 +1,4 @@ -namespace AdaptiveRemote.Services.CloudAssets; +namespace AdaptiveRemote.Models.CloudAssets; /// /// Per-asset capability bundle. One implementation per cloud-fetched asset type. diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs b/src/AdaptiveRemote.App/Models/CloudAssets/JsonCloudAsset.cs similarity index 92% rename from src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs rename to src/AdaptiveRemote.App/Models/CloudAssets/JsonCloudAsset.cs index e36b4fb7..0a506bd9 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs +++ b/src/AdaptiveRemote.App/Models/CloudAssets/JsonCloudAsset.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace AdaptiveRemote.Services.CloudAssets; +namespace AdaptiveRemote.Models.CloudAssets; internal sealed class JsonCloudAsset( string name, diff --git a/src/AdaptiveRemote.App/Models/Phrases.cs b/src/AdaptiveRemote.App/Models/Phrases.cs index e4a46b22..8edc0651 100644 --- a/src/AdaptiveRemote.App/Models/Phrases.cs +++ b/src/AdaptiveRemote.App/Models/Phrases.cs @@ -23,6 +23,7 @@ internal static class Phrases public static string Startup_BuildingServiceGraph => "Building service graph"; public static string Startup_StartingServices => "Starting services"; public static string Startup_Preinitializing(string initializer) => $"Waiting for preinitializer {initializer}"; + public static string Startup_LoadingCloudAssets => "Loading cloud assets"; public static string Startup_ConnectingToBroadlink => "Connecting to Broadlink device"; public static string Startup_ConnectingToTiVo => "Connecting to TiVo"; public static string Startup_Starting(string service) => $"Starting {service}"; diff --git a/src/AdaptiveRemote.App/Services/MvvmPropertyIdleAdapter.cs b/src/AdaptiveRemote.App/Mvvm/MvvmPropertyActivityDetector.cs similarity index 84% rename from src/AdaptiveRemote.App/Services/MvvmPropertyIdleAdapter.cs rename to src/AdaptiveRemote.App/Mvvm/MvvmPropertyActivityDetector.cs index 1af8f5ca..7b61c191 100644 --- a/src/AdaptiveRemote.App/Services/MvvmPropertyIdleAdapter.cs +++ b/src/AdaptiveRemote.App/Mvvm/MvvmPropertyActivityDetector.cs @@ -1,19 +1,19 @@ using System.ComponentModel; -using AdaptiveRemote.Mvvm; +using AdaptiveRemote.Services; -namespace AdaptiveRemote.Services; +namespace AdaptiveRemote.Mvvm; // Subscribes to a bool MvvmProperty on an MvvmObject and holds a non-idle token via // IIdleDetector while the property is true. Thread-safe: InitializeAsync, CleanUpAsync, // and OnPropertyChanged all synchronize on _lock, and _subscribed prevents token leaks // if a PropertyChanged callback races with CleanUpAsync. -internal abstract class MvvmPropertyIdleAdapter : IUserActivityDetector, IDisposable +internal abstract class MvvmPropertyActivityDetector : IUserActivityDetector, IDisposable { private readonly MvvmObject _target; private readonly MvvmProperty _property; private DateTime? _lastActivityTime; - protected MvvmPropertyIdleAdapter(MvvmObject target, MvvmProperty property) + protected MvvmPropertyActivityDetector(MvvmObject target, MvvmProperty property) { _target = target ?? throw new ArgumentNullException(nameof(target)); _property = property ?? throw new ArgumentNullException(nameof(property)); diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetCache.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetCache.cs new file mode 100644 index 00000000..5760038e --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetCache.cs @@ -0,0 +1,44 @@ +using AdaptiveRemote.Services; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Services.CloudAssets; + +internal sealed class CloudAssetCache : ICloudAssetCache +{ + private readonly string _cacheDirectory; + private readonly IFileSystem _fileSystem; + + public CloudAssetCache(IOptions options, IFileSystem fileSystem) + { + _cacheDirectory = Environment.ExpandEnvironmentVariables(options.Value.CachePath); + _fileSystem = fileSystem; + } + + public Task LoadAsync(string name, CancellationToken ct) + { + string path = GetCachePath(name); + if (!_fileSystem.FileExists(path)) + { + return Task.FromResult(null); + } + return Task.FromResult(_fileSystem.OpenRead(path)); + } + + public async Task SaveAsync(string name, Stream assetData, CancellationToken ct) + { + string path = GetCachePath(name); + await using Stream dest = _fileSystem.OpenWrite(path, createDirectory: true); + await assetData.CopyToAsync(dest, ct); + } + + private string GetCachePath(string name) + { + // Asset names are developer-controlled DI constants, but guard against accidental + // misconfiguration that would place a cache file outside the cache directory. + if (name.IndexOfAny(['/', '\\', ':']) >= 0 || name.StartsWith('.')) + { + throw new ArgumentException($"Invalid asset name '{name}'.", nameof(name)); + } + return Path.Combine(_cacheDirectory, $"{name}.cache"); + } +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs index 2899f2e1..d75cbcca 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs @@ -1,4 +1,8 @@ +using System.Runtime.InteropServices.Marshalling; +using System.Security.Cryptography; using AdaptiveRemote.Logging; +using AdaptiveRemote.Models; +using AdaptiveRemote.Models.CloudAssets; using AdaptiveRemote.Services.Lifecycle; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -10,18 +14,38 @@ internal class CloudAssetOrchestrator : BackgroundService, IPreScopeInitializer private readonly IEnumerable _assets; private readonly ICloudAssetDownloader _downloader; private readonly ICloudAssetStore _store; + private readonly ICloudAssetCache _cache; + private readonly IApplicationRecycleSignal _signal; + private readonly IIdleDetector _idleDetector; + private readonly ICloudAssetChangeNotifier _changeNotifier; private readonly MessageLogger _log; private readonly TaskCompletionSource _initCompleted = new(); + // SHA256 hashes of bytes last written to cache, keyed by asset name. + // Populated only for assets loaded from cache in Phase 1; used by Phase 2 to detect server changes. + private readonly Dictionary _cacheHashes = new(); + + // Non-null while a WaitForIdleAsync task is pending; prevents stacking recycle requests + // across Phase 2/3 cycles. + private Task? _pendingRecycleTask; + public CloudAssetOrchestrator( IEnumerable assets, ICloudAssetDownloader downloader, ICloudAssetStore store, + ICloudAssetCache cache, + IApplicationRecycleSignal signal, + IIdleDetector idleDetector, + ICloudAssetChangeNotifier changeNotifier, ILogger logger) { _assets = assets; _downloader = downloader; _store = store; + _cache = cache; + _signal = signal; + _idleDetector = idleDetector; + _changeNotifier = changeNotifier; _log = new MessageLogger(logger); } @@ -31,30 +55,227 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { 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); - } - } + await Phase1Async(stoppingToken); _initCompleted.SetResult(); } catch (Exception ex) { _log.CloudAssetOrchestrator_Failed(ex); _initCompleted.TrySetException(ex); - throw; + // Do not re-throw: ApplicationLifecycle observes the faulted WaitAsync and sets + // FatalError. Re-throwing here would trigger BackgroundServiceExceptionBehavior.StopHost, + // which kills the process before the FatalError UI state can be observed. + return; + } + + try + { + await Phase2Async(stoppingToken); + await Phase3Async(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Normal shutdown — swallow so BackgroundServiceExceptionBehavior.StopHost is not triggered. + } + catch (Exception ex) + { + // Unexpected exception in background phases — log and exit cleanly. + _log.CloudAssetOrchestrator_Failed(ex); } } public Task WaitAsync(ILifecycleActivity activity, CancellationToken ct) { - activity.Description = "Loading cloud assets"; + activity.Description = Phrases.Startup_LoadingCloudAssets; return _initCompleted.Task.WaitAsync(ct); } + + private async Task Phase1Async(CancellationToken ct) + { + ICloudAsset[] assets = _assets.ToArray(); + await Task.WhenAll(assets.Select(asset => LoadAssetAsync(asset, ct))); + } + + private async Task LoadAssetAsync(ICloudAsset asset, CancellationToken ct) + { + byte[]? cachedBytes = await LoadAssetFromCacheAsync(asset, ct); + if (cachedBytes != null) + { + await ApplyAssetAsync(asset, cachedBytes, fromCache: true, ct); + _log.CloudAssetOrchestrator_LoadedFromCache(asset.Name); + return; + } + else + { + _log.CloudAssetOrchestrator_NotFoundInCache(asset.Name); + } + + byte[] serverBytes = await DownloadAssetAsync(asset, ct) + ?? throw new InvalidOperationException($"Failed to download asset '{asset.Name}'."); + + await ApplyAssetAsync(asset, serverBytes, ct); + } + + private async Task LoadAssetFromCacheAsync(ICloudAsset asset, CancellationToken ct) + { + Stream? cachedStream = await _cache.LoadAsync(asset.Name, ct); + if (cachedStream != null) + { + return await ReadAllBytesAndDisposeAsync(cachedStream, ct); + } + + return null; + } + + private async Task Phase2Async(CancellationToken ct) + { + // Only check assets that were loaded from cache in Phase 1 (present in _cacheHashes). + HashSet cacheLoadedNames; + lock (_cacheHashes) + { + cacheLoadedNames = [.. _cacheHashes.Keys]; + } + ICloudAsset[] toCheck = _assets.Where(a => cacheLoadedNames.Contains(a.Name)).ToArray(); + + foreach (ICloudAsset asset in toCheck) + { + if (ct.IsCancellationRequested) + { + return; + } + + byte[]? serverBytes = await DownloadAssetAsync(asset, ct); + ct.ThrowIfCancellationRequested(); + + if (serverBytes == null) + { + continue; + } + + await ApplyAssetAsync(asset, serverBytes, ct); + ct.ThrowIfCancellationRequested(); + } + } + + private async Task Phase3Async(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + ICloudAsset asset = await _changeNotifier.WaitForChangeAsync(ct); + ct.ThrowIfCancellationRequested(); + + _log.CloudAssetOrchestrator_FileChangeDetected(asset.Name); + + byte[]? serverBytes = await DownloadAssetAsync(asset, ct); + ct.ThrowIfCancellationRequested(); + + if (serverBytes is not null) + { + await ApplyAssetAsync(asset, serverBytes, ct); + } + } + } + + private static async Task ReadAllBytesAndDisposeAsync(Stream stream, CancellationToken ct) + { + await using (stream) + { + using MemoryStream ms = new(); + await stream.CopyToAsync(ms, ct); + return ms.ToArray(); + } + } + + private Task ApplyAssetAsync(ICloudAsset asset, byte[] bytes, CancellationToken ct = default) + => ApplyAssetAsync(asset, bytes, fromCache: false, ct); + + private async Task ApplyAssetAsync(ICloudAsset asset, byte[] bytes, bool fromCache, CancellationToken ct = default) + { + byte[] assetHash = SHA256.HashData(bytes); + if (ShouldApply(asset, assetHash)) + { + object value = await asset.DeserializeAsync(new MemoryStream(bytes), ct); + _store.Set(asset.Name, value); + + if (fromCache) + { + _cacheHashes[asset.Name] = assetHash; + } + else + { + await _cache.SaveAsync(asset.Name, new MemoryStream(bytes), ct); + } + + if (_initCompleted.Task.IsCompleted) + { + // Only need to recycle if we've already signaled that initialization is complete, + // otherwise the initialization sequence will take care of applying the assets. + _log.CloudAssetOrchestrator_AssetUpdated(asset.Name); + + IdleDeferRecycle(); + } + } + } + + private bool ShouldApply(ICloudAsset asset, byte[] assetHash) + { + lock (_cacheHashes) + { + if (_cacheHashes.TryGetValue(asset.Name, out byte[]? cachedHash) + && assetHash.AsSpan().SequenceEqual(cachedHash)) + { + _log.CloudAssetOrchestrator_AssetUpToDate(asset.Name); + return false; + } + } + + return true; + } + + private async Task DownloadAssetAsync(ICloudAsset asset, CancellationToken ct) + { + _log.CloudAssetOrchestrator_Downloading(asset.Name); + + Stream? serverStream; + try + { + serverStream = await _downloader.GetActiveAsync(asset.ResourcePath, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _log.CloudAssetOrchestrator_BackgroundFetchFailed(asset.Name, ex); + return null; + } + + if (serverStream == null) + { + _log.CloudAssetOrchestrator_BackgroundFetchFailed(asset.Name, null); + return null; + } + + byte[] bytes = await ReadAllBytesAndDisposeAsync(serverStream, ct); + _log.CloudAssetOrchestrator_Downloaded(asset.Name); + + return bytes; + } + + private void IdleDeferRecycle() + { + if (_pendingRecycleTask?.IsCompleted == false) + { + return; + } + + _pendingRecycleTask = _idleDetector.WaitForIdleAsync(default) + .ContinueWith( + _ => _signal.RequestRecycle(), + CancellationToken.None, + TaskContinuationOptions.OnlyOnRanToCompletion, + TaskScheduler.Default); + } } + diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/FileCloudAssetDownloader.cs b/src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetDownloader.cs similarity index 81% rename from src/AdaptiveRemote.App/Services/CloudAssets/FileCloudAssetDownloader.cs rename to src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetDownloader.cs index 043d3f80..05575b76 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/FileCloudAssetDownloader.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetDownloader.cs @@ -3,12 +3,12 @@ namespace AdaptiveRemote.Services.CloudAssets; -internal sealed class FileCloudAssetDownloader : ICloudAssetDownloader +internal sealed class FileSystemCloudAssetDownloader : ICloudAssetDownloader { private readonly CloudSettings _settings; private readonly IFileSystem _fileSystem; - public FileCloudAssetDownloader(IOptions options, IFileSystem fileSystem) + public FileSystemCloudAssetDownloader(IOptions options, IFileSystem fileSystem) { _settings = options.Value; _fileSystem = fileSystem; diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetWatchService.cs b/src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetWatchService.cs new file mode 100644 index 00000000..e85fe2ef --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetWatchService.cs @@ -0,0 +1,86 @@ +using AdaptiveRemote.Models.CloudAssets; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Services.CloudAssets; + +internal sealed class FileSystemCloudAssetWatchService : BackgroundService, ICloudAssetChangeNotifier +{ + private readonly CloudSettings _settings; + private readonly ICloudAsset _asset; + private readonly SemaphoreSlim _semaphore = new(0, 1); + private readonly object _debounceLock = new(); + private CancellationTokenSource? _debounceCts; + + public FileSystemCloudAssetWatchService(IEnumerable assets, IOptions options) + { + _settings = options.Value; + + // The real implementation will need to support multiple assets, but for now we only have one and this + // is just for short-term testing. + _asset = assets.First(); + } + + public async Task WaitForChangeAsync(CancellationToken ct) + { + await _semaphore.WaitAsync(ct); + return _asset; + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + string path = Environment.ExpandEnvironmentVariables(_settings.StubFilePath); + string? dir = Path.GetDirectoryName(path); + string fileName = Path.GetFileName(path); + + if (dir is null || fileName is null) + { + return Task.CompletedTask; + } + + FileSystemWatcher watcher = new(dir, fileName) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.CreationTime, + EnableRaisingEvents = true + }; + + watcher.Changed += OnChanged; + watcher.Created += OnChanged; + watcher.Renamed += OnChanged; + + stoppingToken.Register(watcher.Dispose); + + return Task.CompletedTask; + } + + private void OnChanged(object sender, FileSystemEventArgs e) + { + CancellationTokenSource newCts; + lock (_debounceLock) + { + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + newCts = _debounceCts = new CancellationTokenSource(); + } + + _ = Task.Run(async () => + { + try + { + await Task.Delay(100, newCts.Token); + try + { + _semaphore.Release(); + } + catch (SemaphoreFullException) + { + // Already a notification pending — swallow so the count stays at 1. + } + } + catch (OperationCanceledException) + { + // Debounced by a later event. + } + }); + } +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetChangeNotifier.cs b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetChangeNotifier.cs new file mode 100644 index 00000000..065fab88 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetChangeNotifier.cs @@ -0,0 +1,11 @@ +using AdaptiveRemote.Models.CloudAssets; + +namespace AdaptiveRemote.Services.CloudAssets; + +internal interface ICloudAssetChangeNotifier +{ + /// + /// Waits until a change is detected and returns the name of the asset that changed. + /// + Task WaitForChangeAsync(CancellationToken ct); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/IdleDetector.cs b/src/AdaptiveRemote.App/Services/CloudAssets/IdleDetector.cs deleted file mode 100644 index 4fcababa..00000000 --- a/src/AdaptiveRemote.App/Services/CloudAssets/IdleDetector.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.Extensions.Options; - -namespace AdaptiveRemote.Services.CloudAssets; - -internal class IdleDetector : IIdleDetector -{ - private readonly TimeSpan _cooldown; - private readonly IEnumerable _userActivityDetectors; - - public IdleDetector(IEnumerable userActivityDetectors, IOptions settings) - { - _cooldown = TimeSpan.FromSeconds(Math.Max(.1, settings.Value.IdleCooldownSeconds)); - _userActivityDetectors = userActivityDetectors.ToImmutableList(); - } - - public async Task WaitForIdleAsync(CancellationToken cancellationToken) - { - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - - DateTime mostRecentActivity = _userActivityDetectors - .Select(x => x.LastActivityTime) - .DefaultIfEmpty(DateTime.MinValue) - .Max(); - - TimeSpan timeUntilIdle = (mostRecentActivity + _cooldown) - DateTime.Now; - - if (timeUntilIdle <= TimeSpan.Zero) - { - break; - } - - await Task.Delay(timeUntilIdle, cancellationToken); - } - } -} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/_doc_CloudAssets.md b/src/AdaptiveRemote.App/Services/CloudAssets/_doc_CloudAssets.md new file mode 100644 index 00000000..a2071581 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/_doc_CloudAssets.md @@ -0,0 +1,60 @@ +# Cloud Assets + +Cloud assets are server-fetched, locally-cached data bundles (e.g. the compiled layout) that drive the runtime behavior of the application. + +## Composition model + +Each asset is a singleton implementing [`ICloudAsset`](ICloudAsset.cs): + +| Property | Purpose | +|----------|---------| +| `Name` | Cache key; also used in logging (e.g. `"layout"`) | +| `ResourcePath` | REST path for fetching the latest version | +| `StreamUrl` / `EventName` | SSE endpoint/event for live push notifications (future) | +| `DeserializeAsync` | Converts raw bytes to the asset's runtime type | + +Assets are registered via [`CloudAssetServiceExtensions.AddScopedCloudAsset`](../../Configuration/CloudAssetServiceExtensions.cs) and resolved from the DI-scoped [`ICloudAssetStore`](ICloudAssetStore.cs). + +## Three-phase orchestrator + +[`CloudAssetOrchestrator`](CloudAssetOrchestrator.cs) is a `BackgroundService` and `IPreScopeInitializer`. It runs three phases: + +**Phase 1 — cache-first load (blocks scope initialization)** + +All assets are loaded in parallel. For each asset: +- If a `.cache` file exists → deserialize from cache; record SHA-256 of those bytes. +- Otherwise → download from server, save to cache, deserialize. + +`WaitAsync` returns once all assets are in the store. If any asset fails, `WaitAsync` faults. + +**Phase 2 — background server refresh (runs after Phase 1 completes)** + +For every asset that was loaded from cache, the server is queried. If the content differs (by SHA-256), the cache and store are updated and an idle-deferred scope recycle is scheduled. Server failures log a warning and are silently skipped. + +**Phase 3 — ongoing file-change loop (stub / dev mode)** + +Waits on [`IAssetChangeNotifier.WaitForChangeAsync`](IAssetChangeNotifier.cs) in a loop. On each notification, all assets are re-downloaded, cached, and stored, and a recycle is scheduled. + +## Cache + +[`CloudAssetCache`](CloudAssetCache.cs) reads and writes `.cache` files under `CloudSettings.CachePath` (environment variables expanded). File path: `{CachePath}/{name}.cache`. + +## File-change notification + +[`FileSystemCloudAssetWatchService`](FileSystemCloudAssetWatchService.cs) watches `CloudSettings.StubFilePath` using `FileSystemWatcher`. It debounces rapid events using a cancel-restart pattern (100 ms delay) and exposes a `SemaphoreSlim(0,1)` so multiple events collapse to a single notification. + +This service will be replaced by an SSE-based implementation (ADR-186) without touching the orchestrator. + +## Idle-deferred scope recycle + +When an update is detected, the orchestrator calls `IdleDeferRecycle`: +- If `IIdleDetector.IsIdle` → `IApplicationRecycleSignal.RequestRecycle()` immediately. +- Otherwise → subscribe to `IIdleDetector.BecameIdle`; recycle when the event fires. + +The idle cooldown in tests is set to 0 seconds via `--cloud:IdleCooldownSeconds=0`. + +## Adding a new asset type + +1. Create a class implementing `ICloudAsset` (or use [`JsonCloudAsset`](JsonCloudAsset.cs)). +2. Register it: `services.AddScopedCloudAsset(new JsonCloudAsset(...))`. +3. Inject `MyType` into scoped services via DI — the store resolves it automatically. diff --git a/src/AdaptiveRemote.App/Services/Commands/CommandIdleAdapter.cs b/src/AdaptiveRemote.App/Services/Commands/CommandActivityDetector.cs similarity index 58% rename from src/AdaptiveRemote.App/Services/Commands/CommandIdleAdapter.cs rename to src/AdaptiveRemote.App/Services/Commands/CommandActivityDetector.cs index 8fd0fa00..d74b400d 100644 --- a/src/AdaptiveRemote.App/Services/Commands/CommandIdleAdapter.cs +++ b/src/AdaptiveRemote.App/Services/Commands/CommandActivityDetector.cs @@ -1,11 +1,12 @@ using AdaptiveRemote.Models; +using AdaptiveRemote.Mvvm; namespace AdaptiveRemote.Services.Commands; // Tracks IsActive for a single Command; created by CommandExecutionIdleAdapter. -internal sealed class CommandIdleAdapter : MvvmPropertyIdleAdapter +internal sealed class CommandActivityDetector : MvvmPropertyActivityDetector { - internal CommandIdleAdapter(Command command, IIdleDetector idleDetector) + internal CommandActivityDetector(Command command) : base(command, Command.IsActiveProperty) { } diff --git a/src/AdaptiveRemote.App/Services/Commands/CommandExecutionIdleAdapter.cs b/src/AdaptiveRemote.App/Services/Commands/CommandsActivityDetector.cs similarity index 60% rename from src/AdaptiveRemote.App/Services/Commands/CommandExecutionIdleAdapter.cs rename to src/AdaptiveRemote.App/Services/Commands/CommandsActivityDetector.cs index f4ba5334..e898ecef 100644 --- a/src/AdaptiveRemote.App/Services/Commands/CommandExecutionIdleAdapter.cs +++ b/src/AdaptiveRemote.App/Services/Commands/CommandsActivityDetector.cs @@ -1,16 +1,16 @@ namespace AdaptiveRemote.Services.Commands; // Creates one CommandIdleAdapter per command and delegates lifecycle calls to them. -internal class CommandExecutionIdleAdapter : IUserActivityDetector +internal class CommandsActivityDetector : IUserActivityDetector { private readonly IReadOnlyList _adapters; public DateTime LastActivityTime => _adapters.Select(x => x.LastActivityTime).Max(); - public CommandExecutionIdleAdapter(IRemoteDefinitionService remoteDefinition, IIdleDetector idleDetector) + public CommandsActivityDetector(IRemoteDefinitionService remoteDefinition) { _adapters = remoteDefinition.GetCommands() - .Select(cmd => new CommandIdleAdapter(cmd, idleDetector)) + .Select(cmd => new CommandActivityDetector(cmd)) .ToList(); } } diff --git a/src/AdaptiveRemote.App/Services/Conversation/ConversationIdleAdapter.cs b/src/AdaptiveRemote.App/Services/Conversation/ConversationActivityDetector.cs similarity index 51% rename from src/AdaptiveRemote.App/Services/Conversation/ConversationIdleAdapter.cs rename to src/AdaptiveRemote.App/Services/Conversation/ConversationActivityDetector.cs index 53a26444..881865af 100644 --- a/src/AdaptiveRemote.App/Services/Conversation/ConversationIdleAdapter.cs +++ b/src/AdaptiveRemote.App/Services/Conversation/ConversationActivityDetector.cs @@ -1,10 +1,11 @@ using AdaptiveRemote.Models; +using AdaptiveRemote.Mvvm; namespace AdaptiveRemote.Services.Conversation; -internal class ConversationIdleAdapter : MvvmPropertyIdleAdapter +internal class ConversationActivityDetector : MvvmPropertyActivityDetector { - public ConversationIdleAdapter(IRemoteDefinitionService remoteDefinition, IIdleDetector idleDetector) + public ConversationActivityDetector(IRemoteDefinitionService remoteDefinition) : base(remoteDefinition.GetElement(), ConversationView.IsListeningProperty) { } diff --git a/src/AdaptiveRemote.App/Services/IdleDetection/IdleDetector.cs b/src/AdaptiveRemote.App/Services/IdleDetection/IdleDetector.cs new file mode 100644 index 00000000..269d9378 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/IdleDetection/IdleDetector.cs @@ -0,0 +1,70 @@ +using System.Collections.Immutable; +using AdaptiveRemote.Services.CloudAssets; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Services.IdleDetection; + +internal class IdleDetector : IIdleDetector +{ + private TaskCompletionSource _scopedIdleDetectorTask = new(); + + public async Task WaitForIdleAsync(CancellationToken cancellationToken) + { +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks + ScopedIdleDetector scoped = await _scopedIdleDetectorTask.Task; +#pragma warning restore VSTHRD003 // Avoid awaiting foreign Tasks + + await scoped.WaitForIdleAsync(cancellationToken); + } + + internal class ScopedIdleDetector : IScopedLifecycle + { + private readonly TimeSpan _cooldown; + private readonly IEnumerable _userActivityDetectors; + private readonly IdleDetector _globalIdleDetector; + + public ScopedIdleDetector(IEnumerable userActivityDetectors, IIdleDetector globalIdleDetector, IOptions settings) + { + _cooldown = TimeSpan.FromSeconds(Math.Max(.1, settings.Value.IdleCooldownSeconds)); + _userActivityDetectors = userActivityDetectors.ToImmutableList(); + _globalIdleDetector = globalIdleDetector as IdleDetector + ?? throw new ArgumentException("Wrong type was injected for IIdleDetector", nameof(globalIdleDetector)); + } + + public string Name => "Idle Detection"; + + public Task CleanUpAsync(ILifecycleActivity activity, CancellationToken cancellationToken) + { + _globalIdleDetector._scopedIdleDetectorTask = new(); + return Task.CompletedTask; + } + + public Task InitializeAsync(ILifecycleActivity activity, CancellationToken cancellationToken) + { + _globalIdleDetector._scopedIdleDetectorTask.TrySetResult(this); + return Task.CompletedTask; + } + + public async Task WaitForIdleAsync(CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + DateTime mostRecentActivity = _userActivityDetectors + .Select(x => x.LastActivityTime) + .DefaultIfEmpty(DateTime.MinValue) + .Max(); + + TimeSpan timeUntilIdle = (mostRecentActivity + _cooldown) - DateTime.Now; + + if (timeUntilIdle <= TimeSpan.Zero) + { + break; + } + + await Task.Delay(timeUntilIdle, cancellationToken); + } + } + } +} diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeActivityDetector.cs b/src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeActivityDetector.cs new file mode 100644 index 00000000..3a98a2ee --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeActivityDetector.cs @@ -0,0 +1,12 @@ +using AdaptiveRemote.Models; +using AdaptiveRemote.Mvvm; + +namespace AdaptiveRemote.Services.Lifecycle; + +internal class ProgrammingModeActivityDetector : MvvmPropertyActivityDetector +{ + public ProgrammingModeActivityDetector(LifecycleView lifecycleView) + : base(lifecycleView, LifecycleView.IsProgrammingModeProperty) + { + } +} diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeIdleAdapter.cs b/src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeIdleAdapter.cs deleted file mode 100644 index 5ec12e3b..00000000 --- a/src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeIdleAdapter.cs +++ /dev/null @@ -1,11 +0,0 @@ -using AdaptiveRemote.Models; - -namespace AdaptiveRemote.Services.Lifecycle; - -internal class ProgrammingModeIdleAdapter : MvvmPropertyIdleAdapter -{ - public ProgrammingModeIdleAdapter(LifecycleView lifecycleView, IIdleDetector idleDetector) - : base(lifecycleView, LifecycleView.IsProgrammingModeProperty) - { - } -} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetCacheTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetCacheTests.cs new file mode 100644 index 00000000..be6e0bfc --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetCacheTests.cs @@ -0,0 +1,128 @@ +using System.Text; +using AdaptiveRemote.TestUtilities; +using FluentAssertions; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class CloudAssetCacheTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private const string AssetName = "layout"; + private const string CachePath = "cache"; + private static readonly string CacheFile = Path.Combine("cache", "layout.cache"); + private const string CacheDir = CachePath; + + private static CloudAssetCache MakeSut(MockFileSystem fileSystem, string cachePath = CachePath) + => new(new MockOptions(new CloudSettings { CachePath = cachePath }), fileSystem.Object); + + [TestMethod] + public void CloudAssetCache_SaveAsync_WritesFileWithCorrectContent() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddDirectory(CacheDir); + byte[] content = "test-payload"u8.ToArray(); + CloudAssetCache sut = MakeSut(fileSystem); + + // Act + sut.SaveAsync(AssetName, new MemoryStream(content), CancellationToken.None) + .Should().BeCompleteWithin(Timeout); + + // Assert + fileSystem.VerifyFileContents(CacheFile, "test-payload"); + } + + [TestMethod] + public void CloudAssetCache_SaveAsync_CreatesDirectoryWhenAbsent() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.Expect_CreateDirectory_ForPath(CacheDir); + byte[] content = "data"u8.ToArray(); + CloudAssetCache sut = MakeSut(fileSystem); + + // Act + sut.SaveAsync(AssetName, new MemoryStream(content), CancellationToken.None) + .Should().BeCompleteWithin(Timeout); + + // Assert + fileSystem.Verify(); + } + + [TestMethod] + public void CloudAssetCache_SaveAsync_DoesNotCreateDirectoryWhenPresent() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddDirectory(CacheDir); + fileSystem.Expect_CreateDirectory_IsNotCalled(); + CloudAssetCache sut = MakeSut(fileSystem); + + // Act + sut.SaveAsync(AssetName, new MemoryStream("data"u8.ToArray()), CancellationToken.None) + .Should().BeCompleteWithin(Timeout); + + // Assert + fileSystem.Verify(); + } + + [TestMethod] + public void CloudAssetCache_LoadAsync_ReturnsStreamOnCacheHit() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddFile(CacheFile, "cached-data"); + CloudAssetCache sut = MakeSut(fileSystem); + + // Act + Task loadTask = sut.LoadAsync(AssetName, CancellationToken.None); + loadTask.Should().BeCompleteWithin(Timeout); + Stream? result = loadTask.Result; + + // Assert + result.Should().NotBeNull(); + using StreamReader reader = new(result!); + reader.ReadToEnd().Should().Be("cached-data"); + } + + [TestMethod] + public void CloudAssetCache_LoadAsync_ReturnsNullOnCacheMiss() + { + // Arrange + MockFileSystem fileSystem = new(); + CloudAssetCache sut = MakeSut(fileSystem); + + // Act + Task loadTask = sut.LoadAsync(AssetName, CancellationToken.None); + loadTask.Should().BeCompleteWithin(Timeout); + Stream? result = loadTask.Result; + + // Assert + result.Should().BeNull(); + } + + [TestMethod] + public void CloudAssetCache_SaveThenLoad_RoundTrips() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddDirectory(CacheDir); + byte[] original = Encoding.UTF8.GetBytes("{\"key\":\"value\"}"); + CloudAssetCache sut = MakeSut(fileSystem); + + // Act + sut.SaveAsync(AssetName, new MemoryStream(original), CancellationToken.None) + .Should().BeCompleteWithin(Timeout); + Task loadTask = sut.LoadAsync(AssetName, CancellationToken.None); + loadTask.Should().BeCompleteWithin(Timeout); + Stream? loaded = loadTask.Result; + + // Assert + loaded.Should().NotBeNull(); + using MemoryStream ms = new(); + loaded!.CopyTo(ms); + ms.ToArray().Should().Equal(original); + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs index aaf847a4..01eade93 100644 --- a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs @@ -1,5 +1,5 @@ +using AdaptiveRemote.Models.CloudAssets; using AdaptiveRemote.Services.Lifecycle; -using AdaptiveRemote.TestUtilities; using FluentAssertions; using Moq; @@ -15,11 +15,14 @@ public class CloudAssetOrchestratorTests private readonly Mock MockAsset = new(); private readonly Mock MockDownloader = new(); private readonly Mock MockStore = new(); + private readonly Mock MockCache = new(); + private readonly Mock MockSignal = new(); + private readonly Mock MockIdleDetector = new(); + private readonly Mock MockChangeNotifier = 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); + public TestContext TestContext { get; set; } = null!; [TestInitialize] public void SetupMocks() @@ -27,10 +30,46 @@ public void SetupMocks() MockAsset.SetupGet(a => a.Name).Returns(AssetName); MockAsset.SetupGet(a => a.ResourcePath).Returns(ResourcePath); MockActivity.SetupSet(a => a.Description = It.IsAny()); + + // Default: idle, no changes pending + MockIdleDetector.Setup(d => d.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // Default: block forever on change notifier (prevents Phase 3 interference) + MockChangeNotifier.Setup(n => n.WaitForChangeAsync(It.IsAny())) + .Returns(ct => Task.Delay(Timeout.Multiply(10), ct).ContinueWith(_ => MockAsset.Object, ct, TaskContinuationOptions.None, TaskScheduler.Default)); + + // Default: cache miss + MockCache.Setup(c => c.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Stream?)null); + } + + [TestCleanup] + public void LogMockLoggerMessages() + { + // Log all captured messages for easier debugging of test failures; in a real test suite we might want to be more selective about this + foreach (string log in MockLogger.Messages) + { + TestContext.WriteLine(log.ToString()); + } } + private CloudAssetOrchestrator MakeSut(IEnumerable? assets = null) + => new(assets ?? [MockAsset.Object], + MockDownloader.Object, + MockStore.Object, + MockCache.Object, + MockSignal.Object, + MockIdleDetector.Object, + MockChangeNotifier.Object, + MockLogger); + + // ────────────────────────────────────────────────────────────── + // Phase 1 — cache miss (server download path) + // ────────────────────────────────────────────────────────────── + [TestMethod] - public void CloudAssetOrchestrator_ExecuteAsync_DownloadsAndStoresAsset() + public void CloudAssetOrchestrator_ExecuteAsync_CacheMiss_DownloadsAndStoresAsset() { // Arrange object parsedValue = new(); @@ -48,11 +87,18 @@ public void CloudAssetOrchestrator_ExecuteAsync_DownloadsAndStoresAsset() waitTask.Should().BeCompleteWithin(Timeout); waitTask.Should().BeSuccessful(); MockStore.Verify(s => s.Set(AssetName, parsedValue), Times.Once); - MockLogger.VerifyMessages(log => { log.CloudAssetOrchestrator_Downloading(AssetName); }); + MockCache.Verify(c => c.SaveAsync(AssetName, It.IsAny(), It.IsAny()), Times.Once); + MockLogger.VerifyMessages( + log => + { + log.CloudAssetOrchestrator_NotFoundInCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Downloaded(AssetName); + }); } [TestMethod] - public void CloudAssetOrchestrator_ExecuteAsync_FaultsWhenDownloadReturnsNull() + public void CloudAssetOrchestrator_ExecuteAsync_CacheMiss_FaultsWhenDownloadReturnsNull() { // Arrange MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) @@ -68,13 +114,39 @@ public void CloudAssetOrchestrator_ExecuteAsync_FaultsWhenDownloadReturnsNull() waitTask.Should().BeFaultedWith(expectedException, within: Timeout); MockLogger.VerifyMessages(log => { + log.CloudAssetOrchestrator_NotFoundInCache(AssetName); log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, null); log.CloudAssetOrchestrator_Failed(expectedException); }); } [TestMethod] - public void CloudAssetOrchestrator_ExecuteAsync_FaultsWhenDeserializationFails() + public void CloudAssetOrchestrator_ExecuteAsync_CacheMiss_FaultsWhenDownloaderThrows() + { + // Arrange + InvalidOperationException downloadException = new($"Failed to download asset '{AssetName}'."); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ThrowsAsync(downloadException); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeFaultedWith(downloadException, within: Timeout); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_NotFoundInCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, downloadException); + log.CloudAssetOrchestrator_Failed(downloadException); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheMiss_FaultsWhenDeserializationFails() { // Arrange InvalidOperationException parseException = new("Deserialization failed"); @@ -92,8 +164,465 @@ public void CloudAssetOrchestrator_ExecuteAsync_FaultsWhenDeserializationFails() waitTask.Should().BeFaultedWith(parseException, within: Timeout); MockLogger.VerifyMessages(log => { + log.CloudAssetOrchestrator_NotFoundInCache(AssetName); log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Downloaded(AssetName); log.CloudAssetOrchestrator_Failed(parseException); }); } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheMiss_FaultsWhenCacheSaveThrows() + { + // Arrange + IOException saveException = new("disk full"); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new object()); + MockCache.Setup(c => c.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(saveException); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeFaultedWith(saveException, within: Timeout); + } + + // ────────────────────────────────────────────────────────────── + // Phase 1 — cache hit + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheHit_LoadsFromCacheAndSignalsReady() + { + // Arrange + object parsedValue = new(); + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(parsedValue); + + // Phase 2 server check returns same bytes — no recycle expected + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert — Phase 1 completed; Phase 2 runs concurrently after WaitAsync returns + waitTask.Should().BeCompleteWithin(Timeout); + waitTask.Should().BeSuccessful(); + MockStore.Verify(s => s.Set(AssetName, parsedValue), Times.AtLeastOnce); + // Downloader not called during Phase 1 (cache hit path); only Phase 2 may call it + MockLogger.CountMessages(log => { log.CloudAssetOrchestrator_LoadedFromCache(AssetName); }) + .Should().BeGreaterThanOrEqualTo(1); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheHit_FaultsWhenDeserializationFails() + { + // Arrange + InvalidOperationException parseException = new("parse error"); + MockCache.Setup(c => c.LoadAsync(AssetName, 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_Failed(parseException); }); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheHit_FaultsWhenCacheLoadThrows() + { + // Arrange + IOException loadException = new("disk error"); + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ThrowsAsync(loadException); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeFaultedWith(loadException, within: Timeout); + MockLogger.VerifyMessages(log => { log.CloudAssetOrchestrator_Failed(loadException); }); + } + + // ────────────────────────────────────────────────────────────── + // Phase 1 — multiple assets + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_MultipleAssets_AllStoredBeforeReady() + { + // Arrange + Mock asset1 = new(); + Mock asset2 = new(); + asset1.SetupGet(a => a.Name).Returns("asset1"); + asset1.SetupGet(a => a.ResourcePath).Returns("/path1"); + asset2.SetupGet(a => a.Name).Returns("asset2"); + asset2.SetupGet(a => a.ResourcePath).Returns("/path2"); + + object val1 = new(), val2 = new(); + MockDownloader.Setup(d => d.GetActiveAsync("/path1", It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockDownloader.Setup(d => d.GetActiveAsync("/path2", It.IsAny())) + .ReturnsAsync(new MemoryStream()); + asset1.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(val1); + asset2.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(val2); + + CloudAssetOrchestrator sut = MakeSut([asset1.Object, asset2.Object]); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeCompleteWithin(Timeout); + MockStore.Verify(s => s.Set("asset1", val1), Times.Once); + MockStore.Verify(s => s.Set("asset2", val2), Times.Once); + } + + // ────────────────────────────────────────────────────────────── + // Phase 2 — background server check + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void CloudAssetOrchestrator_Phase2_UpdatesStoreWhenServerReturnsDifferentContent() + { + // Arrange + byte[] cachedBytes = "cached"u8.ToArray(); + byte[] serverBytes = "updated"u8.ToArray(); + object initialValue = new(); + object updatedValue = new(); + + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(cachedBytes)); + + int deserializeCallCount = 0; + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => deserializeCallCount++ == 0 ? initialValue : updatedValue); + + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(serverBytes)); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + waitTask.Should().BeCompleteWithin(Timeout); + + // Wait for Phase 2 completion log rather than a fixed delay + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_AssetUpdated(AssetName); }).Should().BeCompleteWithin(Timeout); + + // Assert + MockStore.Verify(s => s.Set(AssetName, updatedValue), Times.Once); + MockSignal.Verify(s => s.RequestRecycle(), Times.Once); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_LoadedFromCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Downloaded(AssetName); + log.CloudAssetOrchestrator_AssetUpdated(AssetName); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_Phase2_DoesNotRecycleWhenServerReturnsIdenticalContent() + { + // Arrange + byte[] sharedBytes = "same-content"u8.ToArray(); + object parsedValue = new(); + + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(sharedBytes)); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(parsedValue); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(sharedBytes)); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + waitTask.Should().BeCompleteWithin(Timeout); + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_AssetUpToDate(AssetName); }).Should().BeCompleteWithin(Timeout); + + // Assert + MockSignal.Verify(s => s.RequestRecycle(), Times.Never); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_LoadedFromCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Downloaded(AssetName); + log.CloudAssetOrchestrator_AssetUpToDate(AssetName); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_Phase2_LogsWarningAndContinuesWhenServerReturnsNull() + { + // Arrange + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(() => new MemoryStream("cached"u8.ToArray())); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new object()); + 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); + waitTask.Should().BeCompleteWithin(Timeout); + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, null); }).Should().BeCompleteWithin(Timeout); + + // Assert — Phase 2 warning logged, no recycle, no crash + MockSignal.Verify(s => s.RequestRecycle(), Times.Never); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_LoadedFromCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, null); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_Phase2_LogsWarningAndContinuesWhenServerThrows() + { + // Arrange + HttpRequestException networkException = new("network error"); + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(() => new MemoryStream("cached"u8.ToArray())); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new object()); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ThrowsAsync(networkException); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + waitTask.Should().BeCompleteWithin(Timeout); + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, networkException); }).Should().BeCompleteWithin(Timeout); + + // Assert — Phase 2 warning, no crash + MockSignal.Verify(s => s.RequestRecycle(), Times.Never); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_LoadedFromCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, networkException); + }); + } + + // ────────────────────────────────────────────────────────────── + // Phase 3 — file-change loop + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void CloudAssetOrchestrator_Phase3_OnFileChange_DownloadsAndUpdatesStore() + { + // Arrange + TaskCompletionSource notificationSource = new(); + MockChangeNotifier.Setup(n => n.WaitForChangeAsync(It.IsAny())) + .Returns(async ct => + { + await notificationSource.Task.WaitAsync(ct); + // Block subsequent calls so only one Phase 3 cycle runs + MockChangeNotifier.Setup(n => n.WaitForChangeAsync(It.IsAny())) + .Returns(ct2 => Task.Delay(Timeout.Multiply(10), ct2).ContinueWith(_ => MockAsset.Object, ct2, TaskContinuationOptions.None, TaskScheduler.Default)); + return MockAsset.Object; + }); + + object updatedValue = new(); + // Phase 1 (cache miss): downloader returns initial bytes + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(() => new MemoryStream("server-data"u8.ToArray())); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(updatedValue); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act — start, let Phase 1 (cache miss + download) complete + _ = sut.StartAsync(CancellationToken.None); + sut.WaitAsync(MockActivity.Object, CancellationToken.None).Should().BeCompleteWithin(Timeout); + // Clear Phase 1 logs and store invocations before Phase 3 triggers + MockLogger.ClearMessages(); + MockStore.Invocations.Clear(); + MockSignal.Invocations.Clear(); + + // Signal a file change; wait until RequestRecycle is called (last observable action in Phase 3) + notificationSource.SetResult(); + SpinWait.SpinUntil(() => MockSignal.Invocations.Any(), Timeout); + + // Assert + MockStore.Verify(s => s.Set(AssetName, updatedValue), Times.Once); + MockSignal.Verify(s => s.RequestRecycle(), Times.Once); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_FileChangeDetected(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Downloaded(AssetName); + log.CloudAssetOrchestrator_AssetUpdated(AssetName); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_Phase3_OnFileChange_LogsWarningWhenServerReturnsNull() + { + // Arrange + TaskCompletionSource notificationSource = new(); + MockChangeNotifier.Setup(n => n.WaitForChangeAsync(It.IsAny())) + .Returns(async ct => + { + await notificationSource.Task.WaitAsync(ct); + MockChangeNotifier.Setup(n => n.WaitForChangeAsync(It.IsAny())) + .Returns(ct2 => Task.Delay(Timeout.Multiply(10), ct2).ContinueWith(_ => MockAsset.Object, ct2, TaskContinuationOptions.None, TaskScheduler.Default)); + return MockAsset.Object; + }); + + // Phase 1 (cache miss): downloader returns content so startup succeeds + // Phase 3: switch to null so the warning path is triggered + MockDownloader.SetupSequence(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(new MemoryStream("initial"u8.ToArray())) // Phase 1 + .ReturnsAsync((Stream?)null); // Phase 3 + + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new object()); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act — Phase 1 succeeds + _ = sut.StartAsync(CancellationToken.None); + sut.WaitAsync(MockActivity.Object, CancellationToken.None).Should().BeCompleteWithin(Timeout); + MockLogger.ClearMessages(); + + // Trigger Phase 3 and wait for warning log + notificationSource.SetResult(); + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, null); }).Should().BeCompleteWithin(Timeout); + + // Assert — warning logged, no crash, no recycle from Phase 3 + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_FileChangeDetected(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, null); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_Phase3_StopsCleanlyOnCancellation() + { + // Arrange — change notifier blocks until cancelled + CancellationTokenSource cts = new(); + MockChangeNotifier.Setup(n => n.WaitForChangeAsync(It.IsAny())) + .Returns(ct => Task.Delay(Timeout.Multiply(10), ct).ContinueWith(_ => MockAsset.Object, ct, TaskContinuationOptions.None, TaskScheduler.Default)); + + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync((Stream?)null); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act — start then cancel + _ = sut.StartAsync(CancellationToken.None); + sut.WaitAsync(MockActivity.Object, CancellationToken.None).Should().BeCompleteWithin(Timeout); + + Task stopTask = sut.StopAsync(CancellationToken.None); + + // Assert + stopTask.Should().BeCompleteWithin(Timeout); + stopTask.Should().BeSuccessful(); + } + + // ────────────────────────────────────────────────────────────── + // Idle-defer recycle + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void CloudAssetOrchestrator_IdleDeferRecycle_RequestsRecycleImmediatelyWhenAlreadyIdle() + { + // Arrange — cache hit + different server bytes = update triggered + byte[] cachedBytes = "v1"u8.ToArray(); + byte[] serverBytes = "v2"u8.ToArray(); + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(cachedBytes)); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new object()); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(serverBytes)); + MockIdleDetector.Setup(d => d.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + sut.WaitAsync(MockActivity.Object, CancellationToken.None).Should().BeCompleteWithin(Timeout); + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_AssetUpdated(AssetName); }).Should().BeCompleteWithin(Timeout); + + // Assert + MockSignal.Verify(s => s.RequestRecycle(), Times.Once); + // WaitForIdleAsync subscribes then immediately unsubscribes when already idle; + // the important assertion is that RequestRecycle was called synchronously. + MockIdleDetector.Verify(d => d.WaitForIdleAsync(It.IsAny()), Times.Once); + } + + [TestMethod] + public void CloudAssetOrchestrator_IdleDeferRecycle_SubscribesToBecameIdleWhenNotIdle() + { + // Arrange + byte[] cachedBytes = "v1"u8.ToArray(); + byte[] serverBytes = "v2"u8.ToArray(); + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(cachedBytes)); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new object()); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(serverBytes)); + + TaskCompletionSource tcs = new(); + MockIdleDetector.Setup(d => d.WaitForIdleAsync(It.IsAny())) + .Returns(tcs.Task); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act — wait for Phase 2 to register the handler + _ = sut.StartAsync(CancellationToken.None); + sut.WaitAsync(MockActivity.Object, CancellationToken.None).Should().BeCompleteWithin(Timeout); + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_AssetUpdated(AssetName); }).Should().BeCompleteWithin(Timeout); + + // Not yet recycled + MockSignal.Verify(s => s.RequestRecycle(), Times.Never); + + // Simulate becoming idle + tcs.SetResult(); + + // Wait for the ContinueWith continuation to fire RequestRecycle + SpinWait.SpinUntil(() => MockSignal.Invocations.Any(), Timeout); + + // Now recycle should have been requested + MockSignal.Verify(s => s.RequestRecycle(), Times.Once); + } } diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs index c33a4c70..0934dfeb 100644 --- a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs @@ -8,7 +8,7 @@ public class FileCloudAssetDownloaderTests private const string FilePath = "dev/layout.json"; private const string ResourcePath = "/layouts/compiled"; - private static FileCloudAssetDownloader MakeSut( + private static FileSystemCloudAssetDownloader MakeSut( string stubFilePath, MockFileSystem fileSystem) => new(new MockOptions(new CloudSettings { StubFilePath = stubFilePath }), fileSystem.Object); @@ -19,7 +19,7 @@ public async Task FileCloudAssetDownloader_GetActiveAsync_ReturnsStreamForConfig // Arrange MockFileSystem fileSystem = new(); fileSystem.AddFile(FilePath, "{}"); - FileCloudAssetDownloader sut = MakeSut(FilePath, fileSystem); + FileSystemCloudAssetDownloader sut = MakeSut(FilePath, fileSystem); // Act Stream? result = await sut.GetActiveAsync(ResourcePath, CancellationToken.None); @@ -33,7 +33,7 @@ public async Task FileCloudAssetDownloader_GetActiveAsync_ReturnsNullWhenFileAbs { // Arrange MockFileSystem fileSystem = new(); - FileCloudAssetDownloader sut = MakeSut("nonexistent/layout.json", fileSystem); + FileSystemCloudAssetDownloader sut = MakeSut("nonexistent/layout.json", fileSystem); // Act Stream? result = await sut.GetActiveAsync(ResourcePath, CancellationToken.None); @@ -48,7 +48,7 @@ public async Task FileCloudAssetDownloader_GetByIdAsync_ReturnsNullAsync() // Arrange MockFileSystem fileSystem = new(); fileSystem.AddFile(FilePath, "{}"); - FileCloudAssetDownloader sut = MakeSut(FilePath, fileSystem); + FileSystemCloudAssetDownloader sut = MakeSut(FilePath, fileSystem); // Act Stream? result = await sut.GetByIdAsync(ResourcePath, Guid.NewGuid(), CancellationToken.None); diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileSystemCloudAssetWatchServiceTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileSystemCloudAssetWatchServiceTests.cs new file mode 100644 index 00000000..98883822 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileSystemCloudAssetWatchServiceTests.cs @@ -0,0 +1,123 @@ +using AdaptiveRemote.Models.CloudAssets; +using FluentAssertions; +using Moq; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class FileSystemCloudAssetWatchServiceTests +{ + private static readonly TimeSpan WatchTimeout = TimeSpan.FromSeconds(5); + private readonly Mock MockAsset = new(); + + private FileSystemCloudAssetWatchService MakeSut(string stubFilePath) + => new([MockAsset.Object], new MockOptions(new CloudSettings { StubFilePath = stubFilePath })); + + [TestMethod] + public async Task FileSystemCloudAssetWatchService_WaitForChangeAsync_UnblocksOnFileWriteAsync() + { + // Arrange + string tempFile = Path.GetTempFileName(); + try + { + FileSystemCloudAssetWatchService sut = MakeSut(tempFile); + await sut.StartAsync(CancellationToken.None); + + Task waitTask = sut.WaitForChangeAsync(CancellationToken.None); + + // Act — write to the file after a short delay + await Task.Delay(50); + await File.WriteAllTextAsync(tempFile, "updated"); + + // Assert + waitTask.Should().BeCompleteWithin(WatchTimeout); + } + finally + { + File.Delete(tempFile); + } + } + + [TestMethod] + public async Task FileSystemCloudAssetWatchService_WaitForChangeAsync_UnblocksOnFileCreationAsync() + { + // Arrange + string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + string stubPath = Path.Combine(tempDir, "stub.json"); + + try + { + FileSystemCloudAssetWatchService sut = MakeSut(stubPath); + await sut.StartAsync(CancellationToken.None); + + Task waitTask = sut.WaitForChangeAsync(CancellationToken.None); + + // Act — create the file + await Task.Delay(50); + await File.WriteAllTextAsync(stubPath, "{}"); + + // Assert + waitTask.Should().BeCompleteWithin(WatchTimeout); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [TestMethod] + public async Task FileSystemCloudAssetWatchService_WaitForChangeAsync_RapidEventsCollapseToOneNotificationAsync() + { + // Arrange + string tempFile = Path.GetTempFileName(); + try + { + FileSystemCloudAssetWatchService sut = MakeSut(tempFile); + await sut.StartAsync(CancellationToken.None); + + // Act — trigger two rapid writes + await Task.Delay(50); + await File.WriteAllTextAsync(tempFile, "write1"); + await Task.Delay(10); + await File.WriteAllTextAsync(tempFile, "write2"); + + // First WaitForChangeAsync should complete + Task firstWait = sut.WaitForChangeAsync(CancellationToken.None); + firstWait.Should().BeCompleteWithin(WatchTimeout); + + // Second call should block because rapid events collapsed to one notification + using CancellationTokenSource cts = new(TimeSpan.FromMilliseconds(200)); + Task secondWait = sut.WaitForChangeAsync(cts.Token); + await secondWait.Awaiting(t => t).Should().ThrowAsync(); + } + finally + { + File.Delete(tempFile); + } + } + + [TestMethod] + public async Task FileSystemCloudAssetWatchService_WaitForChangeAsync_RespectsCancellationAsync() + { + // Arrange + string tempFile = Path.GetTempFileName(); + try + { + FileSystemCloudAssetWatchService sut = MakeSut(tempFile); + await sut.StartAsync(CancellationToken.None); + + using CancellationTokenSource cts = new(TimeSpan.FromMilliseconds(100)); + + // Act + Task waitTask = sut.WaitForChangeAsync(cts.Token); + + // Assert + await waitTask.Awaiting(t => t).Should().ThrowAsync(); + } + finally + { + File.Delete(tempFile); + } + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/IdleDetectorTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/IdleDetectorTests.cs index 45c2a4dd..b300edb9 100644 --- a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/IdleDetectorTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/IdleDetectorTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using AdaptiveRemote.Services.IdleDetection; using FluentAssertions; namespace AdaptiveRemote.Services.CloudAssets; @@ -8,12 +9,67 @@ public class IdleDetectorTests { private readonly MockOptions MockOptions = new(); + [TestMethod] + public void WaitForIdleAsync_Waits_WhenNoScopedDetector() + { + // Arrange + FakeUserActivityDetector fake = new(DateTime.MinValue); + IdleDetector sut = new(); + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(0)); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); + + // Act + Task resultTask = sut.WaitForIdleAsync(cts.Token); + + // Assert + resultTask.Should().NotBeComplete(); + } + + [TestMethod] + public void WaitForIdleAsync_Completes_WhenScopedDetectorInitialized() + { + // Arrange + FakeUserActivityDetector fake = new(DateTime.MinValue); + IdleDetector sut = new(); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); + Task resultTask = sut.WaitForIdleAsync(cts.Token); + + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(0)); + + // Act + scoped.InitializeAsync(null!, default); + + // Assert + resultTask.Should().BeComplete(); + } + + [TestMethod] + public void WaitForIdleAsync_Waits_WhenScopedHasBeenCleanedUp() + { + // Arrange + FakeUserActivityDetector fake = new(DateTime.MinValue); + IdleDetector sut = new(); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); + + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(0)); + scoped.InitializeAsync(null!, default); + + // Act + scoped.CleanUpAsync(null!, default); + Task resultTask = sut.WaitForIdleAsync(cts.Token); + + // Assert + resultTask.Should().NotBeComplete(because: "the scope is being recycled which should be considered non-idle"); + } + [TestMethod] public void WaitForIdleAsync_Completes_WhenNoActivity() { // Arrange FakeUserActivityDetector fake = new(DateTime.MinValue); - IdleDetector sut = new(new[] { fake }, new FakeOptions(0)); + IdleDetector sut = new(); + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(0)); + scoped.InitializeAsync(null!, default); CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); // Act @@ -30,8 +86,10 @@ public void WaitForIdleAsync_WaitsForCooldown() DateTime now = DateTime.Now; FakeUserActivityDetector fake = new(now); int cooldown = 1; // seconds - IdleDetector sut = new(new[] { fake }, new FakeOptions(cooldown)); + IdleDetector sut = new(); + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(cooldown)); CancellationTokenSource cts = new(TimeSpan.FromSeconds(5)); + scoped.InitializeAsync(null!, default); Stopwatch sw = System.Diagnostics.Stopwatch.StartNew(); // Act @@ -47,7 +105,9 @@ public void WaitForIdleAsync_Cancels_IfTokenCancelled() { // Arrange FakeUserActivityDetector fake = new(DateTime.Now); - IdleDetector sut = new(new[] { fake }, new FakeOptions(10)); + IdleDetector sut = new(); + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(10)); + scoped.InitializeAsync(null!, default); CancellationTokenSource cts = new(); Task task = sut.WaitForIdleAsync(cts.Token); @@ -63,7 +123,9 @@ public void WaitForIdleAsync_Throws_IfDetectorThrows() { // Arrange ThrowingUserActivityDetector fake = new(); - IdleDetector sut = new(new[] { fake }, new FakeOptions(1)); + IdleDetector sut = new(); + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(1)); + scoped.InitializeAsync(null!, default); CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); // Act @@ -77,7 +139,9 @@ public void WaitForIdleAsync_Throws_IfDetectorThrows() public void WaitForIdleAsync_Handles_EmptyDetectorList() { // Arrange - IdleDetector sut = new(new List(), new FakeOptions(0)); + IdleDetector sut = new(); + IdleDetector.ScopedIdleDetector scoped = new(new List(), sut, new FakeOptions(0)); + scoped.InitializeAsync(null!, default); CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); // Act diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs index c65237f9..01cae398 100644 --- a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using AdaptiveRemote.Models.CloudAssets; using FluentAssertions; namespace AdaptiveRemote.Services.CloudAssets; diff --git a/test/AdaptiveRemote.App.Tests/Services/MvvmPropertyIdleAdapterTests.cs b/test/AdaptiveRemote.App.Tests/Services/MvvmPropertyIdleAdapterTests.cs index a4a13dfb..1381db56 100644 --- a/test/AdaptiveRemote.App.Tests/Services/MvvmPropertyIdleAdapterTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/MvvmPropertyIdleAdapterTests.cs @@ -113,13 +113,13 @@ internal bool IsActive } } - private sealed class TestIdleAdapter : MvvmPropertyIdleAdapter + private sealed class TestIdleAdapter : MvvmPropertyActivityDetector { public TestIdleAdapter(TestTarget target) : base(target, TestTarget.IsActiveProperty) { } } - private sealed class BrokenIdleAdapter : MvvmPropertyIdleAdapter + private sealed class BrokenIdleAdapter : MvvmPropertyActivityDetector { public BrokenIdleAdapter(TestTarget target) : base(target, null!) { } diff --git a/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs b/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs index ccace17b..167204a7 100644 --- a/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs +++ b/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs @@ -31,10 +31,16 @@ exception is AssertInconclusiveException || } string message = $"{logLevel}[{eventId.Id}]: {formatter(state, exception)}"; + if (exception is not null) + { + message += $"\n {exception.GetType().Name}: {exception.Message}"; + } + foreach ((string find, string replace) in ReplaceStrings) { message = message.Replace(find, replace); } + lock (_lock) { _messages.Add(message); diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/CloudLayoutUpdate.feature b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/CloudLayoutUpdate.feature new file mode 100644 index 00000000..898bc4d7 --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/CloudLayoutUpdate.feature @@ -0,0 +1,118 @@ +Feature: Cloud layout update + The application loads the remote control layout from a cloud asset (compiled layout service). + The layout is cached locally. On startup, the cache is checked first; if present + the app becomes Ready immediately while a background refresh runs. If the server + returns a different layout after a scope recycle, the UI updates. If the compiled + layout service changes while the app is running, the layout reloads and an idle-deferred recycle occurs. + +@cloud-layout +Scenario: App starts successfully from compiled layout service when cache is empty + Given the application is not running + And the local layout cache is empty + And the compiled layout service serves the primary layout + When I start the application + Then I should see the application in the Ready phase + And I should see a message in the logs: + """ + Downloading asset 'layout' + """ + And I should not see the Guide button + And I should not see any error messages in the logs + +@cloud-layout +Scenario: App starts successfully from local cache when cache is present + Given the application is not running + And the local layout cache contains the primary layout + And the compiled layout service serves the primary layout + When I start the application + Then I should see the application in the Ready phase + And I should see a message in the logs: + """ + Loaded asset 'layout' from cache + """ + And I should see a message in the logs: + """ + Asset 'layout' is up to date + """ + And I should not see the Guide button + And I should not see any error messages in the logs + +@cloud-layout +Scenario: App shows updated layout after background refresh detects server change + Given the application is not running + And the local layout cache contains the primary layout + And the compiled layout service serves the updated layout + And the idle cooldown is 2 seconds + When I start the application + Then I should see the application in the Ready phase + And I should see a message in the logs: + """ + Loaded asset 'layout' from cache + """ + And I should see a message in the logs: + """ + Asset 'layout' updated from server; scheduling recycle + """ + And I should see the application recycle + And I should see the "Guide" button exists + And I should not see any error messages in the logs + +@cloud-layout +Scenario: App shows no update when cached layout matches server layout + Given the application is not running + And the local layout cache contains the primary layout + And the compiled layout service serves the primary layout + When I start the application + Then I should see the application in the Ready phase + And I should see a message in the logs: + """ + Asset 'layout' is up to date + """ + And I should not see a message containing 'recycling' in the logs + And I should not see the Guide button + And I should not see any error messages in the logs + +@cloud-layout +Scenario: Layout updates after compiled layout service changes while app is running + Given the application is not running + And the local layout cache contains the primary layout + And the compiled layout service serves the primary layout + When I start the application + Then I should see the application in the Ready phase + And I should not see the Guide button + When the compiled layout service is updated to the updated layout + Then I should see a message in the logs: + """ + Layout service reported a change; re-downloading asset 'layout' + """ + And I should see the application recycle + And I should see the application in the Ready phase + And I should see the "Guide" button exists + And I should not see any error messages in the logs + +@cloud-layout +Scenario: App enters fatal error state when cache is empty and compiled layout service is unavailable + Given the application is not running + And the local layout cache is empty + And the compiled layout service is unavailable + When I start the application + Then I should see a fatal startup error message + +@cloud-layout +Scenario: App continues with cached layout when background server download fails + Given the application is not running + And the local layout cache contains the primary layout + And the compiled layout service is unavailable + When I start the application + Then I should see the application in the Ready phase + And I should see a message in the logs: + """ + Loaded asset 'layout' from cache + """ + And I should see a message in the logs: + """ + Failed to download latest 'layout' from server; keeping cached version + """ + And I should not see the Guide button + And I should not see any error messages in the logs + diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/ConversationModalUI.feature b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/ConversationModalUI.feature index 72b56596..ad15bc86 100644 --- a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/ConversationModalUI.feature +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/ConversationModalUI.feature @@ -5,6 +5,7 @@ Feature: Conversation Modal UI Scenario: Speech synthesis displays modal message box Given the application is in the Ready phase + Then I should not see a modal message When I say "Hey Remote" Then I should see a modal message containing """ diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature index 27d058e9..34480200 100644 --- a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature @@ -23,7 +23,6 @@ Scenario: All expected buttons from layout are present # WELL group And I should see the 'TiVo' button exists And I should see the 'Netflix' button exists - And I should see the 'Guide' button exists And I should see the 'Info' button exists # PLAYBACK group And I should see the 'Play' button exists diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/CloudLayoutSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/CloudLayoutSteps.cs new file mode 100644 index 00000000..c00ff86a --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/CloudLayoutSteps.cs @@ -0,0 +1,110 @@ +using AdaptiveRemote.EndtoEndTests; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Reqnroll; + +namespace AdaptiveRemote.EndToEndTests.Steps; + +[Binding] +public class CloudLayoutSteps : StepsBase +{ + private static readonly string PrimaryLayoutPath = Path.Combine( + Path.GetDirectoryName(typeof(CloudLayoutSteps).Assembly.Location)!, + "Layout", "primary-layout.json"); + + private static readonly string UpdatedLayoutPath = Path.Combine( + Path.GetDirectoryName(typeof(CloudLayoutSteps).Assembly.Location)!, + "Layout", "updated-layout.json"); + + private string CachePath => Environment.CloudCachePath + ?? throw new InvalidOperationException("CloudCachePath is not configured."); + + private string StubFilePath => Environment.CloudStubFilePath + ?? throw new InvalidOperationException("CloudStubFilePath is not configured."); + + [Given(@"the local layout cache is empty")] + public void GivenTheLocalLayoutCacheIsEmpty() + { + if (Directory.Exists(CachePath)) + { + foreach (string file in Directory.GetFiles(CachePath)) + { + File.Delete(file); + } + } + } + + [Given(@"the local layout cache contains the primary layout")] + public void GivenTheLocalLayoutCacheContainsThePrimaryLayout() + { + WriteToCacheFile(PrimaryLayoutPath); + } + + [Given(@"the local layout cache contains the updated layout")] + public void GivenTheLocalLayoutCacheContainsTheUpdatedLayout() + { + WriteToCacheFile(UpdatedLayoutPath); + } + + [Given(@"the compiled layout service serves the primary layout")] + public void GivenTheCompiledLayoutServiceServesThePrimaryLayout() + { + File.Copy(PrimaryLayoutPath, StubFilePath, overwrite: true); + } + + [Given(@"the compiled layout service serves the updated layout")] + public void GivenTheCompiledLayoutServiceServesTheUpdatedLayout() + { + File.Copy(UpdatedLayoutPath, StubFilePath, overwrite: true); + } + + [Given(@"the compiled layout service is unavailable")] + public void GivenTheCompiledLayoutServiceIsUnavailable() + { + if (File.Exists(StubFilePath)) + { + File.Delete(StubFilePath); + } + } + + [Given(@"the idle cooldown is (\d+) seconds?")] + public void GivenTheIdleCooldownIsSeconds(int seconds) + { + Environment.SetIdleCooldownSeconds(seconds); + } + + [When(@"the compiled layout service is updated to the updated layout")] + public void WhenTheCompiledLayoutServiceIsUpdatedToTheUpdatedLayout() + { + File.Copy(UpdatedLayoutPath, StubFilePath, overwrite: true); + } + + [Then(@"I should not see the Guide button")] + public void ThenIShouldNotSeeTheGuideButton() + { + bool exists = Host.UI.WaitForButtonExists("Guide", TimeSpan.FromSeconds(2)); + Assert.IsFalse(exists, "Guide button should not be visible in the primary layout."); + } + + [Then(@"I should see a fatal startup error message")] + public void ThenIShouldSeeAFatalStartupErrorMessage() + { + Host.Application.WaitForPhase(LifecyclePhase.FatalError, timeout: TimeSpan.FromSeconds(60)); + } + + // Stop the host after cloud-layout scenarios so fatal-error or incomplete states don't leak + // into subsequent tests that reuse the running host. + [AfterScenario("cloud-layout")] + public void AfterScenario_StopHostAfterCloudLayoutTest() + { + Environment.StopHostIfRunning(); + } + + private void WriteToCacheFile(string sourceFixturePath) + { + Directory.CreateDirectory(CachePath); + string cacheFile = Path.Combine(CachePath, "layout.cache"); + File.Copy(sourceFixturePath, cacheFile, overwrite: true); + } +} + diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/Hooks/EnvironmentSetupHooks.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Hooks/EnvironmentSetupHooks.cs index 97a118d9..3319a919 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/Hooks/EnvironmentSetupHooks.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Hooks/EnvironmentSetupHooks.cs @@ -4,6 +4,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Reqnroll; using Reqnroll.BoDi; +using System.Reflection; namespace AdaptiveRemote.EndToEndTests.Steps.Hooks; @@ -18,7 +19,21 @@ public static void OnBeforeTestRun_SetUpEnvironment(IObjectContainer container, { _lazyLogger = new Lazy(() => loggerFactory.CreateLogger()); - container.RegisterInstanceAs(_startedEnvironment ??= container.Resolve()); + _startedEnvironment ??= container.Resolve(); + container.RegisterInstanceAs(_startedEnvironment); + + // Configure cloud asset paths once for the entire test run. + string assemblyDir = Path.GetDirectoryName(typeof(EnvironmentSetupHooks).Assembly.Location)!; + string testRunTempDir = Path.Combine(assemblyDir, "cloud-test-run"); + string cachePath = Path.Combine(testRunTempDir, "cache"); + string stubFilePath = Path.Combine(testRunTempDir, "stub.json"); + + Directory.CreateDirectory(testRunTempDir); + // Write the full layout so non-cloud scenarios see the complete button set. + string updatedLayout = File.ReadAllText(Path.Combine(assemblyDir, "Layout", "updated-layout.json")); + File.WriteAllText(stubFilePath, updatedLayout); + + _startedEnvironment.SetCloudAssetPaths(cachePath, stubFilePath); } [AfterTestRun] diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/LogVerificationSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/LogVerificationSteps.cs index c3eaab79..07998dd1 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/LogVerificationSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/LogVerificationSteps.cs @@ -11,6 +11,7 @@ public class LogVerificationSteps : StepsBase private static readonly Dictionary _lastLineRead = new(); [Then("I should not see any warning or error messages in the logs")] + [Then("I should not see any warnings or errors in the logs")] public void ThenIShouldNotSeeAnyWarningsOrErrorsInTheLogFile() { IEnumerable warningAndErrorLines = FilterLogLines(IsWarningOrError); @@ -52,6 +53,64 @@ public void ThenIShouldSeeAnErrorInTheLogs(string expectedErrorMessage) "Host log error message does not match the expected text"); } + [Then("I should see a message in the logs:")] + public void ThenIShouldSeeAMessageInTheLogs(string expectedMessage) + { + string? matchingLine = null; + + WaitHelpers.ExecuteWithRetries(() => + { + Assert.IsNotNull(Environment.HostLogs, "Host log path was not set."); + + using Stream logStream = File.Open(Environment.HostLogs, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using StreamReader reader = new(logStream); + string logContent = reader.ReadToEnd(); + string[] logLines = logContent.Split(System.Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + matchingLine = logLines.FirstOrDefault(l => l.Contains(expectedMessage, StringComparison.Ordinal)); + return matchingLine is not null; + }); + + Assert.IsNotNull(matchingLine, "Host log does not contain the expected message: {0}", expectedMessage); + } + + [Then(@"I should not see a message containing '(.*)' in the logs")] + public void ThenIShouldNotSeeAMessageContainingInTheLogs(string fragment) + { + Assert.IsNotNull(Environment.HostLogs, "Host log path was not set."); + + string logContent; + using (Stream logStream = File.Open(Environment.HostLogs, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + logContent = new StreamReader(logStream).ReadToEnd(); + } + + string[] logLines = logContent.Split(System.Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + IEnumerable matching = logLines.Where(l => l.Contains(fragment, StringComparison.OrdinalIgnoreCase)); + Assert.IsFalse( + matching.Any(), + "Host log unexpectedly contains '{0}':\n{1}", + fragment, + string.Join("\n", matching)); + } + + [Then(@"I should see the application recycle")] + public void ThenIShouldSeeTheApplicationRecycle() + { + Assert.IsNotNull(Environment.HostLogs, "Host log path was not set."); + + string? matchingLine = null; + WaitHelpers.ExecuteWithRetries(() => + { + using Stream logStream = File.Open(Environment.HostLogs, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + string logContent = new StreamReader(logStream).ReadToEnd(); + string[] logLines = logContent.Split(System.Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + matchingLine = logLines.FirstOrDefault(l => l.Contains("Recycling application scope", StringComparison.Ordinal)); + return matchingLine is not null; + }); + + Assert.IsNotNull(matchingLine, "Host log does not contain 'Recycling application scope'."); + } + private IEnumerable FilterLogLines(Func lineFilter) { Assert.IsNotNull(Environment.HostLogs, "Host log path was not set."); @@ -97,3 +156,4 @@ private static bool IsWarningOrError(string line) || line.Contains("] Warning [", StringComparison.Ordinal); } } + diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/AdaptiveRemote.EndtoEndTests.TestServices.csproj b/test/AdaptiveRemote.EndtoEndTests.TestServices/AdaptiveRemote.EndtoEndTests.TestServices.csproj index a5241338..849d25d4 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/AdaptiveRemote.EndtoEndTests.TestServices.csproj +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/AdaptiveRemote.EndtoEndTests.TestServices.csproj @@ -7,6 +7,12 @@ AdaptiveRemote.EndtoEndTests + + + PreserveNewest + + + diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/ApplicationTestService.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/ApplicationTestService.cs index 2fdb8b12..8a9afcd5 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/ApplicationTestService.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/ApplicationTestService.cs @@ -1,5 +1,6 @@ using AdaptiveRemote.Models; using AdaptiveRemote.Services.Testing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace AdaptiveRemote.EndtoEndTests; @@ -11,16 +12,21 @@ namespace AdaptiveRemote.EndtoEndTests; /// public class ApplicationTestService : IApplicationTestService { - private readonly Services.IRemoteDefinitionService _remoteDefinitionService; + private readonly IServiceProvider _serviceProvider; private readonly LifecycleView _lifecycleView; private readonly IHostApplicationLifetime _applicationLifetime; + // Resolved lazily so that GetCurrentPhaseAsync works in FatalError state, + // where the layout asset is absent and IRemoteDefinitionService cannot be built. + private Services.IRemoteDefinitionService RemoteDefinitionService + => _serviceProvider.GetRequiredService(); + public ApplicationTestService( - Services.IRemoteDefinitionService remoteDefinitionService, + IServiceProvider serviceProvider, LifecycleView lifecycleView, IHostApplicationLifetime applicationLifetime) { - _remoteDefinitionService = remoteDefinitionService; + _serviceProvider = serviceProvider; _lifecycleView = lifecycleView; _applicationLifetime = applicationLifetime; } @@ -28,7 +34,7 @@ public ApplicationTestService( public async Task InvokeCommandAsync(string commandName, CancellationToken cancellationToken) { // Find the Exit command by walking the remote tree - Command command = FindCommandByName(_remoteDefinitionService.RemoteRoot, commandName) + Command command = FindCommandByName(RemoteDefinitionService.RemoteRoot, commandName) ?? throw new InvalidOperationException($"{commandName} command not found in remote definition service"); if (command.ExecuteAsync is null) @@ -51,7 +57,7 @@ public async Task StopApplicationAsync(CancellationToken cancellationToken) public Task GetIsListeningAsync(CancellationToken cancellationToken) { // Find the ConversationView by walking the remote tree - ConversationView? conversationView = FindConversationView(_remoteDefinitionService.RemoteRoot) + ConversationView? conversationView = FindConversationView(RemoteDefinitionService.RemoteRoot) ?? throw new InvalidOperationException("ConversationView not found in remote definition service"); // Use GetValue to access the internal property diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs index 4a216938..55f25d0c 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs @@ -34,4 +34,20 @@ public interface ISimulatedEnvironment : IDisposable /// Commands not present in this dictionary are not programmed and should be disabled. /// IReadOnlyDictionary TestIrPayloads { get; } + + /// + /// Gets the cloud asset cache directory path configured for the current test run, or null if not configured. + /// + string? CloudCachePath { get; } + + /// + /// Gets the stub layout file path configured for the current test run, or null if not configured. + /// + string? CloudStubFilePath { get; } + + /// + /// Overrides the idle cooldown for the next host start. + /// Appends a command-line arg that supersedes the default configured in SetCloudAssetPaths. + /// + void SetIdleCooldownSeconds(int seconds); } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs index 68d170a4..b6597130 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs @@ -31,6 +31,8 @@ public sealed class SimulatedEnvironment : ISimulatedEnvironment private string? _currentLogLocation; // Settings file path is determined lazily from the TestResults directory when SetLogLocation is first called. private string? _testSettingsPath; + private string? _cloudCachePath; + private string? _cloudStubFilePath; public SimulatedEnvironment(SimulatedTiVoDeviceBuilder tivoBuilder, SimulatedBroadlinkDeviceBuilder broadlinkBuilder, AdaptiveRemoteHost.Builder hostBuilder) { @@ -68,6 +70,12 @@ public SimulatedEnvironment(SimulatedTiVoDeviceBuilder tivoBuilder, SimulatedBro /// public IReadOnlyDictionary TestIrPayloads => _testIrPayloads; + /// + public string? CloudCachePath => _cloudCachePath; + + /// + public string? CloudStubFilePath => _cloudStubFilePath; + public AdaptiveRemoteHost Host { get @@ -146,6 +154,27 @@ public void StopHostIfRunning() } } + public void SetCloudAssetPaths(string cachePath, string stubFilePath) + { + _cloudCachePath = cachePath; + _cloudStubFilePath = stubFilePath; + + _hostBuilder.ConfigureSettings(s => s.AddCommandLineArgs( + $"--cloud:CachePath=\"{cachePath}\" --cloud:StubFilePath=\"{stubFilePath}\" --cloud:IdleCooldownSeconds=0")); + } + + public void SetIdleCooldownSeconds(int seconds) + { + if (seconds < 0) + { + throw new ArgumentOutOfRangeException(nameof(seconds), seconds, "Idle cooldown must be non-negative."); + } + + // Appends the arg; the configuration system uses last-wins for duplicate keys, + // so this overrides the value set in SetCloudAssetPaths. + _hostBuilder.ConfigureSettings(s => s.AddCommandLineArgs($"--cloud:IdleCooldownSeconds={seconds}")); + } + public void SetLogLocation(string logLocation) { // Ensure settings file is created (adds --programmatic arg) before adding log arg diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/IApplicationTestServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/IApplicationTestServiceExtensions.cs index 9d3e5a2f..802367c9 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IApplicationTestServiceExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IApplicationTestServiceExtensions.cs @@ -11,7 +11,7 @@ public static void WaitForPhase(this IApplicationTestService testService, Lifecy bool result = WaitHelpers.ExecuteWithRetries(() => { currentPhase = testService.GetCurrentPhase(); - return currentPhase >= expectedPhase; + return currentPhase == expectedPhase || currentPhase == LifecyclePhase.FatalError; }, timeout); currentPhase.Should().Be(expectedPhase, diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/primary-layout.json b/test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/primary-layout.json new file mode 100644 index 00000000..e925bf7c --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/primary-layout.json @@ -0,0 +1,57 @@ +{ + "id": "00000000-0000-0000-0000-000000000001", + "rawLayoutId": "00000000-0000-0000-0000-000000000001", + "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": "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.EndtoEndTests.TestServices/Layout/updated-layout.json b/test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/updated-layout.json new file mode 100644 index 00000000..d6fd0566 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/updated-layout.json @@ -0,0 +1,58 @@ +{ + "id": "00000000-0000-0000-0000-000000000002", + "rawLayoutId": "00000000-0000-0000-0000-000000000002", + "userId": "stub", + "isActive": true, + "version": 2, + "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" +}