From b421cde6627c7b362c854cf3f529921fe7631c8f Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Thu, 9 Apr 2026 11:50:26 -0700 Subject: [PATCH 01/14] [ADR-162] Spec on using layout from the backend service in the client application --- .claude/commands/spec.md | 27 +- .../Layout/_spec_LayoutConsumption.md | 989 ++++++++++++++++++ src/_spec_LayoutCustomizationService.md | 2 +- 3 files changed, 1009 insertions(+), 9 deletions(-) create mode 100644 src/AdaptiveRemote.App/Services/Layout/_spec_LayoutConsumption.md diff --git a/.claude/commands/spec.md b/.claude/commands/spec.md index c236589c..48d66542 100644 --- a/.claude/commands/spec.md +++ b/.claude/commands/spec.md @@ -72,14 +72,18 @@ When the user signals they're ready: 1. Re-read the spec file with the Read tool. 2. Collect all `> **Review:** ...` markers and note any direct edits. -3. Address review comments **one at a time** in document order: - a. Present your analysis of the comment — the trade-offs, your - recommendation, and why. - b. **PAUSE — wait for the user's decision before editing.** - c. Update the spec to reflect the resolved decision; remove the - review marker. - d. Tell the user what changed, then move to the next comment. -4. After all comments are resolved, invite another review pass. +3. Present the **first unresolved** review comment only: + - State the comment and its location. + - Give your analysis — trade-offs, recommendation, and why. + **PAUSE — wait for the user's decision before doing anything else.** +4. Once the user decides: + a. Update the spec to reflect the decision; remove the review marker. + b. Tell the user what changed. + c. Present the **next** unresolved review comment (go to step 3). +5. After all comments are resolved, invite another review pass. + +**Never present more than one review comment at a time. Always pause for a +decision before moving to the next comment or making any edits.** Repeat Phase 3 until the user says the document is ready. @@ -119,6 +123,13 @@ When Phase 4 is complete: - Exit criteria as a checkbox list; for tasks that include new E2E tests, write those exit criteria as Gherkin-style acceptance scenarios (`Given / When / Then`) + + **Implementation ordering:** Order tasks so that primary consumers are + implemented before the dependencies they rely on. For each dependency that + isn't ready yet, introduce a stub that provides known-good static data + (e.g. hardcoded or serialized from the current implementation). Replace + stubs with real implementations one step at a time. Every task's exit + criteria must include: all existing unit and E2E tests pass. 3. If the spec has a `## Related Epics` section listing features to be spec'd separately, add those as placeholder entries in `## Tasks` as well — titled "Create epic: \" with a one-line scope description. diff --git a/src/AdaptiveRemote.App/Services/Layout/_spec_LayoutConsumption.md b/src/AdaptiveRemote.App/Services/Layout/_spec_LayoutConsumption.md new file mode 100644 index 00000000..368bdf0b --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Layout/_spec_LayoutConsumption.md @@ -0,0 +1,989 @@ +# Client-Side Layout Consumption + +> **Status:** Draft +> **Will become:** `_doc_LayoutConsumption.md` once implementation is complete + +## Overview + +This feature enables the AdaptiveRemote client to download its compiled remote control layout +from the backend `CompiledLayoutService`, cache it locally, and apply it at startup. It +replaces the static hardcoded `StaticCommandGroupProvider` entirely. When the backend +publishes a new layout, the client downloads it and automatically applies it the next time +the user is idle. The downloaded `CompiledLayout` (defined in `AdaptiveRemote.Contracts`) +is mapped to the runtime `Command` and `LayoutGroup` types used by all other subsystems. +CSS from the compiled layout is injected into the Blazor page as a ` +} +``` +This runs once per scope lifetime; the style is replaced cleanly on every scope recycle. + +## Open Questions + +- [X] ~~**`CommandType.Action` runtime type:** Resolved: `CommandType.Action` is removed + from the backend spec. That was an error in that spec.~~ + +- [X] ~~**SSE reconnect policy:** Resolved: `System.Net.ServerSentEvents` for parsing; + manual exponential backoff loop (5 s → 2 min cap, jitter) in `CloudAssetWatchService`; + silent reconnect with warning logging; error logged after `CloudSettings.SseMaxConsecutiveFailures` + consecutive failures (default 10). No user notification on disconnect.~~ + +## Tasks + +### 1. [ADR-175](https://jodasoft.atlassian.net/browse/ADR-175) — Interfaces, CloudAssetStore, pass-through RemoteLayoutDefinitionService, and stub orchestrator + +Replace `StaticCommandGroupProvider` with a minimal store + pass-through definition service +backed by a stub orchestrator that inlines the same hardcoded layout. No DTO mapping or +parsing yet; the store holds runtime types directly. + +- [ ] All interfaces defined: `ICloudAsset`, `ICloudAsset`, `ICloudAssetStore`, + `ICloudAssetCache`, `ICloudAssetDownloader`, `IPreScopeInitializer` +- [ ] `CloudSettings` registered as `IOptions` +- [ ] `CloudAssetStore` implemented as thread-safe singleton +- [ ] **Stub `CloudAssetOrchestrator`**: inlines the same hardcoded commands as + `StaticCommandGroupProvider` (GUTTER included); stores the `LayoutGroup` root directly in + `CloudAssetStore`; immediately signals `IPreScopeInitializer` complete; no file I/O, no HTTP +- [ ] `StaticCommandGroupProvider` removed +- [ ] `RemoteLayoutDefinitionService` v1: reads `LayoutGroup` directly from + `ICloudAssetStore.Get("layout")`; returns it as `RemoteRoot`; no DTO mapping +- [ ] `RemoteLayoutDefinitionService` registered as sole `IRemoteDefinitionService` +- [ ] `ApplicationLifecycle` awaits `IPreScopeInitializer` before calling `InvokeInScopeAsync` + (single-iteration; recycle loop comes in Task 5) +- [ ] Unit tests: `CloudAssetStore.Get` throws on missing key and on wrong type; + `Set` + `Get` round-trips correctly; `RemoteLayoutDefinitionService` returns store + contents unchanged; empty store throws with a descriptive message +- [ ] All existing unit and E2E tests pass + +--- + +### 2. [ADR-176](https://jodasoft.atlassian.net/browse/ADR-176) — CSS extraction and stub IDynamicStylesheetProvider + +Wire the Blazor root to consume CSS from `IDynamicStylesheetProvider`, backed by a stub +that returns the current static grid CSS extracted from `app.css`. + +- [ ] `IDynamicStylesheetProvider` interface defined in `Services/Layout/` +- [ ] Grid CSS extracted from `app.css` into a standalone resource; `LayoutStylesheetProvider` + v1 returns this content as a static string +- [ ] Blazor root wired to render `` from + `IDynamicStylesheetProvider` +- [ ] `app.css` no longer contains the extracted grid CSS +- [ ] Unit tests: `LayoutStylesheetProvider.GetCss()` returns non-null content +- [ ] All existing unit and E2E tests pass + +--- + +### 3. [ADR-177](https://jodasoft.atlassian.net/browse/ADR-177) — CompiledLayout DTO and DTO-to-runtime mapping + +Switch the stub orchestrator to push a `CompiledLayout` DTO and implement full DTO-to-runtime +mapping in `RemoteLayoutDefinitionService`. + +- [ ] Stub orchestrator updated: constructs a `CompiledLayout` object in code representing + the same hardcoded layout, without GUTTER (GUTTER is now appended by the definition service) +- [ ] `RemoteLayoutDefinitionService` v2: reads `CompiledLayout` from + `ICloudAssetStore.Get("layout")`; maps element tree per the DTO mapping + table; appends GUTTER; throws descriptive error if store is empty +- [ ] DI registration updated: + `services.AddScoped(sp => sp.GetRequiredService().Get("layout"))` +- [ ] Unit tests: each `CommandType` maps to the correct runtime type; GUTTER always appended + as last root child; unknown `CommandType` throws; empty store throws with descriptive message +- [ ] All existing unit and E2E tests pass + +--- + +### 4. [ADR-178](https://jodasoft.atlassian.net/browse/ADR-178) — JSON parsing, BasicCloudAsset\/JsonCloudAsset\, and stub file-based downloader + +Introduce the asset abstraction and replace in-code `CompiledLayout` construction with JSON +deserialization from a file, so the stub orchestrator exercises the same parse path as the +real orchestrator will. + +- [ ] `BasicCloudAsset` (abstract `ICloudAsset`) and `JsonCloudAsset` (concrete, + configurable `JsonSerializerContext`) implemented +- [ ] `JsonCloudAsset` registered as `ICloudAsset` with layout name, URLs, + and `LayoutContractsJsonContext` +- [ ] **Stub `FileCloudAssetDownloader`**: implements `ICloudAssetDownloader`; reads a stream + from a configured path on disk; `GetByIdAsync` returns null +- [ ] A sample `layout.json` (serialized `CompiledLayout`) checked in for development use +- [ ] Stub orchestrator updated: iterates `IEnumerable`; for each, calls + `FileCloudAssetDownloader.GetActiveAsync` → `asset.ParseAsync` → `CloudAssetStore.Set` +- [ ] Unit tests: `JsonCloudAsset.ParseAsync` correctly deserializes a `CompiledLayout`; + `FileCloudAssetDownloader` returns a stream for the configured path; returns null when + file is absent +- [ ] All existing unit and E2E tests pass + +--- + +### 5. [ADR-179](https://jodasoft.atlassian.net/browse/ADR-179) — ApplicationLifecycle recycle loop and BlazorAppScope.RecycleAsync + +Convert `ApplicationLifecycle.ExecuteAsync` to a recycle loop and implement +`BlazorAppScope.RecycleAsync()`. + +- [ ] `IApplicationRecycleSignal` and `ApplicationRecycleSignal` added to `Services/Lifecycle/` +- [ ] `ApplicationLifecycle.ExecuteAsync` refactored to `while` loop; linked token from + `stoppingToken + signal.Token` passed into scope work item +- [ ] `ApplicationLifecycle` awaits all `IPreScopeInitializer.WaitAsync(ct)` before the + first scope; not re-awaited on subsequent loop iterations +- [ ] Steady-state path: signal fires during wait → cleanup → `RecycleScopeAsync` → + `signal.Reset()` → loop +- [ ] Init-phase path: signal fires during `InitializeAllAsync` → cancel → cleanup → + `signal.Reset()` → loop without `RecycleScopeAsync` +- [ ] `BlazorAppScope.RecycleAsync()` implemented via + `IJSRuntime.InvokeVoidAsync("location.reload")` +- [ ] Unit tests: loop iterates on recycle signal; loop exits cleanly on `stoppingToken`; + second signal during init cancels init and retries without an additional `RecycleScopeAsync` + call; first scope creation waits for `IPreScopeInitializer`; recycles do not re-await + `IPreScopeInitializer` +- [ ] `_doc_Lifecycle.md` updated +- [ ] All existing unit and E2E tests pass + +--- + +### 6. [ADR-180](https://jodasoft.atlassian.net/browse/ADR-180) — Idle detection + +Introduce `IIdleDetector`, its implementation, and the three ViewModel adapter services. + +- [ ] `IIdleDetector` interface defined in `Services/CloudAssets/` +- [ ] `IdleDetector` implements token-based non-idle tracking with cooldown timer +- [ ] `ConversationIdleAdapter`, `ProgrammingModeIdleAdapter`, `CommandExecutionIdleAdapter` + implemented as scoped `IScopedLifecycle` services +- [ ] `IdleCooldownSeconds` sourced from `CloudSettings` +- [ ] Unit tests: `IsIdle` is false while any token is held; cooldown starts when last token + is disposed; `BecameIdle` fires after cooldown; new `StartNonIdle()` during cooldown resets + the timer; adapters hold/release token in response to ViewModel property changes +- [ ] All existing unit and E2E tests pass + +--- + +### 7. [ADR-181](https://jodasoft.atlassian.net/browse/ADR-181) — Real CloudAssetOrchestrator, CloudAssetCache, FileSystemCloudAssetWatchService, and file-based E2E tests + +Replace the stub orchestrator with the real `CloudAssetOrchestrator` backed by the real file +cache and stub file downloader; introduce `FileSystemCloudAssetWatchService` so the full +update path is exercisable and all startup/update scenarios are covered by E2E tests without +any backend dependency. + +- [ ] Real `CloudAssetOrchestrator` (singleton `BackgroundService` + `IPreScopeInitializer`): + loads each `ICloudAsset` from cache in parallel → parse → store → signal complete; in + background calls `FileCloudAssetDownloader.GetActiveAsync` → updates store/cache → + `RequestRecycle()` if asset changed +- [ ] `CloudAssetCache` (real file-backed `ICloudAssetCache`): reads/writes streams to files + under `CloudSettings.CachePath`; creates directory if absent +- [ ] Stub orchestrator removed +- [ ] **`FileSystemCloudAssetWatchService`** (BackgroundService): watches the same configured + file path as `FileCloudAssetDownloader`; on change, downloads → parses → updates cache and + store → idle-defers `IApplicationRecycleSignal.RequestRecycle()`; registered in DI in place + of `CloudAssetWatchService` until Task 12 +- [ ] Unit tests: orchestrator signals after cache load; cache miss causes orchestrator to + wait for downloader; both fail → fatal error; background downloader triggers recycle when + content differs; `WaitAsync` not re-awaited on recycle path; `CloudAssetCache` writes file + on `SaveAsync`; returns stream on hit; null on miss; creates directory if absent +- [ ] E2E tests: two test layout fixtures defined in + `test/AdaptiveRemote.EndToEndTests.TestServices/`: + — **primary-layout** — same commands as the current static layout (Play, Pause, Exit, TiVo, + Power); `FileCloudAssetDownloader` is configured to read this fixture by default + — **updated-layout** — same commands plus `'Guide'` (or another command absent from + primary-layout, chosen during implementation); the presence of this command is the assertion + signal for which layout is active in E2E steps + + The E2E test configuration sets `IdleCooldownSeconds: 0` so idle-deferred recycles happen as + soon as the user is not in an active conversation or command execution. + + Existing steps used without modification: + `Given the application is not running`, + `When I start the application`, + `Then I should see the application in the {LifecyclePhase} phase`, + `Then I should see the '{string}' button is enabled/disabled`, + `Then I should not see any error messages in the logs`, + `Then I should see an error message in the logs:`, + `When I say {string}`, + `Then the application should enter listening mode`, + `Then the application should exit listening mode` + + New steps required — each describes the test environment or what the user observes, not + application internals: + - `Given the local layout cache is empty` — deletes the E2E test cache directory before start + - `Given the local layout cache contains the primary/updated test layout` — pre-seeds the + cache file from the named fixture + - `Given the stub layout file is set to the primary/updated test layout` — writes the named + fixture to the path `FileCloudAssetDownloader` is configured to read + - `Given the stub layout file is absent` — deletes that file + - `When I update the stub layout file to the updated test layout` — overwrites the file at + runtime; `FileSystemCloudAssetWatchService` detects the change and starts the update path + - `Then I should see a fatal startup error message` — verifies the fatal-error UI is visible + +```gherkin + Scenario: App loads layout from stub file when cache is empty + Given the application is not running + And the local layout cache is empty + And the stub layout file is set to the primary test layout + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Play' button is enabled + And I should not see any error messages in the logs + + Scenario: App starts from cache when stub file is unchanged + Given the application is not running + And the local layout cache contains the primary test layout + And the stub layout file is set to the primary test layout + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Play' button is enabled + And I should not see any error messages in the logs + + Scenario: App fails to start when cache is empty and stub file is absent + Given the application is not running + And the local layout cache is empty + And the stub layout file is absent + When I start the application + Then I should see a fatal startup error message + + Scenario: App applies updated layout on first idle cycle after startup + # Cache has primary-layout; stub has updated-layout. Background download detects the + # difference, triggers a recycle; zero cooldown means it fires immediately on idle. + Given the application is not running + And the local layout cache contains the primary test layout + And the stub layout file is set to the updated test layout + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Guide' button is enabled + And I should not see any error messages in the logs + + Scenario: App continues on cached layout when background download fails + # Stub file is absent so the downloader returns null; the app stays on the cached layout. + Given the application is not running + And the local layout cache contains the primary test layout + And the stub layout file is absent + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Play' button is enabled + And I should see an error message in the logs: + """ + Failed to download asset 'layout' + """ + # Note: exact log message text determined in MessageLogger.cs during implementation. + + Scenario: Layout is updated when stub file changes while the user is idle + Given the application is in the Ready phase + When I update the stub layout file to the updated test layout + Then I should see the application in the Ready phase + And I should see the 'Guide' button is enabled + And I should not see any error messages in the logs + + Scenario: Layout update is deferred until after an active conversation ends + Given the application is in the Ready phase + When I say "Hey Remote" + Then the application should enter listening mode + When I update the stub layout file to the updated test layout + And I say "Thank you" + Then the application should exit listening mode + And I should see the application in the Ready phase + And I should see the 'Guide' button is enabled + And I should not see any error messages in the logs +``` +- [ ] All existing unit and E2E tests pass + +--- + +### 8. [ADR-182](https://jodasoft.atlassian.net/browse/ADR-182) — CSS from CompiledLayout + +Switch `LayoutStylesheetProvider` to read `CssDefinitions` from the `CompiledLayout` in the +store; remove the extracted static grid CSS. + +- [ ] `LayoutStylesheetProvider` v2: returns + `ICloudAssetStore.Get("layout").CssDefinitions` +- [ ] Extracted static grid CSS resource removed +- [ ] Sample `layout.json` updated to include representative `CssDefinitions` +- [ ] Unit tests: `LayoutStylesheetProvider` returns `CssDefinitions` from store; null store + throws descriptive error +- [ ] All existing unit and E2E tests pass + +--- + +### 9. [ADR-183](https://jodasoft.atlassian.net/browse/ADR-183) — OAuth token provider + +Introduce `ICloudAuthTokenProvider` and `CognitoTokenProvider`. + +- [ ] `ICloudAuthTokenProvider` defined in `Services/CloudAssets/` +- [ ] `CognitoTokenProvider` POSTs to `CloudSettings.CognitoTokenEndpointUrl` with + `client_credentials` grant; caches token; refreshes before expiry +- [ ] `ClientId`, `ClientSecret`, `CognitoTokenEndpointUrl` present in `CloudSettings` +- [ ] Unit tests: token fetched on first call; cached token reused before expiry; token + refreshed when near expiry; `CancellationToken` respected +- [ ] All existing unit and E2E tests pass + +--- + +### 10. [ADR-184](https://jodasoft.atlassian.net/browse/ADR-184) — E2E test infrastructure — Docker Compose for backend services + +Set up Docker Compose so the real backend services are available before HTTP implementations +are introduced in subsequent tasks. + +- [ ] `docker-compose.yml` starts `CompiledLayoutService` and `NotificationService` with + test configuration +- [ ] Both services expose test-only control endpoints when `ASPNETCORE_ENVIRONMENT=Test` + (to be added to ADR-161 backend spec); these are the seams that make Tasks 11–12 E2E + scenarios exercisable without manual intervention: + - `POST /test/layouts/set-active` — activates a named test layout fixture on the backend + - `POST /test/backend/set-unavailable` / `POST /test/backend/set-available` — makes all + requests return 503 or restores normal operation + - `POST /test/backend/set-download-unavailable` — makes only the download endpoint return 503 + while SSE continues to function + - `POST /test/sse/publish-event` — stores a new active layout version and sends a + `layout-ready` SSE event to all connected clients + - `POST /test/sse/disconnect-all` — forcibly closes all active SSE connections +- [ ] New step definitions added to `AdaptiveRemote.EndToEndTests.Steps` for the + backend-control steps used in Tasks 11–12 +- [ ] E2E test fixture starts and stops the compose stack as part of test setup/teardown +- [ ] Tests wait for service health checks before proceeding +- [ ] `test/_doc_EndToEndTests.md` updated to document Docker-based E2E setup + +--- + +### 11. [ADR-185](https://jodasoft.atlassian.net/browse/ADR-185) — Real CloudAssetDownloader + +Replace `FileCloudAssetDownloader` with the real HTTP `CloudAssetDownloader`; startup +scenarios now run against the real backend. + +- [ ] `CloudAssetDownloader` wraps `HttpClient`; appends resource path for active/by-id + fetches; attaches `Authorization: Bearer` token via `ICloudAuthTokenProvider` +- [ ] `FileCloudAssetDownloader` removed from DI +- [ ] Unit tests: `Authorization` header attached on every request; `GetActiveAsync` + constructs the correct URL; null returned on 404; exception propagated on other HTTP errors +- [ ] E2E tests: startup scenarios repeated against the Docker backend using the test-only + control endpoints from Task 10. Reuses the primary/updated test layout fixtures and all + step definitions from Task 7. + + New backend-control steps (calling the test-only API from Task 10): + - `Given the backend has the primary/updated test layout active` — calls + `POST /test/layouts/set-active` + - `Given the backend is not responding` — calls `POST /test/backend/set-unavailable` + +```gherkin + Scenario: App loads layout from backend when cache is empty + Given the application is not running + And the local layout cache is empty + And the backend has the primary test layout active + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Play' button is enabled + And I should not see any error messages in the logs + + Scenario: App falls back to cache when backend is unavailable at startup + Given the application is not running + And the local layout cache contains the primary test layout + And the backend is not responding + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Play' button is enabled + And I should not see any error messages in the logs + + Scenario: App fails to start when backend is unavailable and cache is empty + Given the application is not running + And the local layout cache is empty + And the backend is not responding + When I start the application + Then I should see a fatal startup error message + + Scenario: App applies updated layout on first idle cycle when backend has newer version + Given the application is not running + And the local layout cache contains the primary test layout + And the backend has the updated test layout active + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Guide' button is enabled + And I should not see any error messages in the logs +``` +- [ ] All existing unit and E2E tests pass + +--- + +### 12. [ADR-186](https://jodasoft.atlassian.net/browse/ADR-186) — Real CloudAssetWatchService — SSE subscription and update flow + +Replace `FileSystemCloudAssetWatchService` with the real SSE-based `CloudAssetWatchService`; +SSE update scenarios now run against the real backend. + +- [ ] `CloudAssetWatchService` opens one SSE connection per `ICloudAsset.StreamUrl` using + `System.Net.ServerSentEvents` +- [ ] On event matching `asset.EventName`: calls + `ICloudAssetDownloader.GetActiveAsync(asset.ResourcePath, ct)`, `asset.ParseAsync`, + `ICloudAssetCache.SaveAsync(asset.Name, ...)`, `ICloudAssetStore.Set(asset.Name, ...)`, + then idle-defers `IApplicationRecycleSignal.RequestRecycle()` +- [ ] Exponential backoff reconnect loop (5 s → 2 min cap, jitter) on any non-cancellation + exception +- [ ] Warning logged per reconnect attempt; error logged after + `CloudSettings.SseMaxConsecutiveFailures` consecutive failures +- [ ] Bearer token attached via `ICloudAuthTokenProvider` +- [ ] `FileSystemCloudAssetWatchService` removed from DI +- [ ] Unit tests: store and cache updated on matching event; non-matching events ignored; + `RequestRecycle()` called after idle; backoff delay increases on repeated failures; second + SSE event overwrites store with newer value without duplicate recycle request +- [ ] E2E tests: SSE-triggered update scenarios run against the Docker backend using the + test-only control endpoints from Task 10. Reuses primary/updated test layout fixtures and + all step definitions from Tasks 7 and 11. + + New backend-control steps (calling the test-only API from Task 10): + - `When the backend publishes the updated test layout` — calls `POST /test/sse/publish-event`, + which stores the updated layout as active and sends a `layout-ready` SSE event to all + connected clients + - `When the backend drops all SSE connections` — calls `POST /test/sse/disconnect-all` + - `Given the backend download endpoint is not responding` — calls + `POST /test/backend/set-download-unavailable` + - `When the backend sends a layout-ready SSE event` — calls `POST /test/sse/publish-event` + with the current active layout unchanged (notifies without providing new content) + +```gherkin + Scenario: Layout is updated when backend publishes while user is idle + Given the application is in the Ready phase + When the backend publishes the updated test layout + Then I should see the application in the Ready phase + And I should see the 'Guide' button is enabled + And I should not see any error messages in the logs + + Scenario: Layout update is deferred until after an active conversation ends + Given the application is in the Ready phase + When I say "Hey Remote" + Then the application should enter listening mode + When the backend publishes the updated test layout + And I say "Thank you" + Then the application should exit listening mode + And I should see the application in the Ready phase + And I should see the 'Guide' button is enabled + And I should not see any error messages in the logs + + Scenario: App continues running when the SSE connection is dropped + Given the application is in the Ready phase + When the backend drops all SSE connections + Then I should see the application in the Ready phase + And I should not see any error messages in the logs + + Scenario: App continues on current layout when download fails after SSE notification + Given the application is in the Ready phase + And the backend download endpoint is not responding + When the backend sends a layout-ready SSE event + Then I should see the application in the Ready phase + And I should see the 'Play' button is enabled + And I should see an error message in the logs: + """ + Failed to download asset 'layout' + """ + # Note: exact log message text determined in MessageLogger.cs during implementation. +``` +- [ ] All existing unit and E2E tests pass + +--- + +## Related Docs + +- [`src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md`](../Lifecycle/_doc_Lifecycle.md) +- [`src/AdaptiveRemote.App/Services/Commands/_doc_Commands.md`](../Commands/_doc_Commands.md) +- [`src/AdaptiveRemote.App/Components/_doc_UI.md`](../../Components/_doc_UI.md) +- [`src/AdaptiveRemote.App/Services/ProgrammaticSettings/_doc_ProgrammaticSettings.md`](../ProgrammaticSettings/_doc_ProgrammaticSettings.md) +- [`src/_doc_Projects.md`](../../../_doc_Projects.md) +- [`src/_spec_LayoutCustomizationService.md`](../../../_spec_LayoutCustomizationService.md) — backend spec (ADR-161); defines `CompiledLayout` wire format, REST endpoints, and SSE event types diff --git a/src/_spec_LayoutCustomizationService.md b/src/_spec_LayoutCustomizationService.md index 283c2cfd..4e8b70c2 100644 --- a/src/_spec_LayoutCustomizationService.md +++ b/src/_spec_LayoutCustomizationService.md @@ -309,7 +309,7 @@ Test projects follow the pattern `.Tests` under `test/`. // IR — payload programmed via remote, stored in ProgrammaticSettings // Others — keyed by Name // Subtypes with additional properties are deferred until a concrete need arises. -public enum CommandType { Lifecycle, TiVo, IR, Action } +public enum CommandType { Lifecycle, TiVo, IR } // Shared behavioral interface — prevents drift between the compiled and raw command types. // Adding a new behavioral property means updating this interface first; the compiler From 2a2e26015e7182e3d620a92c071f25e4df8de591 Mon Sep 17 00:00:00 2001 From: Claude <242468646+Claude@users.noreply.github.com> Date: Wed, 15 Apr 2026 06:44:58 -0700 Subject: [PATCH 02/14] [ADR-175] Interfaces, CloudAssetStore, pass-through CloudLayoutDefinitionService, and stub orchestrator (#143) --- CLAUDE.md | 5 + .../CloudAssetServiceExtensions.cs | 15 + .../Configuration/HostBuilderExtensions.cs | 8 +- .../CloudAssets/CloudAssetOrchestrator.cs | 80 +++++ .../Services/CloudAssets/CloudAssetStore.cs | 31 ++ .../CloudAssets/CloudAssetStoreExtensions.cs | 32 ++ .../Services/CloudAssets/CloudSettings.cs | 15 + .../Services/CloudAssets/ICloudAsset.cs | 38 +++ .../Services/CloudAssets/ICloudAssetCache.cs | 23 ++ .../CloudAssets/ICloudAssetDownloader.cs | 24 ++ .../Services/CloudAssets/ICloudAssetStore.cs | 23 ++ .../Commands/StaticCommandGroupProvider.cs | 50 --- .../Layout/RemoteLayoutDefinitionService.cs | 37 +++ .../Lifecycle/ApplicationLifecycle.cs | 43 ++- .../Lifecycle/IPreScopeInitializer.cs | 16 + .../CloudAssets/CloudAssetStoreTests.cs | 165 ++++++++++ .../RemoteLayoutDefinitionServiceTests.cs | 102 ++++++ .../Lifecycle/ApplicationLifecycleTests.cs | 292 +++++++++++++++++- .../PersistSettingsTests.cs | 18 +- .../TestUtilities/MockFileSystem.cs | 1 + .../Features/Shared/LayoutButtons.feature | 33 ++ .../PlaywrightUITestService.cs | 23 +- test/_doc_EndToEndTests.md | 8 + 23 files changed, 1020 insertions(+), 62 deletions(-) create mode 100644 src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStore.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetCache.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetDownloader.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetStore.cs delete mode 100644 src/AdaptiveRemote.App/Services/Commands/StaticCommandGroupProvider.cs create mode 100644 src/AdaptiveRemote.App/Services/Layout/RemoteLayoutDefinitionService.cs create mode 100644 src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs create mode 100644 test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetStoreTests.cs create mode 100644 test/AdaptiveRemote.App.Tests/Services/Layout/RemoteLayoutDefinitionServiceTests.cs create mode 100644 test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature diff --git a/CLAUDE.md b/CLAUDE.md index af48d4d6..42dc8855 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,12 +75,17 @@ mock verification. Group setup calls into `Expect_*` helper methods. ### E2E tests Prefer the Headless host for new E2E tests — cross-platform, no display required: +**IMPORTANT:** Before running E2E tests for the first time, you must install Playwright browsers: ```bash +# Build the Headless host first (required to generate the Playwright installation script) dotnet build src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj pwsh src/AdaptiveRemote.Headless/bin/Debug/net10.0/playwright.ps1 install chromium # if tests crash at startup with a JSON-RPC disconnect dotnet test --filter "FullyQualifiedName~Host.Headless" ``` +If E2E tests fail with JSON-RPC connection errors, the most likely cause is that Playwright browsers +are not installed. Run the `playwright.ps1 install chromium` command above to fix this. + ## Documentation ### `_spec_*.md` — pre-implementation design docs diff --git a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs new file mode 100644 index 00000000..da862fbe --- /dev/null +++ b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs @@ -0,0 +1,15 @@ +using AdaptiveRemote.Services.CloudAssets; +using AdaptiveRemote.Services.Lifecycle; +using Microsoft.Extensions.DependencyInjection; + +namespace AdaptiveRemote.Configuration; + +internal static class CloudAssetServiceExtensions +{ + internal static IServiceCollection AddCloudAssetServices(this IServiceCollection services) + => services + .AddSingleton() + .AddSingleton() + .AddSingleton(sp => sp.GetRequiredService()) + .AddHostedService(sp => sp.GetRequiredService()); +} diff --git a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs index f5dddb7f..f5b7f17b 100644 --- a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs @@ -1,5 +1,7 @@ using AdaptiveRemote.Services; +using AdaptiveRemote.Services.CloudAssets; using AdaptiveRemote.Services.Commands; +using AdaptiveRemote.Services.Layout; using AdaptiveRemote.Services.Lifecycle; using AdaptiveRemote.Services.ProgrammaticSettings; using Microsoft.Extensions.Configuration; @@ -25,10 +27,12 @@ internal static IHostBuilder AddRemoteServices(this IHostBuilder builder) internal static IServiceCollection AddRemoteServices(this IServiceCollection services, IConfiguration configuration) => services .AddApplicationLifecycleServices() + .AddCloudAssetServices() .AddScopedLifecycleService() - .AddScoped() + .AddScoped() .AddSingleton() - .Configure(configuration.GetSection(SettingsKeys.ProgrammaticSettings)); + .Configure(configuration.GetSection(SettingsKeys.ProgrammaticSettings)) + .Configure(configuration.GetSection("CloudSettings")); internal static IServiceCollection AddScopedLifecycleService(this IServiceCollection services) where ServiceType : class, IScopedLifecycle diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs new file mode 100644 index 00000000..6353f5f4 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs @@ -0,0 +1,80 @@ +using AdaptiveRemote.Models; +using AdaptiveRemote.Services.Lifecycle; +using Microsoft.Extensions.Hosting; + +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// Stub orchestrator that inlines the same hardcoded commands as StaticCommandGroupProvider. +/// Stores the LayoutGroup root directly in CloudAssetStore and immediately signals +/// IPreScopeInitializer complete. No file I/O, no HTTP. +/// +internal class CloudAssetOrchestrator : BackgroundService, IPreScopeInitializer +{ + private readonly ICloudAssetStore _store; + private readonly TaskCompletionSource _initCompleted = new(); + + public CloudAssetOrchestrator(ICloudAssetStore store) + { + _store = store; + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + // Inline the same hardcoded layout from StaticCommandGroupProvider + LayoutGroup layout = new("ROOT", + [ + new LayoutGroup("DPAD", + [ + new TiVoCommand("Up", reverse: "Down"), + new TiVoCommand("Down", reverse: "Up"), + new TiVoCommand("Left", reverse: "Right"), + new TiVoCommand("Right", reverse: "Left"), + new TiVoCommand("Select"), + new TiVoCommand("Back"), + new IRCommand("Power", reverse: "Power"), + new IRCommand("PowerOn", reverse: "PowerOff"), + new IRCommand("PowerOff", reverse: "PowerOn"), + ]), + new LayoutGroup("WELL", + [ + new TiVoCommand("TiVo"), + new TiVoCommand("Netflix"), + new TiVoCommand("Guide"), + ]), + new LayoutGroup("PLAYBACK", + [ + new TiVoCommand("Play", reverse: "Pause"), + new TiVoCommand("Pause", reverse: "Play"), + new TiVoCommand("Record"), + new TiVoCommand("Skip", reverse: "Replay"), + new TiVoCommand("Replay", reverse: "Skip"), + ]), + new LayoutGroup("CHANNELANDVOLUME", + [ + new TiVoCommand("ChannelUp", reverse: "ChannelDown", label: "Up", speakName: "Channel Up"), + new TiVoCommand("ChannelDown", reverse: "ChannelUp", label: "Down", speakName: "Channel Down"), + new IRCommand("VolumeUp", reverse: "VolumeDown", label: "Up", speakName: "Volume Up"), + new IRCommand("VolumeDown", reverse: "VolumeUp", label: "Down", speakName: "Volume Down"), + new IRCommand("Mute", reverse: "Mute", label: "Mute"), + ]), + new LayoutGroup("GUTTER", + [ + new ConversationView(), + new LifecycleCommand("Learn"), + new LifecycleCommand("Exit", speakPhrase: Phrases.Conversation_Goodbye) + ]) + ]); + + _store.SetLayout(layout); + _initCompleted.SetResult(); + + return Task.CompletedTask; + } + + public Task WaitAsync(ILifecycleActivity activity, CancellationToken ct) + { + activity.Description = "Loading cloud assets"; + return _initCompleted.Task.WaitAsync(ct); + } +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStore.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStore.cs new file mode 100644 index 00000000..02e974a2 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStore.cs @@ -0,0 +1,31 @@ +using System.Collections.Concurrent; + +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// Thread-safe in-memory dictionary keyed by asset name. +/// +internal class CloudAssetStore : ICloudAssetStore +{ + private readonly ConcurrentDictionary _assets = new(); + + public T Get(string name) + { + if (!_assets.TryGetValue(name, out object? value)) + { + throw new InvalidOperationException($"Asset '{name}' not found in store. Ensure CloudAssetOrchestrator has completed before accessing assets."); + } + + if (value is not T typedValue) + { + throw new InvalidOperationException($"Asset '{name}' is of type '{value.GetType().Name}', but '{typeof(T).Name}' was requested."); + } + + return typedValue; + } + + public void Set(string name, object asset) + { + _assets[name] = asset; + } +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs new file mode 100644 index 00000000..897f6fdf --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs @@ -0,0 +1,32 @@ +using AdaptiveRemote.Models; + +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// Extension methods for ICloudAssetStore that encapsulate asset names for common assets. +/// +internal static class CloudAssetStoreExtensions +{ + private const string LayoutAssetName = "layout"; + + /// + /// Retrieves the layout asset from the store. + /// + /// The cloud asset store + /// The layout group + /// Thrown if layout not found or wrong type + public static LayoutGroup GetLayout(this ICloudAssetStore store) + { + return store.Get(LayoutAssetName); + } + + /// + /// Stores the layout asset in the store. + /// + /// The cloud asset store + /// The layout group to store + public static void SetLayout(this ICloudAssetStore store, LayoutGroup layout) + { + store.Set(LayoutAssetName, layout); + } +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs new file mode 100644 index 00000000..b720f491 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs @@ -0,0 +1,15 @@ +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// Shared connection and auth settings for all cloud asset services. +/// +internal class CloudSettings +{ + public string BackendBaseUrl { get; set; } = ""; + public string CognitoTokenEndpointUrl { get; set; } = ""; + public string ClientId { get; set; } = ""; + public string ClientSecret { get; set; } = ""; + public int IdleCooldownSeconds { get; set; } = 30; + public int SseMaxConsecutiveFailures { get; set; } = 10; + public string CachePath { get; set; } = @"%LocalAppData%\AdaptiveRemote\CloudAssets"; +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs new file mode 100644 index 00000000..4d439864 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs @@ -0,0 +1,38 @@ +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// Per-asset capability bundle. One implementation per cloud-fetched asset type. +/// +internal interface ICloudAsset +{ + /// + /// Unique key used in store/cache; also used for logging + /// + string Name { get; } + + /// + /// SSE endpoint, e.g. "/notifications/layouts/stream" + /// + string StreamUrl { get; } + + /// + /// SSE event name, e.g. "layout-ready" + /// + string EventName { get; } + + /// + /// REST base path, e.g. "/layouts/compiled" + /// + string ResourcePath { get; } + + /// + /// Parses downloaded or cached bytes into the asset's runtime type. + /// + Task ParseAsync(Stream stream, CancellationToken ct); +} + +/// +/// Marker interface — allows type-constrained DI registrations: +/// services.AddScoped(sp => sp.GetRequiredService<ICloudAssetStore>().Get<T>(name)) +/// +internal interface ICloudAsset : ICloudAsset { } diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetCache.cs b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetCache.cs new file mode 100644 index 00000000..6fc8e553 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetCache.cs @@ -0,0 +1,23 @@ +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// File-backed persistence for raw asset bytes, keyed by Name. +/// +internal interface ICloudAssetCache +{ + /// + /// Loads an asset from the cache. + /// + /// The unique name of the asset + /// Cancellation token + /// A stream containing the cached asset data, or null if no cached file exists + Task LoadAsync(string name, CancellationToken ct); + + /// + /// Saves an asset to the cache. + /// + /// The unique name of the asset + /// A stream containing the asset data to cache + /// Cancellation token + Task SaveAsync(string name, Stream assetData, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetDownloader.cs b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetDownloader.cs new file mode 100644 index 00000000..3c37717f --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetDownloader.cs @@ -0,0 +1,24 @@ +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// HTTP download against the backend REST API. +/// +internal interface ICloudAssetDownloader +{ + /// + /// Downloads the currently active version of an asset. + /// + /// The REST resource path for the asset + /// Cancellation token + /// A stream containing the asset data, or null if the asset is not available + Task GetActiveAsync(string resourcePath, CancellationToken ct); + + /// + /// Downloads a specific version of an asset by ID. + /// + /// The REST resource path for the asset + /// The unique identifier of the asset version + /// Cancellation token + /// A stream containing the asset data, or null if the asset is not available + Task GetByIdAsync(string resourcePath, Guid id, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetStore.cs b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetStore.cs new file mode 100644 index 00000000..87517307 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetStore.cs @@ -0,0 +1,23 @@ +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// In-memory cross-scope holder for all cloud assets, keyed by Name. +/// +internal interface ICloudAssetStore +{ + /// + /// Retrieves an asset from the store. + /// + /// The expected type of the asset + /// The unique name of the asset + /// The asset value + /// Thrown if asset not found or wrong type + T Get(string name); + + /// + /// Stores an asset in the store. + /// + /// The unique name of the asset + /// The asset value to store + void Set(string name, object asset); +} diff --git a/src/AdaptiveRemote.App/Services/Commands/StaticCommandGroupProvider.cs b/src/AdaptiveRemote.App/Services/Commands/StaticCommandGroupProvider.cs deleted file mode 100644 index b4874fc0..00000000 --- a/src/AdaptiveRemote.App/Services/Commands/StaticCommandGroupProvider.cs +++ /dev/null @@ -1,50 +0,0 @@ -using AdaptiveRemote.Models; - -namespace AdaptiveRemote.Services.Commands; - -internal class StaticCommandGroupProvider : IRemoteDefinitionService -{ - public RemoteLayoutElement RemoteRoot { get; } = new LayoutGroup("ROOT", - [ - new LayoutGroup("DPAD", - [ - new TiVoCommand("Up", reverse: "Down"), - new TiVoCommand("Down", reverse: "Up"), - new TiVoCommand("Left", reverse: "Right"), - new TiVoCommand("Right", reverse: "Left"), - new TiVoCommand("Select"), - new TiVoCommand("Back"), - new IRCommand("Power", reverse: "Power"), - new IRCommand("PowerOn", reverse: "PowerOff"), - new IRCommand("PowerOff", reverse: "PowerOn"), - ]), - new LayoutGroup("WELL", - [ - new TiVoCommand("TiVo"), - new TiVoCommand("Netflix"), - new TiVoCommand("Guide"), - ]), - new LayoutGroup("PLAYBACK", - [ - new TiVoCommand("Play", reverse: "Pause"), - new TiVoCommand("Pause", reverse: "Play"), - new TiVoCommand("Record"), - new TiVoCommand("Skip", reverse: "Replay"), - new TiVoCommand("Replay", reverse: "Skip"), - ]), - new LayoutGroup("CHANNELANDVOLUME", - [ - new TiVoCommand("ChannelUp", reverse: "ChannelDown", label: "Up", speakName: "Channel Up"), - new TiVoCommand("ChannelDown", reverse: "ChannelUp", label: "Down", speakName: "Channel Down"), - new IRCommand("VolumeUp", reverse: "VolumeDown", label: "Up", speakName: "Volume Up"), - new IRCommand("VolumeDown", reverse: "VolumeUp", label: "Down", speakName: "Volume Down"), - new IRCommand("Mute", reverse: "Mute", label: "Mute"), - ]), - new LayoutGroup("GUTTER", - [ - new ConversationView(), - new LifecycleCommand("Learn"), - new LifecycleCommand("Exit", speakPhrase: Phrases.Conversation_Goodbye) - ]) - ]); -} diff --git a/src/AdaptiveRemote.App/Services/Layout/RemoteLayoutDefinitionService.cs b/src/AdaptiveRemote.App/Services/Layout/RemoteLayoutDefinitionService.cs new file mode 100644 index 00000000..8dfb9cb8 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Layout/RemoteLayoutDefinitionService.cs @@ -0,0 +1,37 @@ +using AdaptiveRemote.Models; +using AdaptiveRemote.Services.CloudAssets; + +namespace AdaptiveRemote.Services.Layout; + +/// +/// RemoteLayoutDefinitionService v1: reads LayoutGroup directly from +/// ICloudAssetStore.Get<LayoutGroup>("layout") and returns it as RemoteRoot. +/// No DTO mapping in this version. +/// +internal class RemoteLayoutDefinitionService : IRemoteDefinitionService +{ + private readonly ICloudAssetStore _store; + + public RemoteLayoutDefinitionService(ICloudAssetStore store) + { + _store = store; + } + + public RemoteLayoutElement RemoteRoot + { + get + { + try + { + return _store.GetLayout(); + } + catch (InvalidOperationException ex) + { + throw new InvalidOperationException( + "Failed to load layout from CloudAssetStore. " + + "Ensure CloudAssetOrchestrator has completed initialization before the first scope is created.", + ex); + } + } + } +} diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs index 59619249..fe2280d3 100644 --- a/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs +++ b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs @@ -9,22 +9,31 @@ internal class ApplicationLifecycle : BackgroundService { private readonly IApplicationScopeProvider _scopeProvider; private readonly ILifecycleViewController _viewController; + private readonly IEnumerable _preInitializers; private readonly MessageLogger _logger; private ScopedLifecycleContainer? _currentContainer; - public ApplicationLifecycle(IApplicationScopeProvider scopeProvider, ILifecycleViewController viewController, ILogger logger) + public ApplicationLifecycle( + IApplicationScopeProvider scopeProvider, + ILifecycleViewController viewController, + IEnumerable preInitializers, + ILogger logger) { _scopeProvider = scopeProvider; _viewController = viewController; + _preInitializers = preInitializers; _logger = new(logger); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.ApplicationLifecycle_WaitingForScope(); - try { + // Await all IPreScopeInitializer services before creating the first scope + await RunPreInitializersAsync(stoppingToken); + + _logger.ApplicationLifecycle_WaitingForScope(); + await _scopeProvider.InvokeInScopeAsync(InitializeLifecycleAsync, stoppingToken); _logger.ApplicationLifecycle_ScopeReleased(); } @@ -38,13 +47,39 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await CleanUpCurrentContainerAsync(default); } - await stoppingToken.WaitForCancelledAsync(); + try + { + await stoppingToken.WaitForCancelledAsync(); + } + catch (OperationCanceledException) + { + // Expected when stopping + } _logger.ApplicationLifecycle_ShuttingDown(); await CleanUpCurrentContainerAsync(default); } + private async Task RunPreInitializersAsync(CancellationToken stoppingToken) + { + Task[] initTasks = _preInitializers.Select(init => RunSinglePreInitializerAsync(init, stoppingToken)).ToArray(); + await Task.WhenAll(initTasks); + } + + private async Task RunSinglePreInitializerAsync(IPreScopeInitializer initializer, CancellationToken stoppingToken) + { + ILifecycleActivity activity = _viewController.StartTask($"Initializing {initializer.GetType().Name}"); + try + { + await initializer.WaitAsync(activity, stoppingToken); + } + finally + { + activity.Dispose(); + } + } + private async Task InitializeLifecycleAsync(IServiceProvider provider, CancellationToken cancellationToken) { _currentContainer = SafeGetContainer(provider); diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs b/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs new file mode 100644 index 00000000..d0f14879 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs @@ -0,0 +1,16 @@ +namespace AdaptiveRemote.Services.Lifecycle; + +/// +/// Implemented by singleton services that must fully initialize before the first scope is +/// created. ApplicationLifecycle awaits all registrations before calling InvokeInScopeAsync. +/// Not re-awaited on scope recycles. +/// +internal interface IPreScopeInitializer +{ + /// + /// Waits for the service to be ready before scope creation can proceed. + /// + /// Lifecycle activity for reporting progress and errors + /// Cancellation token + Task WaitAsync(ILifecycleActivity activity, CancellationToken ct); +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetStoreTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetStoreTests.cs new file mode 100644 index 00000000..55f170c3 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetStoreTests.cs @@ -0,0 +1,165 @@ +using FluentAssertions; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class CloudAssetStoreTests +{ + [TestMethod] + public void CloudAssetStore_Get_ThrowsOnMissingKey() + { + // Arrange + CloudAssetStore sut = new(); + + // Act + Action act = () => sut.Get("nonexistent"); + + // Assert + act.Should().Throw() + .WithMessage("Asset 'nonexistent' not found in store.*"); + } + + [TestMethod] + public void CloudAssetStore_Get_ThrowsOnWrongType() + { + // Arrange + CloudAssetStore sut = new(); + sut.Set("test", 42); + + // Act + Action act = () => sut.Get("test"); + + // Assert + act.Should().Throw() + .WithMessage("Asset 'test' is of type 'Int32', but 'String' was requested."); + } + + [TestMethod] + public void CloudAssetStore_SetAndGet_RoundTripsCorrectly() + { + // Arrange + CloudAssetStore sut = new(); + string expectedValue = "test value"; + + // Act + sut.Set("key", expectedValue); + string actualValue = sut.Get("key"); + + // Assert + actualValue.Should().Be(expectedValue); + } + + [TestMethod] + public void CloudAssetStore_Set_OverwritesExistingValue() + { + // Arrange + CloudAssetStore sut = new(); + sut.Set("key", "initial"); + + // Act + sut.Set("key", "updated"); + string actualValue = sut.Get("key"); + + // Assert + actualValue.Should().Be("updated"); + } + + [TestMethod] + public void CloudAssetStore_Get_SupportsMultipleAssets() + { + // Arrange + CloudAssetStore sut = new(); + sut.Set("asset1", "value1"); + sut.Set("asset2", 123); + sut.Set("asset3", true); + + // Act & Assert + sut.Get("asset1").Should().Be("value1"); + sut.Get("asset2").Should().Be(123); + sut.Get("asset3").Should().BeTrue(); + } + + [TestMethod] + public void CloudAssetStore_ThreadSafety_HandlesSimultaneousReadsAndWrites() + { + // Arrange + CloudAssetStore sut = new(); + const int threadCount = 10; + const int operationsPerThread = 100; + List exceptions = []; + object exceptionsLock = new(); + + // Pre-populate with some assets + for (int i = 0; i < threadCount; i++) + { + sut.Set($"shared-asset-{i}", $"initial-{i}"); + } + + // Act + Thread[] threads = new Thread[threadCount]; + for (int threadIndex = 0; threadIndex < threadCount; threadIndex++) + { + int currentThreadIndex = threadIndex; + threads[threadIndex] = new Thread(() => + { + try + { + for (int operation = 0; operation < operationsPerThread; operation++) + { + // Mix of different operations + if (operation % 3 == 0) + { + // Write to shared assets + sut.Set($"shared-asset-{operation % threadCount}", $"thread-{currentThreadIndex}-op-{operation}"); + } + else if (operation % 3 == 1) + { + // Write to thread-specific assets + sut.Set($"thread-{currentThreadIndex}-asset-{operation}", $"value-{operation}"); + } + else + { + // Read from shared assets + try + { + _ = sut.Get($"shared-asset-{operation % threadCount}"); + } + catch (InvalidOperationException) + { + // Expected if another thread is modifying or type mismatch + } + } + } + } + catch (Exception ex) + { + lock (exceptionsLock) + { + exceptions.Add(ex); + } + } + }); + threads[threadIndex].Start(); + } + + // Wait for all threads to complete + foreach (Thread thread in threads) + { + thread.Join(); + } + + // Assert + exceptions.Should().BeEmpty("thread-safe operations should not throw exceptions"); + + // Verify that we can still read all thread-specific assets + for (int threadIndex = 0; threadIndex < threadCount; threadIndex++) + { + for (int operation = 1; operation < operationsPerThread; operation += 3) + { + string key = $"thread-{threadIndex}-asset-{operation}"; + string expectedValue = $"value-{operation}"; + sut.Get(key).Should().Be(expectedValue, $"asset {key} should have correct value"); + } + } + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/Layout/RemoteLayoutDefinitionServiceTests.cs b/test/AdaptiveRemote.App.Tests/Services/Layout/RemoteLayoutDefinitionServiceTests.cs new file mode 100644 index 00000000..b27a4c79 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/Layout/RemoteLayoutDefinitionServiceTests.cs @@ -0,0 +1,102 @@ +using AdaptiveRemote.Models; +using AdaptiveRemote.Services.CloudAssets; +using FluentAssertions; +using Moq; + +namespace AdaptiveRemote.Services.Layout; + +[TestClass] +public class RemoteLayoutDefinitionServiceTests +{ + private readonly Mock _mockStore = new(); + + [TestCleanup] + public void VerifyMocks() + { + _mockStore.Verify(); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_ReturnsStoreContents() + { + // Arrange + LayoutGroup expectedLayout = new("ROOT", []); + _mockStore.Setup(s => s.Get("layout")) + .Returns(expectedLayout) + .Verifiable(); + + RemoteLayoutDefinitionService sut = new(_mockStore.Object); + + // Act + RemoteLayoutElement actualRoot = sut.RemoteRoot; + + // Assert + actualRoot.Should().BeSameAs(expectedLayout); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_EmptyStoreThrowsDescriptiveError() + { + // Arrange + _mockStore.Setup(s => s.Get("layout")) + .Throws(new InvalidOperationException("Asset 'layout' not found in store.")) + .Verifiable(); + + RemoteLayoutDefinitionService sut = new(_mockStore.Object); + + // Act + Action act = () => _ = sut.RemoteRoot; + + // Assert + act.Should().Throw() + .WithMessage("Failed to load layout from CloudAssetStore.*"); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_WrongTypeThrowsDescriptiveError() + { + // Arrange + _mockStore.Setup(s => s.Get("layout")) + .Throws(new InvalidOperationException("Asset 'layout' is of type 'String', but 'LayoutGroup' was requested.")) + .Verifiable(); + + RemoteLayoutDefinitionService sut = new(_mockStore.Object); + + // Act + Action act = () => _ = sut.RemoteRoot; + + // Assert + act.Should().Throw() + .WithMessage("Failed to load layout from CloudAssetStore.*"); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_ReturnsComplexLayout() + { + // Arrange + LayoutGroup expectedLayout = new("ROOT", + [ + new LayoutGroup("GROUP1", + [ + new TiVoCommand("Up"), + new TiVoCommand("Down"), + ]), + new LayoutGroup("GROUP2", + [ + new IRCommand("Power"), + ]) + ]); + + _mockStore.Setup(s => s.Get("layout")) + .Returns(expectedLayout) + .Verifiable(); + + RemoteLayoutDefinitionService sut = new(_mockStore.Object); + + // Act + RemoteLayoutElement actualRoot = sut.RemoteRoot; + + // Assert + actualRoot.Should().BeSameAs(expectedLayout); + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs b/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs index 1e2bceb5..f1f84ff2 100644 --- a/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs @@ -22,7 +22,11 @@ public class ApplicationLifecycleTests public LifecyclePhase LatestLifecyclePhase { get; private set; } - private ApplicationLifecycle CreateSut() => new ApplicationLifecycle(MockScopeProvider.Object, MockLifecycleViewController.Object, MockLogger); + private ApplicationLifecycle CreateSut() => new ApplicationLifecycle( + MockScopeProvider.Object, + MockLifecycleViewController.Object, + [], // Empty IPreScopeInitializer collection + MockLogger); [TestInitialize] public void SetupMocks() @@ -636,4 +640,290 @@ private static void Expect_SetFatalErrorOn(Mock contro }) .Verifiable(Times.Exactly(expectedExceptions.Length)); + [TestMethod] + public void ApplicationLifecycle_StartAsync_WaitsForPreInitializers() + { + // Arrange + Mock mockPreInit = new(); + mockPreInit + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(Times.Once); + + ApplicationLifecycle sut = new(MockScopeProvider.Object, MockLifecycleViewController.Object, [mockPreInit.Object], MockLogger); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for pre-initializer to be called + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Assert + mockPreInit.Verify(); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_PreInitializerDelayed_WaitsBeforeStartingScope() + { + // Arrange + TaskCompletionSource preInitTcs = new(); + Mock mockPreInit = new(); + mockPreInit + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(preInitTcs.Task) + .Verifiable(Times.Once); + + ApplicationLifecycle sut = new(MockScopeProvider.Object, MockLifecycleViewController.Object, [mockPreInit.Object], MockLogger); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Pre-initializer hasn't completed, so scope shouldn't be created yet + MockLogger.Messages.Should().NotContain(m => m.Contains("WaitingForScope")); + + // Complete pre-initializer + preInitTcs.SetResult(); + + // Assert + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + mockPreInit.Verify(); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_MultiplePreInitializers_WaitsForAll() + { + // Arrange + Mock mockPreInit1 = new(); + Mock mockPreInit2 = new(); + Mock mockPreInit3 = new(); + + mockPreInit1 + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(Times.Once); + mockPreInit2 + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(Times.Once); + mockPreInit3 + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(Times.Once); + + ApplicationLifecycle sut = new( + MockScopeProvider.Object, + MockLifecycleViewController.Object, + [mockPreInit1.Object, mockPreInit2.Object, mockPreInit3.Object], + MockLogger); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for scope to be ready + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Assert + mockPreInit1.Verify(); + mockPreInit2.Verify(); + mockPreInit3.Verify(); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_PreInitializerFails_SetsActivityError() + { + // Arrange + Exception expectedError = new InvalidOperationException("PreInit failed"); + Mock mockPreInit = new(); + + mockPreInit + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromException(expectedError)) + .Verifiable(Times.Once); + + // Don't expect scope to be created when pre-init fails + MockServiceProvider + .Setup(x => x.GetService(typeof(ScopedLifecycleContainer))) + .Verifiable(Times.Never); + + ApplicationLifecycle sut = new(MockScopeProvider.Object, MockLifecycleViewController.Object, [mockPreInit.Object], MockLogger); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Assert - ExecuteTask should fault with unhandled error + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_UnhandledError(expectedError), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + mockPreInit.Verify(); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_LastPreInitializerFails_StopsBeforeScope() + { + // Arrange + Exception expectedError = new InvalidOperationException("Last PreInit failed"); + Mock mockPreInit1 = new(); + Mock mockPreInit2 = new(); + + mockPreInit1 + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(Times.Once); + mockPreInit2 + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromException(expectedError)) + .Verifiable(Times.Once); + + // Don't expect scope to be created when pre-init fails + MockServiceProvider + .Setup(x => x.GetService(typeof(ScopedLifecycleContainer))) + .Verifiable(Times.Never); + + ApplicationLifecycle sut = new( + MockScopeProvider.Object, + MockLifecycleViewController.Object, + [mockPreInit1.Object, mockPreInit2.Object], + MockLogger); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Assert - execution should fail before creating scope + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_UnhandledError(expectedError), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + MockLogger.Messages.Should().NotContain(m => m.Contains("WaitingForScope")); + mockPreInit1.Verify(); + mockPreInit2.Verify(); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_PreInitializerCreatesActivityForEach() + { + // Arrange + Mock mockPreInit = new(); + Mock mockActivity = new(); + + mockPreInit + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(Times.Once); + + MockLifecycleViewController + .Setup(x => x.StartTask(It.Is(s => s.Contains("CloudAssetOrchestrator") || s.Contains("PreScopeInitializer")))) + .Returns(mockActivity.Object) + .Verifiable(Times.Once); + + ApplicationLifecycle sut = new(MockScopeProvider.Object, MockLifecycleViewController.Object, [mockPreInit.Object], MockLogger); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for pre-initializer to be called + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Assert - StartTask should have been called for the pre-initializer + MockLifecycleViewController.Verify(); + mockPreInit.Verify(); + + // Assert - Activity should be disposed after pre-initializer completes + mockActivity.Verify(x => x.Dispose(), Times.Once, "Activity should be disposed after pre-initializer completes"); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_PreInitializerActivity_DisposesImmediatelyWhenCompleted() + { + // Arrange + TaskCompletionSource slowPreInitTcs = new(); + Mock fastPreInit = new(); + Mock slowPreInit = new(); + Mock fastActivity = new(); + Mock slowActivity = new(); + + int callCount = 0; + + fastPreInit + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(Times.Once); + + slowPreInit + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(slowPreInitTcs.Task) + .Verifiable(Times.Once); + + // Mock StartTask to return different activities based on call order + MockLifecycleViewController + .Setup(x => x.StartTask(It.IsAny())) + .Returns(() => + { + if (callCount == 0) + { + callCount++; + return fastActivity.Object; + } + else if (callCount == 1) + { + callCount++; + return slowActivity.Object; + } + return MockActivity.Object; + }); + + ApplicationLifecycle sut = new( + MockScopeProvider.Object, + MockLifecycleViewController.Object, + [fastPreInit.Object, slowPreInit.Object], + MockLogger); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Give time for fast pre-initializer to complete and be disposed + Thread.Sleep(100); + + // Assert - Fast activity should be disposed even though slow activity is still pending + fastActivity.Verify(x => x.Dispose(), Times.Once, "Fast activity should be disposed immediately after completing"); + slowActivity.Verify(x => x.Dispose(), Times.Never, "Slow activity should not be disposed while still pending"); + + // Complete slow pre-initializer + slowPreInitTcs.SetResult(); + + // Wait for scope to start + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Assert - Now slow activity should be disposed too + slowActivity.Verify(x => x.Dispose(), Times.Once, "Slow activity should be disposed after completing"); + } + } diff --git a/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs b/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs index 98fc4ba0..1d0f9698 100644 --- a/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs @@ -5,7 +5,9 @@ namespace AdaptiveRemote.Services.ProgrammaticSettings; [TestClass] public class PersistSettingsTests { - private static readonly string InputSettingsPath = Path.Combine("%LocalAppData%", "path", "to", "settings.ini"); + // Use a test-specific environment variable to ensure cross-platform compatibility + private const string TestEnvVarName = "ADAPTIVEREMOTE_TEST_SETTINGS_PATH"; + private static readonly string InputSettingsPath = Path.Combine($"%{TestEnvVarName}%", "path", "to", "settings.ini"); private static readonly string ResolvedSettingsPath = Environment.ExpandEnvironmentVariables(InputSettingsPath); private readonly MockLogger MockLogger = new(); @@ -25,6 +27,20 @@ public TestContext? TestContext private static readonly char[] LineSeparators = ['\r', '\n']; + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + // Set the test-specific environment variable for all tests in this class + Environment.SetEnvironmentVariable(TestEnvVarName, Path.Combine(Path.GetTempPath(), "AdaptiveRemoteTests")); + } + + [ClassCleanup] + public static void ClassCleanup() + { + // Clean up the test-specific environment variable + Environment.SetEnvironmentVariable(TestEnvVarName, null); + } + [TestCleanup] public void VerifyMocks() { diff --git a/test/AdaptiveRemote.App.Tests/TestUtilities/MockFileSystem.cs b/test/AdaptiveRemote.App.Tests/TestUtilities/MockFileSystem.cs index 7ee04e45..bde965d1 100644 --- a/test/AdaptiveRemote.App.Tests/TestUtilities/MockFileSystem.cs +++ b/test/AdaptiveRemote.App.Tests/TestUtilities/MockFileSystem.cs @@ -94,6 +94,7 @@ public void Expect_OpenRead_ForPath(string path) .Verifiable(Times.Once); public void Expect_OpenRead_IsNotCalledForPath(string path) => Setup(x => x.OpenRead(path)) + .Throws(new AssertFailedException($"OpenRead should not be called with path: {path}")) .Verifiable(Times.Never); public void Expect_OpenRead_IsNotCalled() => Setup(x => x.OpenRead(It.IsAny())) diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature new file mode 100644 index 00000000..aa4488e6 --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature @@ -0,0 +1,33 @@ +Feature: Layout button verification + As a user + I want all expected buttons from the layout to be present and accessible + So that I can control my TV and AV equipment + +Scenario: All expected buttons from layout are present + Given the application is not running + When I start the application + Then I should see the application in the Ready phase + # DPAD group + And I should see the 'Up' button is enabled + And I should see the 'Down' button is enabled + And I should see the 'Left' button is enabled + And I should see the 'Right' button is enabled + And I should see the 'Select' button is enabled + And I should see the 'Back' button is enabled + And I should see the 'Power' button is enabled + # WELL group + And I should see the 'TiVo' button is enabled + And I should see the 'Netflix' button is enabled + And I should see the 'Guide' button is enabled + # PLAYBACK group + And I should see the 'Play' button is enabled + And I should see the 'Pause' button is enabled + And I should see the 'Record' button is enabled + And I should see the 'Skip' button is enabled + And I should see the 'Replay' button is enabled + # GUTTER group + And I should see the 'Learn' button is enabled + And I should see the 'Exit' button is enabled + When I click on the 'Exit' button + And I wait for the application to shut down + Then I should not see any error messages in the logs diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs index fedf7ea7..2283ae6e 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs @@ -82,10 +82,25 @@ private ILocator GetButtonLocatorByLabel(string label) // if there are no matches or ambiguous matches return label switch { - "Channel Down" => GetButtonLocatorByLabel("Down").Nth(1), - "Channel Up" => GetButtonLocatorByLabel("Up").Nth(1), - "Volume Down" => GetButtonLocatorByLabel("Down").Nth(2), - "Volume Up" => GetButtonLocatorByLabel("Up").Nth(2), + // DPAD buttons (Nth(0) - first occurrence) + "Up" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Up", Exact = true }).Nth(0) + .Describe("button with label 'Up' on DPAD"), + "Down" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Down", Exact = true }).Nth(0) + .Describe("button with label 'Down' on DPAD"), + "Left" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Left", Exact = true }).Nth(0) + .Describe("button with label 'Left' on DPAD"), + "Right" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Right", Exact = true }).Nth(0) + .Describe("button with label 'Right' on DPAD"), + // Channel buttons (Nth(1) - second occurrence) + "Channel Down" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Down", Exact = true }).Nth(1) + .Describe("button with label 'Down' for Channel"), + "Channel Up" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Up", Exact = true }).Nth(1) + .Describe("button with label 'Up' for Channel"), + // Volume buttons (Nth(2) - third occurrence) + "Volume Down" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Down", Exact = true }).Nth(2) + .Describe("button with label 'Down' for Volume"), + "Volume Up" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Up", Exact = true }).Nth(2) + .Describe("button with label 'Up' for Volume"), _ => CurrentPage.GetByRole(AriaRole.Button, new() { Name = label, Exact = true }) .Describe($"button with label '{label}'") }; diff --git a/test/_doc_EndToEndTests.md b/test/_doc_EndToEndTests.md index 61affeca..10894366 100644 --- a/test/_doc_EndToEndTests.md +++ b/test/_doc_EndToEndTests.md @@ -63,11 +63,19 @@ The three host projects are minimal, sharing most of their functionality from ot **Running Tests:** ```bash +# First-time setup: Build the Headless host so the generated Playwright script exists +dotnet build src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj + +# Install Playwright browsers (required for Headless E2E tests) +pwsh src/AdaptiveRemote.Headless/bin/Debug/net10.0/playwright.ps1 install chromium + +# Run accessibility tests dotnet test test/AdaptiveRemote.EndToEndTests.Host.Headless \ --filter "FullyQualifiedName~AccessibilityCompliance" ``` **Notes:** + - **REQUIRED:** Playwright browsers must be installed before running Headless E2E tests. Without them, tests will fail with JSON-RPC connection errors. - The color contrast accessibility test is only available in the Headless host. It is not available in WPF or Console hosts due to WebView2 limitations (the accessibility checker crashes WebView2). - Headless host is recommended for CI/CD as it requires no graphical environment. - Violations include rule ID, impact level, description, help text, and HTML snippet. From e374e63e6ab08a3479b96aea06fcf89bec0c70f3 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Sat, 18 Apr 2026 11:39:11 -0700 Subject: [PATCH 03/14] [ADR-176] Dynamic CSS for layout (#144) * Implement ADR-176: CSS extraction and stub IDynamicStylesheetProvider - Define IDynamicStylesheetProvider interface in Services/Layout/ - Extract #ROOT grid CSS from app.css into layout-grid.css embedded resource - Implement LayoutStylesheetProvider v1 returning the static grid CSS - Wire Remote.razor to inject +} diff --git a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs index f5b7f17b..69b742e3 100644 --- a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs @@ -30,6 +30,7 @@ internal static IServiceCollection AddRemoteServices(this IServiceCollection ser .AddCloudAssetServices() .AddScopedLifecycleService() .AddScoped() + .AddScoped() .AddSingleton() .Configure(configuration.GetSection(SettingsKeys.ProgrammaticSettings)) .Configure(configuration.GetSection("CloudSettings")); diff --git a/src/AdaptiveRemote.App/Services/Layout/IDynamicStylesheetProvider.cs b/src/AdaptiveRemote.App/Services/Layout/IDynamicStylesheetProvider.cs new file mode 100644 index 00000000..e644f696 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Layout/IDynamicStylesheetProvider.cs @@ -0,0 +1,9 @@ +namespace AdaptiveRemote.Services.Layout; + +/// +/// Scoped. Returns the CSS for the active layout in this scope. +/// +public interface IDynamicStylesheetProvider +{ + string? GetCss(); +} diff --git a/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs b/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs new file mode 100644 index 00000000..6571394b --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs @@ -0,0 +1,19 @@ +using System.Reflection; + +namespace AdaptiveRemote.Services.Layout; + +internal sealed class LayoutStylesheetProvider : IDynamicStylesheetProvider +{ + private static readonly string _css = LoadCss(); + + public string? GetCss() => _css; + + private static string LoadCss() + { + Assembly assembly = typeof(LayoutStylesheetProvider).Assembly; + using Stream stream = assembly.GetManifestResourceStream( + "AdaptiveRemote.Services.Layout.layout-grid.css")!; + using StreamReader reader = new(stream); + return reader.ReadToEnd(); + } +} diff --git a/src/AdaptiveRemote.App/Services/Layout/layout-grid.css b/src/AdaptiveRemote.App/Services/Layout/layout-grid.css new file mode 100644 index 00000000..707174d2 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Layout/layout-grid.css @@ -0,0 +1,190 @@ +#ROOT { + display: grid; + grid-template-rows: 6fr 3fr 1fr; + grid-template-columns: 3fr 2fr; + grid-gap: 20px; + width: 98vw; + height: 96vh; + padding: 2vh 1vw; +} +#ROOT #DPAD { + grid-row: 1; + grid-column: 1; + display: grid; + grid-template-rows: repeat(3, 1fr); + grid-template-columns: repeat(3, 1fr); + grid-gap: 10px; +} +#ROOT #DPAD #UP { + grid-row: 1; + grid-column: 2; +} +#ROOT #DPAD #DOWN { + grid-row: 3; + grid-column: 2; +} +#ROOT #DPAD #LEFT { + grid-row: 2; + grid-column: 1; +} +#ROOT #DPAD #RIGHT { + grid-row: 2; + grid-column: 3; +} +#ROOT #DPAD #SELECT { + grid-row: 2; + grid-column: 2; +} +#ROOT #DPAD #POWER { + grid-row: 1; + grid-column: 1; + margin: 20px; + background-color: #aa2525; + border-color: #561313; + border-width: 2px; + color: black; +} +#ROOT #DPAD #POWER:hover { + background-color: #bf2a2a; +} +#ROOT #DPAD #POWER:active, +#ROOT #DPAD #POWER.btn-active { + background-color: #d74545; +} +#ROOT #DPAD #POWER.btn-not-programmed { + color: #aa2525; + border-color: #aa2525; + background-color: #222; + border-width: 5px; +} +#ROOT #DPAD #POWER.btn-not-programmed:hover { + background-color: #2f2f2f; +} +#ROOT #DPAD #POWER.btn-not-programmed:active, +#ROOT #DPAD #POWER.btn-not-programmed.btn-active { + background-color: #484848; +} +#ROOT #DPAD #POWER.btn-disabled { + background-color: #888888; + color: #3c3c3c; + border-color: #555555; +} +#ROOT #DPAD #POWERON { + display: none; +} +#ROOT #DPAD #POWEROFF { + display: none; +} +#ROOT #DPAD #BACK { + grid-row: 3; + grid-column: 1; + margin: 20px; +} +#ROOT #WELL { + grid-row: 1; + grid-column: 2; + margin: -0.5%; +} +#ROOT #WELL > button { + width: 49%; + height: 19%; + margin: 0.5%; +} +#ROOT #PLAYBACK { + grid-row: 2; + grid-column: 1; + display: grid; + grid-template-rows: repeat(3, 1fr); + grid-template-columns: repeat(5, 1fr); + grid-gap: 10px; +} +#ROOT #PLAYBACK #REPLAY { + grid-row: 1; + grid-column: 1; +} +#ROOT #PLAYBACK #PLAY { + grid-row: 1; + grid-column: 2; +} +#ROOT #PLAYBACK #PAUSE { + grid-row: 1; + grid-column: 3; +} +#ROOT #PLAYBACK #RECORD { + grid-row: 1; + grid-column: 4; +} +#ROOT #PLAYBACK #SKIP { + grid-row: 1; + grid-column: 5; +} +#ROOT #CHANNELANDVOLUME { + grid-row: 2; + grid-column: 2; + display: grid; + grid-template-rows: 36pt 1fr 1fr; + grid-template-columns: 2fr 1fr 2fr; + grid-gap: 10px; +} +#ROOT #CHANNELANDVOLUME:before { + grid-row: 1; + grid-column: 1; + font-size: 36pt; + font-weight: bold; + color: #62b0ff; + content: "Channel"; + text-align: center; +} +#ROOT #CHANNELANDVOLUME:after { + grid-row: 1; + grid-column: 3; + font-size: 36pt; + font-weight: bold; + color: #62b0ff; + content: "Volume"; + text-align: center; +} +#ROOT #CHANNELANDVOLUME #CHANNELUP { + grid-row: 2; + grid-column: 1; +} +#ROOT #CHANNELANDVOLUME #CHANNELDOWN { + grid-row: 3; + grid-column: 1; +} +#ROOT #CHANNELANDVOLUME #VOLUMEUP { + grid-row: 2; + grid-column: 3; +} +#ROOT #CHANNELANDVOLUME #VOLUMEDOWN { + grid-row: 3; + grid-column: 3; +} +#ROOT #CHANNELANDVOLUME #MUTE { + grid-row: 2; + grid-column: 2; + grid-row-end: span 2; +} +#ROOT #GUTTER { + grid-row: 3; + grid-column-start: 1; + grid-column-end: span 2; + grid-gap: 20px; + display: grid; + grid-template-rows: 1fr; + grid-template-columns: 6fr 1fr 1fr; + grid-gap: 10px; +} +#ROOT #GUTTER #EXIT { + grid-row: 1; + grid-column: 3; +} +#ROOT #GUTTER #LEARN { + grid-row: 1; + grid-column: 2; + margin: 10px; +} +#ROOT #GUTTER #LISTENING { + grid-row: 1; + grid-column: 1; +} diff --git a/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs b/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs index 855f6163..107c805e 100644 --- a/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs +++ b/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs @@ -53,4 +53,13 @@ public partial interface IUITestService : IDisposable /// Cancellation token for the operation. /// The inner HTML of the first matching element, or null if not found or not visible. Task GetInnerHtmlFromElementWithCssClassAsync(string cssClass, CancellationToken cancellationToken); + + /// + /// Gets a CSS property value from the first stylesheet rule that matches the provided selector. + /// + /// The exact CSS selector text to match. + /// The CSS property name to read (for example, display). + /// Cancellation token for the operation. + /// The matching property value, or null if no matching rule/property exists. + Task GetStylesheetRulePropertyValueAsync(string selector, string propertyName, CancellationToken cancellationToken); } diff --git a/src/AdaptiveRemote.App/wwwroot/css/app.css b/src/AdaptiveRemote.App/wwwroot/css/app.css index 83ee5b09..6f45616e 100644 --- a/src/AdaptiveRemote.App/wwwroot/css/app.css +++ b/src/AdaptiveRemote.App/wwwroot/css/app.css @@ -84,197 +84,6 @@ div.conversation-speaking-message { left: 50%; transform: translate(-50%, -50%); } -#ROOT { - display: grid; - grid-template-rows: 6fr 3fr 1fr; - grid-template-columns: 3fr 2fr; - grid-gap: 10px; - grid-gap: 20px; - width: 98vw; - height: 96vh; - padding: 2vh 1vw; -} -#ROOT #DPAD { - grid-row: 1; - grid-column: 1; - display: grid; - grid-template-rows: repeat(3, 1fr); - grid-template-columns: repeat(3, 1fr); - grid-gap: 10px; -} -#ROOT #DPAD #UP { - grid-row: 1; - grid-column: 2; -} -#ROOT #DPAD #DOWN { - grid-row: 3; - grid-column: 2; -} -#ROOT #DPAD #LEFT { - grid-row: 2; - grid-column: 1; -} -#ROOT #DPAD #RIGHT { - grid-row: 2; - grid-column: 3; -} -#ROOT #DPAD #SELECT { - grid-row: 2; - grid-column: 2; -} -#ROOT #DPAD #POWER { - grid-row: 1; - grid-column: 1; - margin: 20px; - background-color: #aa2525; - border-color: #561313; - border-width: 2px; - color: black; -} -#ROOT #DPAD #POWER:hover { - background-color: #bf2a2a; -} -#ROOT #DPAD #POWER:active, -#ROOT #DPAD #POWER.btn-active { - background-color: #d74545; -} -#ROOT #DPAD #POWER.btn-not-programmed { - color: #aa2525; - border-color: #aa2525; - background-color: #222; - border-width: 5px; -} -#ROOT #DPAD #POWER.btn-not-programmed:hover { - background-color: #2f2f2f; -} -#ROOT #DPAD #POWER.btn-not-programmed:active, -#ROOT #DPAD #POWER.btn-not-programmed.btn-active { - background-color: #484848; -} -#ROOT #DPAD #POWER.btn-disabled { - background-color: #888888; - color: #3c3c3c; - border-color: #555555; -} -#ROOT #DPAD #POWERON { - display: none; -} -#ROOT #DPAD #POWEROFF { - display: none; -} -#ROOT #DPAD #BACK { - grid-row: 3; - grid-column: 1; - margin: 20px; -} -#ROOT #WELL { - grid-row: 1; - grid-column: 2; - margin: -0.5%; -} -#ROOT #WELL > button { - width: 49%; - height: 19%; - margin: 0.5%; -} -#ROOT #PLAYBACK { - grid-row: 2; - grid-column: 1; - display: grid; - grid-template-rows: repeat(3, 1fr); - grid-template-columns: repeat(5, 1fr); - grid-gap: 10px; -} -#ROOT #PLAYBACK #REPLAY { - grid-row: 1; - grid-column: 1; -} -#ROOT #PLAYBACK #PLAY { - grid-row: 1; - grid-column: 2; -} -#ROOT #PLAYBACK #PAUSE { - grid-row: 1; - grid-column: 3; -} -#ROOT #PLAYBACK #RECORD { - grid-row: 1; - grid-column: 4; -} -#ROOT #PLAYBACK #SKIP { - grid-row: 1; - grid-column: 5; -} -#ROOT #CHANNELANDVOLUME { - grid-row: 2; - grid-column: 2; - display: grid; - grid-template-rows: 36pt 1fr 1fr; - grid-template-columns: 2fr 1fr 2fr; - grid-gap: 10px; -} -#ROOT #CHANNELANDVOLUME:before { - grid-row: 1; - grid-column: 1; - font-size: 36pt; - font-weight: bold; - color: #62b0ff; - content: "Channel"; - text-align: center; -} -#ROOT #CHANNELANDVOLUME:after { - grid-row: 1; - grid-column: 3; - font-size: 36pt; - font-weight: bold; - color: #62b0ff; - content: "Volume"; - text-align: center; -} -#ROOT #CHANNELANDVOLUME #CHANNELUP { - grid-row: 2; - grid-column: 1; -} -#ROOT #CHANNELANDVOLUME #CHANNELDOWN { - grid-row: 3; - grid-column: 1; -} -#ROOT #CHANNELANDVOLUME #VOLUMEUP { - grid-row: 2; - grid-column: 3; -} -#ROOT #CHANNELANDVOLUME #VOLUMEDOWN { - grid-row: 3; - grid-column: 3; -} -#ROOT #CHANNELANDVOLUME #MUTE { - grid-row: 2; - grid-column: 2; - grid-row-end: span 2; -} -#ROOT #GUTTER { - grid-row: 3; - grid-column-start: 1; - grid-column-end: span 2; - grid-gap: 20px; - display: grid; - grid-template-rows: 1fr; - grid-template-columns: 6fr 1fr 1fr; - grid-gap: 10px; -} -#ROOT #GUTTER #EXIT { - grid-row: 1; - grid-column: 3; -} -#ROOT #GUTTER #LEARN { - grid-row: 1; - grid-column: 2; - margin: 10px; -} -#ROOT #GUTTER #LISTENING { - grid-row: 1; - grid-column: 1; -} #LifecycleTitle { height: 50vh; width: 100%; diff --git a/src/AdaptiveRemote.App/wwwroot/css/app.less b/src/AdaptiveRemote.App/wwwroot/css/app.less index fbd3bbec..f69f7b7b 100644 --- a/src/AdaptiveRemote.App/wwwroot/css/app.less +++ b/src/AdaptiveRemote.App/wwwroot/css/app.less @@ -6,7 +6,6 @@ html, body { @import "button_ui.less"; @import "conversation_ui.less"; -@import "layout.less"; @import "loading_screen.less"; #blazor-error-ui { diff --git a/src/AdaptiveRemote.App/wwwroot/css/app.min.css b/src/AdaptiveRemote.App/wwwroot/css/app.min.css index 97e45add..ce39b9e6 100644 --- a/src/AdaptiveRemote.App/wwwroot/css/app.min.css +++ b/src/AdaptiveRemote.App/wwwroot/css/app.min.css @@ -1 +1 @@ -html,body{font-family:'Segoe UI',Helvetica,Arial,sans-serif;font-size:48px;font-weight:bold;background-color:#222;}html,body{margin:0;}.btn-primary{color:#222;border-radius:15px;font-size:36pt;font-weight:bold;cursor:pointer;background-color:#62b0ff;border-color:#007dfb;border-width:2px;}.btn-primary:hover{background-color:#7cbdff;}.btn-primary:active,.btn-primary.btn-active{background-color:#afd6ff;}.btn-primary.btn-not-programmed{color:#62b0ff;border-color:#62b0ff;background-color:#222;border-width:5px;}.btn-primary.btn-not-programmed:hover{background-color:#2f2f2f;}.btn-primary.btn-not-programmed:active,.btn-primary.btn-not-programmed.btn-active{background-color:#484848;}.btn-primary.btn-disabled{background-color:#888;color:#3c3c3c;border-color:#555;}div.conversation-border{pointer-events:none;position:fixed;top:-20px;left:-20px;border-color:#ffea00;border-style:solid;border-width:30px;border-radius:45px;}div.conversation-border div{width:100vw;height:100vh;margin:-10px;}div.conversation-status-message{color:#ffea00;text-align:center;padding:20px;border-radius:15px;}div.conversation-status-message.clickable{cursor:pointer;}div.conversation-status-message.clickable:hover{background-color:#484848;}div.conversation-speaking-message{font-size:24px;background-color:#222;border-radius:15px;border:5px solid #ffea00;margin:-5px;color:#fff;padding:0 48px;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);}#ROOT{display:grid;grid-template-rows:6fr 3fr 1fr;grid-template-columns:3fr 2fr;grid-gap:10px;grid-gap:20px;width:98vw;height:96vh;padding:2vh 1vw;}#ROOT #DPAD{grid-row:1;grid-column:1;display:grid;grid-template-rows:repeat(3,1fr);grid-template-columns:repeat(3,1fr);grid-gap:10px;}#ROOT #DPAD #UP{grid-row:1;grid-column:2;}#ROOT #DPAD #DOWN{grid-row:3;grid-column:2;}#ROOT #DPAD #LEFT{grid-row:2;grid-column:1;}#ROOT #DPAD #RIGHT{grid-row:2;grid-column:3;}#ROOT #DPAD #SELECT{grid-row:2;grid-column:2;}#ROOT #DPAD #POWER{grid-row:1;grid-column:1;margin:20px;background-color:#aa2525;border-color:#561313;border-width:2px;color:#000;}#ROOT #DPAD #POWER:hover{background-color:#bf2a2a;}#ROOT #DPAD #POWER:active,#ROOT #DPAD #POWER.btn-active{background-color:#d74545;}#ROOT #DPAD #POWER.btn-not-programmed{color:#aa2525;border-color:#aa2525;background-color:#222;border-width:5px;}#ROOT #DPAD #POWER.btn-not-programmed:hover{background-color:#2f2f2f;}#ROOT #DPAD #POWER.btn-not-programmed:active,#ROOT #DPAD #POWER.btn-not-programmed.btn-active{background-color:#484848;}#ROOT #DPAD #POWER.btn-disabled{background-color:#888;color:#3c3c3c;border-color:#555;}#ROOT #DPAD #POWERON{display:none;}#ROOT #DPAD #POWEROFF{display:none;}#ROOT #DPAD #BACK{grid-row:3;grid-column:1;margin:20px;}#ROOT #WELL{grid-row:1;grid-column:2;margin:-.5%;}#ROOT #WELL>button{width:49%;height:19%;margin:.5%;}#ROOT #PLAYBACK{grid-row:2;grid-column:1;display:grid;grid-template-rows:repeat(3,1fr);grid-template-columns:repeat(5,1fr);grid-gap:10px;}#ROOT #PLAYBACK #REPLAY{grid-row:1;grid-column:1;}#ROOT #PLAYBACK #PLAY{grid-row:1;grid-column:2;}#ROOT #PLAYBACK #PAUSE{grid-row:1;grid-column:3;}#ROOT #PLAYBACK #RECORD{grid-row:1;grid-column:4;}#ROOT #PLAYBACK #SKIP{grid-row:1;grid-column:5;}#ROOT #CHANNELANDVOLUME{grid-row:2;grid-column:2;display:grid;grid-template-rows:36pt 1fr 1fr;grid-template-columns:2fr 1fr 2fr;grid-gap:10px;}#ROOT #CHANNELANDVOLUME:before{grid-row:1;grid-column:1;font-size:36pt;font-weight:bold;color:#62b0ff;content:"Channel";text-align:center;}#ROOT #CHANNELANDVOLUME:after{grid-row:1;grid-column:3;font-size:36pt;font-weight:bold;color:#62b0ff;content:"Volume";text-align:center;}#ROOT #CHANNELANDVOLUME #CHANNELUP{grid-row:2;grid-column:1;}#ROOT #CHANNELANDVOLUME #CHANNELDOWN{grid-row:3;grid-column:1;}#ROOT #CHANNELANDVOLUME #VOLUMEUP{grid-row:2;grid-column:3;}#ROOT #CHANNELANDVOLUME #VOLUMEDOWN{grid-row:3;grid-column:3;}#ROOT #CHANNELANDVOLUME #MUTE{grid-row:2;grid-column:2;grid-row-end:span 2;}#ROOT #GUTTER{grid-row:3;grid-column-start:1;grid-column-end:span 2;grid-gap:20px;display:grid;grid-template-rows:1fr;grid-template-columns:6fr 1fr 1fr;grid-gap:10px;}#ROOT #GUTTER #EXIT{grid-row:1;grid-column:3;}#ROOT #GUTTER #LEARN{grid-row:1;grid-column:2;margin:10px;}#ROOT #GUTTER #LISTENING{grid-row:1;grid-column:1;}#LifecycleTitle{height:50vh;width:100%;color:#fff;position:relative;}#LifecycleTitle>div{text-align:center;position:absolute;width:100%;bottom:0;}#LifecycleTaskDescription{text-align:center;color:#fff;font-size:36px;}#blazor-error-ui{background:#ffffe0;bottom:0;box-shadow:0 -1px 2px rgba(0,0,0,.2);display:none;left:0;padding:.6rem 1.25rem .7rem 1.25rem;position:fixed;width:100%;z-index:1000;}#blazor-error-ui .dismiss{cursor:pointer;position:absolute;right:.75rem;top:.5rem;} \ No newline at end of file +html,body{font-family:'Segoe UI',Helvetica,Arial,sans-serif;font-size:48px;font-weight:bold;background-color:#222;}html,body{margin:0;}.btn-primary{color:#222;border-radius:15px;font-size:36pt;font-weight:bold;cursor:pointer;background-color:#62b0ff;border-color:#007dfb;border-width:2px;}.btn-primary:hover{background-color:#7cbdff;}.btn-primary:active,.btn-primary.btn-active{background-color:#afd6ff;}.btn-primary.btn-not-programmed{color:#62b0ff;border-color:#62b0ff;background-color:#222;border-width:5px;}.btn-primary.btn-not-programmed:hover{background-color:#2f2f2f;}.btn-primary.btn-not-programmed:active,.btn-primary.btn-not-programmed.btn-active{background-color:#484848;}.btn-primary.btn-disabled{background-color:#888;color:#3c3c3c;border-color:#555;}div.conversation-border{pointer-events:none;position:fixed;top:-20px;left:-20px;border-color:#ffea00;border-style:solid;border-width:30px;border-radius:45px;}div.conversation-border div{width:100vw;height:100vh;margin:-10px;}div.conversation-status-message{color:#ffea00;text-align:center;padding:20px;border-radius:15px;}div.conversation-status-message.clickable{cursor:pointer;}div.conversation-status-message.clickable:hover{background-color:#484848;}div.conversation-speaking-message{font-size:24px;background-color:#222;border-radius:15px;border:5px solid #ffea00;margin:-5px;color:#fff;padding:0 48px;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);}#LifecycleTitle{height:50vh;width:100%;color:#fff;position:relative;}#LifecycleTitle>div{text-align:center;position:absolute;width:100%;bottom:0;}#LifecycleTaskDescription{text-align:center;color:#fff;font-size:36px;}#blazor-error-ui{background:#ffffe0;bottom:0;box-shadow:0 -1px 2px rgba(0,0,0,.2);display:none;left:0;padding:.6rem 1.25rem .7rem 1.25rem;position:fixed;width:100%;z-index:1000;}#blazor-error-ui .dismiss{cursor:pointer;position:absolute;right:.75rem;top:.5rem;} \ No newline at end of file diff --git a/test/AdaptiveRemote.App.Tests/Services/Layout/LayoutStylesheetProviderTests.cs b/test/AdaptiveRemote.App.Tests/Services/Layout/LayoutStylesheetProviderTests.cs new file mode 100644 index 00000000..490179af --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/Layout/LayoutStylesheetProviderTests.cs @@ -0,0 +1,20 @@ +using FluentAssertions; + +namespace AdaptiveRemote.Services.Layout; + +[TestClass] +public class LayoutStylesheetProviderTests +{ + [TestMethod] + public void LayoutStylesheetProvider_GetCss_ReturnsNonNullContent() + { + // Arrange + LayoutStylesheetProvider sut = new(); + + // Act + string? css = sut.GetCss(); + + // Assert + css.Should().NotBeNullOrEmpty(); + } +} diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature index aa4488e6..4b35f786 100644 --- a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature @@ -3,6 +3,16 @@ Feature: Layout button verification I want all expected buttons from the layout to be present and accessible So that I can control my TV and AV equipment +Scenario: Layout CSS rules are present + Given the application is in the Ready phase + Then the stylesheet selector '#ROOT' should define 'display' as 'grid' + And the stylesheet selector '#ROOT' should define 'grid-template-rows' as '6fr 3fr 1fr' + And the stylesheet selector '#ROOT' should define 'grid-template-columns' as '3fr 2fr' + And the stylesheet selector '#ROOT' should define 'grid-gap' as '20px' + When I click on the 'Exit' button + And I wait for the application to shut down + Then I should not see any error messages in the logs + Scenario: All expected buttons from layout are present Given the application is not running When I start the application diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs index f94acc3b..37ad1222 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs @@ -60,4 +60,18 @@ public void ThenIShouldNotSeeAModalMessage() { Host.UI.WaitForNoModalMessage(); } + + [Then(@"the stylesheet selector {string} should define {string} as {string}")] + public void ThenTheStylesheetSelectorShouldDefineAs(string selector, string propertyName, string expectedValue) + { + string? actualValue = Host.UI.GetStylesheetRulePropertyValue(selector, propertyName); + Assert.AreEqual( + expectedValue, + actualValue, + "Expected selector '{0}' to define '{1}' as '{2}', but was '{3}'.", + selector, + propertyName, + expectedValue, + actualValue ?? ""); + } } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs index e3565d4d..502ddba8 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs @@ -270,6 +270,28 @@ public static bool WaitForButtonProgrammed(this IUITestService service, string l return WaitHelpers.WaitForState(button.IsProgrammedAsync, programmed, timeout); } + /// + /// Gets a stylesheet rule property value using default timeout (synchronous wrapper; blocks the calling thread). + /// + /// The UI test service. + /// The exact CSS selector text to match. + /// The CSS property name to read. + /// Optional timeout for the operation. + /// The property value if found; otherwise null. + public static string? GetStylesheetRulePropertyValue(this IUITestService service, string selector, string propertyName, int timeoutInSeconds = DefaultUITimeoutInSeconds) + => service.GetStylesheetRulePropertyValue(selector, propertyName, TimeSpan.FromSeconds(timeoutInSeconds)); + + /// + /// Gets a stylesheet rule property value using explicit timeout (synchronous wrapper; blocks the calling thread). + /// + /// The UI test service. + /// The exact CSS selector text to match. + /// The CSS property name to read. + /// Timeout for the operation. + /// The property value if found; otherwise null. + public static string? GetStylesheetRulePropertyValue(this IUITestService service, string selector, string propertyName, TimeSpan timeout) + => WaitHelpers.WaitForAsyncTask(ct => service.GetStylesheetRulePropertyValueAsync(selector, propertyName, ct), timeout); + private static IUIButtonTestObject GetButtonByLabel(this IUITestService service, string label, TimeSpan timeout) { IUIButtonTestObject? button = null; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs index 2283ae6e..ce80d0c5 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs @@ -171,6 +171,38 @@ private ILocator GetTextLocator(string text) } } + public async Task GetStylesheetRulePropertyValueAsync( + string selector, + string propertyName, + CancellationToken cancellationToken = default) + { + return await CurrentPage.EvaluateAsync( + @"({ selector, propertyName }) => { + for (const stylesheet of Array.from(document.styleSheets)) { + let rules; + try { + rules = stylesheet.cssRules; + } catch { + continue; + } + + for (const rule of Array.from(rules)) { + if (rule.type !== CSSRule.STYLE_RULE) { + continue; + } + + if (rule.selectorText === selector) { + const value = rule.style.getPropertyValue(propertyName); + return value ? value.trim() : null; + } + } + } + + return null; + }", + new { selector, propertyName }); + } + public void Dispose() { GC.SuppressFinalize(this); From 2d54e5d8545a920f71fec90a3a75a6ef74b4b2a0 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Sun, 19 Apr 2026 08:38:14 -0700 Subject: [PATCH 04/14] [ADR-177] Mapping from CompiledLayout DTO to internal view models (#146) --- .../CloudAssetServiceExtensions.cs | 5 + .../Configuration/HostBuilderExtensions.cs | 4 +- src/AdaptiveRemote.App/Models/IRCommand.cs | 5 +- src/AdaptiveRemote.App/Models/TiVoCommand.cs | 5 +- .../CloudAssets/CloudAssetOrchestrator.cs | 94 +++++---- .../CloudAssets/CloudAssetStoreExtensions.cs | 28 +-- .../Layout/RemoteLayoutDefinitionService.cs | 77 +++++--- .../RemoteLayoutDefinitionServiceTests.cs | 186 +++++++++++++----- 8 files changed, 258 insertions(+), 146 deletions(-) diff --git a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs index da862fbe..3fd77152 100644 --- a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs @@ -12,4 +12,9 @@ internal static IServiceCollection AddCloudAssetServices(this IServiceCollection .AddSingleton() .AddSingleton(sp => sp.GetRequiredService()) .AddHostedService(sp => sp.GetRequiredService()); + + internal static IServiceCollection AddScopedCloudAsset( + this IServiceCollection services, string name) + where T : class + => services.AddScoped(sp => sp.GetRequiredService().Get(name)); } diff --git a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs index 69b742e3..24ef8d16 100644 --- a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs @@ -1,4 +1,5 @@ -using AdaptiveRemote.Services; +using AdaptiveRemote.Contracts; +using AdaptiveRemote.Services; using AdaptiveRemote.Services.CloudAssets; using AdaptiveRemote.Services.Commands; using AdaptiveRemote.Services.Layout; @@ -28,6 +29,7 @@ internal static IServiceCollection AddRemoteServices(this IServiceCollection ser => services .AddApplicationLifecycleServices() .AddCloudAssetServices() + .AddScopedCloudAsset("layout") .AddScopedLifecycleService() .AddScoped() .AddScoped() diff --git a/src/AdaptiveRemote.App/Models/IRCommand.cs b/src/AdaptiveRemote.App/Models/IRCommand.cs index 81650782..ebd576a2 100644 --- a/src/AdaptiveRemote.App/Models/IRCommand.cs +++ b/src/AdaptiveRemote.App/Models/IRCommand.cs @@ -9,8 +9,9 @@ public IRCommand( string? cssid = null, string? glyph = null, string? reverse = null, - string? speakName = null) - : base(name, placement, label, cssid, glyph, reverse, Phrases.Conversation_Sent(speakName ?? name)) + string? speakName = null, + string? speakPhrase = null) + : base(name, placement, label, cssid, glyph, reverse, speakPhrase ?? Phrases.Conversation_Sent(speakName ?? name)) { } } diff --git a/src/AdaptiveRemote.App/Models/TiVoCommand.cs b/src/AdaptiveRemote.App/Models/TiVoCommand.cs index 35a84416..b8ccdc84 100644 --- a/src/AdaptiveRemote.App/Models/TiVoCommand.cs +++ b/src/AdaptiveRemote.App/Models/TiVoCommand.cs @@ -10,8 +10,9 @@ public TiVoCommand( string? cssid = null, string? glyph = null, string? reverse = null, - string? speakName = null) - : base(name, placement, label, cssid ?? commandId, glyph, reverse, Phrases.Conversation_Sent(speakName ?? name)) + string? speakName = null, + string? speakPhrase = null) + : base(name, placement, label, cssid ?? commandId, glyph, reverse, speakPhrase ?? Phrases.Conversation_Sent(speakName ?? name)) { CommandId = commandId ?? name.ToUpperInvariant(); } diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs index 6353f5f4..9dbeceb7 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs @@ -1,14 +1,9 @@ -using AdaptiveRemote.Models; +using AdaptiveRemote.Contracts; using AdaptiveRemote.Services.Lifecycle; using Microsoft.Extensions.Hosting; namespace AdaptiveRemote.Services.CloudAssets; -/// -/// Stub orchestrator that inlines the same hardcoded commands as StaticCommandGroupProvider. -/// Stores the LayoutGroup root directly in CloudAssetStore and immediately signals -/// IPreScopeInitializer complete. No file I/O, no HTTP. -/// internal class CloudAssetOrchestrator : BackgroundService, IPreScopeInitializer { private readonly ICloudAssetStore _store; @@ -21,50 +16,51 @@ public CloudAssetOrchestrator(ICloudAssetStore store) protected override Task ExecuteAsync(CancellationToken stoppingToken) { - // Inline the same hardcoded layout from StaticCommandGroupProvider - LayoutGroup layout = new("ROOT", - [ - new LayoutGroup("DPAD", + CompiledLayout layout = new( + Id: Guid.Empty, + RawLayoutId: Guid.Empty, + UserId: "stub", + IsActive: true, + Version: 1, + Elements: [ - new TiVoCommand("Up", reverse: "Down"), - new TiVoCommand("Down", reverse: "Up"), - new TiVoCommand("Left", reverse: "Right"), - new TiVoCommand("Right", reverse: "Left"), - new TiVoCommand("Select"), - new TiVoCommand("Back"), - new IRCommand("Power", reverse: "Power"), - new IRCommand("PowerOn", reverse: "PowerOff"), - new IRCommand("PowerOff", reverse: "PowerOn"), - ]), - new LayoutGroup("WELL", - [ - new TiVoCommand("TiVo"), - new TiVoCommand("Netflix"), - new TiVoCommand("Guide"), - ]), - new LayoutGroup("PLAYBACK", - [ - new TiVoCommand("Play", reverse: "Pause"), - new TiVoCommand("Pause", reverse: "Play"), - new TiVoCommand("Record"), - new TiVoCommand("Skip", reverse: "Replay"), - new TiVoCommand("Replay", reverse: "Skip"), - ]), - new LayoutGroup("CHANNELANDVOLUME", - [ - new TiVoCommand("ChannelUp", reverse: "ChannelDown", label: "Up", speakName: "Channel Up"), - new TiVoCommand("ChannelDown", reverse: "ChannelUp", label: "Down", speakName: "Channel Down"), - new IRCommand("VolumeUp", reverse: "VolumeDown", label: "Up", speakName: "Volume Up"), - new IRCommand("VolumeDown", reverse: "VolumeUp", label: "Down", speakName: "Volume Down"), - new IRCommand("Mute", reverse: "Mute", label: "Mute"), - ]), - new LayoutGroup("GUTTER", - [ - new ConversationView(), - new LifecycleCommand("Learn"), - new LifecycleCommand("Exit", speakPhrase: Phrases.Conversation_Goodbye) - ]) - ]); + new LayoutGroupDefinitionDto("DPAD", + [ + new CommandDefinitionDto(CommandType.TiVo, "Up", "Up", null, "Sent Up", "Down", "Up"), + new CommandDefinitionDto(CommandType.TiVo, "Down", "Down", null, "Sent Down", "Up", "Down"), + new CommandDefinitionDto(CommandType.TiVo, "Left", "Left", null, "Sent Left", "Right", "Left"), + new CommandDefinitionDto(CommandType.TiVo, "Right", "Right", null, "Sent Right", "Left", "Right"), + new CommandDefinitionDto(CommandType.TiVo, "Select", "Select", null, "Sent Select", null, "Select"), + new CommandDefinitionDto(CommandType.TiVo, "Back", "Back", null, "Sent Back", null, "Back"), + new CommandDefinitionDto(CommandType.IR, "Power", "Power", null, "Sent Power", "Power", "Power"), + new CommandDefinitionDto(CommandType.IR, "PowerOn", "PowerOn", null, "Sent PowerOn", "PowerOff", "PowerOn"), + new CommandDefinitionDto(CommandType.IR, "PowerOff", "PowerOff", null, "Sent PowerOff", "PowerOn", "PowerOff"), + ]), + new LayoutGroupDefinitionDto("WELL", + [ + new CommandDefinitionDto(CommandType.TiVo, "TiVo", "TiVo", null, "Sent TiVo", null, "TiVo"), + new CommandDefinitionDto(CommandType.TiVo, "Netflix", "Netflix", null, "Sent Netflix", null, "Netflix"), + new CommandDefinitionDto(CommandType.TiVo, "Guide", "Guide", null, "Sent Guide", null, "Guide"), + ]), + new LayoutGroupDefinitionDto("PLAYBACK", + [ + new CommandDefinitionDto(CommandType.TiVo, "Play", "Play", null, "Sent Play", "Pause", "Play"), + new CommandDefinitionDto(CommandType.TiVo, "Pause", "Pause", null, "Sent Pause", "Play", "Pause"), + new CommandDefinitionDto(CommandType.TiVo, "Record", "Record", null, "Sent Record", null, "Record"), + new CommandDefinitionDto(CommandType.TiVo, "Skip", "Skip", null, "Sent Skip", "Replay", "Skip"), + new CommandDefinitionDto(CommandType.TiVo, "Replay", "Replay", null, "Sent Replay", "Skip", "Replay"), + ]), + new LayoutGroupDefinitionDto("CHANNELANDVOLUME", + [ + new CommandDefinitionDto(CommandType.TiVo, "ChannelUp", "Up", null, "Sent Channel Up", "ChannelDown", "ChannelUp"), + new CommandDefinitionDto(CommandType.TiVo, "ChannelDown", "Down", null, "Sent Channel Down", "ChannelUp", "ChannelDown"), + new CommandDefinitionDto(CommandType.IR, "VolumeUp", "Up", null, "Sent Volume Up", "VolumeDown", "VolumeUp"), + new CommandDefinitionDto(CommandType.IR, "VolumeDown", "Down", null, "Sent Volume Down", "VolumeUp", "VolumeDown"), + new CommandDefinitionDto(CommandType.IR, "Mute", "Mute", null, "Sent Mute", "Mute", "Mute"), + ]), + ], + CssDefinitions: "", + CompiledAt: DateTimeOffset.UtcNow); _store.SetLayout(layout); _initCompleted.SetResult(); diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs index 897f6fdf..9b387a7d 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs @@ -1,32 +1,14 @@ -using AdaptiveRemote.Models; +using AdaptiveRemote.Contracts; namespace AdaptiveRemote.Services.CloudAssets; -/// -/// Extension methods for ICloudAssetStore that encapsulate asset names for common assets. -/// internal static class CloudAssetStoreExtensions { private const string LayoutAssetName = "layout"; - /// - /// Retrieves the layout asset from the store. - /// - /// The cloud asset store - /// The layout group - /// Thrown if layout not found or wrong type - public static LayoutGroup GetLayout(this ICloudAssetStore store) - { - return store.Get(LayoutAssetName); - } + public static CompiledLayout GetLayout(this ICloudAssetStore store) + => store.Get(LayoutAssetName); - /// - /// Stores the layout asset in the store. - /// - /// The cloud asset store - /// The layout group to store - public static void SetLayout(this ICloudAssetStore store, LayoutGroup layout) - { - store.Set(LayoutAssetName, layout); - } + public static void SetLayout(this ICloudAssetStore store, CompiledLayout layout) + => store.Set(LayoutAssetName, layout); } diff --git a/src/AdaptiveRemote.App/Services/Layout/RemoteLayoutDefinitionService.cs b/src/AdaptiveRemote.App/Services/Layout/RemoteLayoutDefinitionService.cs index 8dfb9cb8..66ceac25 100644 --- a/src/AdaptiveRemote.App/Services/Layout/RemoteLayoutDefinitionService.cs +++ b/src/AdaptiveRemote.App/Services/Layout/RemoteLayoutDefinitionService.cs @@ -1,37 +1,70 @@ +using AdaptiveRemote.Contracts; using AdaptiveRemote.Models; -using AdaptiveRemote.Services.CloudAssets; namespace AdaptiveRemote.Services.Layout; -/// -/// RemoteLayoutDefinitionService v1: reads LayoutGroup directly from -/// ICloudAssetStore.Get<LayoutGroup>("layout") and returns it as RemoteRoot. -/// No DTO mapping in this version. -/// internal class RemoteLayoutDefinitionService : IRemoteDefinitionService { - private readonly ICloudAssetStore _store; + public RemoteLayoutElement RemoteRoot { get; } - public RemoteLayoutDefinitionService(ICloudAssetStore store) + public RemoteLayoutDefinitionService(CompiledLayout layout) { - _store = store; + RemoteRoot = BuildRoot(layout); } - public RemoteLayoutElement RemoteRoot + private static RemoteLayoutElement BuildRoot(CompiledLayout layout) { - get + if (layout.Elements.Any(e => e.CssId == "GUTTER")) { - try - { - return _store.GetLayout(); - } - catch (InvalidOperationException ex) - { - throw new InvalidOperationException( - "Failed to load layout from CloudAssetStore. " + - "Ensure CloudAssetOrchestrator has completed initialization before the first scope is created.", - ex); - } + throw new InvalidOperationException( + "The CompiledLayout already contains a GUTTER element. " + + "The client appends its own GUTTER; the server must not include one."); } + + List elements = layout.Elements + .Select(MapElement) + .ToList(); + elements.Add(BuildGutter()); + + return new LayoutGroup("ROOT", elements); } + + private static RemoteLayoutElement MapElement(LayoutElementDto dto) => dto switch + { + LayoutGroupDefinitionDto group => new LayoutGroup( + group.CssId, + group.Children.Select(MapElement).ToList()), + + CommandDefinitionDto cmd => cmd.Type switch + { + CommandType.TiVo => new TiVoCommand( + cmd.Name, placement: null, label: cmd.Label, + cssid: cmd.CssId, glyph: cmd.Glyph, reverse: cmd.Reverse, + speakPhrase: cmd.SpeakPhrase), + + CommandType.IR => new IRCommand( + cmd.Name, placement: null, label: cmd.Label, + cssid: cmd.CssId, glyph: cmd.Glyph, reverse: cmd.Reverse, + speakPhrase: cmd.SpeakPhrase), + + CommandType.Lifecycle => new LifecycleCommand( + cmd.Name, placement: null, label: cmd.Label, + cssid: cmd.CssId, glyph: cmd.Glyph, reverse: cmd.Reverse, + speakPhrase: cmd.SpeakPhrase), + + _ => throw new InvalidOperationException( + $"Unknown CommandType '{cmd.Type}' on command '{cmd.Name}'.") + }, + + _ => throw new InvalidOperationException( + $"Unknown LayoutElementDto type '{dto.GetType().Name}'.") + }; + + private static LayoutGroup BuildGutter() => + new("GUTTER", + [ + new ConversationView(), + new LifecycleCommand("Learn"), + new LifecycleCommand("Exit", speakPhrase: Phrases.Conversation_Goodbye), + ]); } diff --git a/test/AdaptiveRemote.App.Tests/Services/Layout/RemoteLayoutDefinitionServiceTests.cs b/test/AdaptiveRemote.App.Tests/Services/Layout/RemoteLayoutDefinitionServiceTests.cs index b27a4c79..a242dc44 100644 --- a/test/AdaptiveRemote.App.Tests/Services/Layout/RemoteLayoutDefinitionServiceTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/Layout/RemoteLayoutDefinitionServiceTests.cs @@ -1,102 +1,194 @@ +using AdaptiveRemote.Contracts; using AdaptiveRemote.Models; -using AdaptiveRemote.Services.CloudAssets; using FluentAssertions; -using Moq; namespace AdaptiveRemote.Services.Layout; [TestClass] public class RemoteLayoutDefinitionServiceTests { - private readonly Mock _mockStore = new(); + private static CompiledLayout MakeLayout(params LayoutElementDto[] elements) => + new(Guid.Empty, Guid.Empty, "stub", true, 1, elements, "", DateTimeOffset.UtcNow); - [TestCleanup] - public void VerifyMocks() + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_MapsTiVoCommandCorrectly() { - _mockStore.Verify(); + // Arrange + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("GROUP", + [ + new CommandDefinitionDto(CommandType.TiVo, "Play", "Play", null, "Sent Play", "Pause", "Play"), + ])); + + RemoteLayoutDefinitionService sut = new(layout); + + // Act + LayoutGroup root = (LayoutGroup)sut.RemoteRoot; + LayoutGroup group = (LayoutGroup)root.Elements.First(); + TiVoCommand cmd = (TiVoCommand)group.Elements.First(); + + // Assert + cmd.Name.Should().Be("Play"); + cmd.Reverse.Should().Be("Pause"); + cmd.SpeakPhrase.Should().Be("Sent Play"); } [TestMethod] - public void RemoteLayoutDefinitionService_RemoteRoot_ReturnsStoreContents() + public void RemoteLayoutDefinitionService_RemoteRoot_MapsIRCommandCorrectly() { // Arrange - LayoutGroup expectedLayout = new("ROOT", []); - _mockStore.Setup(s => s.Get("layout")) - .Returns(expectedLayout) - .Verifiable(); + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("GROUP", + [ + new CommandDefinitionDto(CommandType.IR, "VolumeUp", "Up", null, "Sent Volume Up", "VolumeDown", "VolumeUp"), + ])); - RemoteLayoutDefinitionService sut = new(_mockStore.Object); + RemoteLayoutDefinitionService sut = new(layout); // Act - RemoteLayoutElement actualRoot = sut.RemoteRoot; + LayoutGroup root = (LayoutGroup)sut.RemoteRoot; + LayoutGroup group = (LayoutGroup)root.Elements.First(); + RemoteLayoutElement cmd = group.Elements.First(); // Assert - actualRoot.Should().BeSameAs(expectedLayout); + cmd.Should().BeOfType(); + cmd.As().Name.Should().Be("VolumeUp"); + cmd.As().SpeakPhrase.Should().Be("Sent Volume Up"); } [TestMethod] - public void RemoteLayoutDefinitionService_RemoteRoot_EmptyStoreThrowsDescriptiveError() + public void RemoteLayoutDefinitionService_RemoteRoot_MapsLifecycleCommandCorrectly() { // Arrange - _mockStore.Setup(s => s.Get("layout")) - .Throws(new InvalidOperationException("Asset 'layout' not found in store.")) - .Verifiable(); + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("GROUP", + [ + new CommandDefinitionDto(CommandType.Lifecycle, "Exit", "Exit", null, "Goodbye", null, "Exit"), + ])); - RemoteLayoutDefinitionService sut = new(_mockStore.Object); + RemoteLayoutDefinitionService sut = new(layout); // Act - Action act = () => _ = sut.RemoteRoot; + LayoutGroup root = (LayoutGroup)sut.RemoteRoot; + LayoutGroup group = (LayoutGroup)root.Elements.First(); + RemoteLayoutElement cmd = group.Elements.First(); // Assert - act.Should().Throw() - .WithMessage("Failed to load layout from CloudAssetStore.*"); + cmd.Should().BeOfType(); + cmd.As().Name.Should().Be("Exit"); } [TestMethod] - public void RemoteLayoutDefinitionService_RemoteRoot_WrongTypeThrowsDescriptiveError() + public void RemoteLayoutDefinitionService_RemoteRoot_MapsLayoutGroupCorrectly() { // Arrange - _mockStore.Setup(s => s.Get("layout")) - .Throws(new InvalidOperationException("Asset 'layout' is of type 'String', but 'LayoutGroup' was requested.")) - .Verifiable(); + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("DPAD", + [ + new CommandDefinitionDto(CommandType.TiVo, "Up", "Up", null, "Sent Up", "Down", "Up"), + new CommandDefinitionDto(CommandType.TiVo, "Down", "Down", null, "Sent Down", "Up", "Down"), + ])); - RemoteLayoutDefinitionService sut = new(_mockStore.Object); + RemoteLayoutDefinitionService sut = new(layout); // Act - Action act = () => _ = sut.RemoteRoot; + LayoutGroup root = (LayoutGroup)sut.RemoteRoot; + LayoutGroup dpad = (LayoutGroup)root.Elements.First(); // Assert - act.Should().Throw() - .WithMessage("Failed to load layout from CloudAssetStore.*"); + dpad.CSSID.Should().Be("DPAD"); + dpad.Elements.Should().HaveCount(2); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_GutterAlwaysAppendedAsLastChild() + { + // Arrange + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("DPAD", + [ + new CommandDefinitionDto(CommandType.TiVo, "Up", "Up", null, "Sent Up", null, "Up"), + ])); + + RemoteLayoutDefinitionService sut = new(layout); + + // Act + LayoutGroup root = (LayoutGroup)sut.RemoteRoot; + RemoteLayoutElement lastChild = root.Elements.Last(); + + // Assert + lastChild.Should().BeOfType(); + LayoutGroup gutter = (LayoutGroup)lastChild; + gutter.CSSID.Should().Be("GUTTER"); + + List gutterElements = gutter.Elements.ToList(); + gutterElements.Should().HaveCount(3); + gutterElements[0].Should().BeOfType(); + gutterElements[1].Should().BeOfType() + .Which.Name.Should().Be("Learn"); + gutterElements[2].Should().BeOfType() + .Which.Name.Should().Be("Exit"); } [TestMethod] - public void RemoteLayoutDefinitionService_RemoteRoot_ReturnsComplexLayout() + public void RemoteLayoutDefinitionService_RemoteRoot_UnknownCommandTypeThrows() { // Arrange - LayoutGroup expectedLayout = new("ROOT", - [ - new LayoutGroup("GROUP1", + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("GROUP", [ - new TiVoCommand("Up"), - new TiVoCommand("Down"), - ]), - new LayoutGroup("GROUP2", + new CommandDefinitionDto((CommandType)99, "Unknown", "Unknown", null, "Unknown", null, "Unknown"), + ])); + + // Act + Action act = () => _ = new RemoteLayoutDefinitionService(layout); + + // Assert + act.Should().Throw() + .WithMessage("*Unknown CommandType*"); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_GutterInLayoutThrowsDescriptiveError() + { + // Arrange + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("GUTTER", [ - new IRCommand("Power"), - ]) - ]); + new CommandDefinitionDto(CommandType.Lifecycle, "Exit", "Exit", null, "Goodbye", null, "Exit"), + ])); + + // Act + Action act = () => _ = new RemoteLayoutDefinitionService(layout); - _mockStore.Setup(s => s.Get("layout")) - .Returns(expectedLayout) - .Verifiable(); + // Assert + act.Should().Throw() + .WithMessage("*already contains a GUTTER*"); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_MapsNestedGroupsRecursively() + { + // Arrange + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("OUTER", + [ + new LayoutGroupDefinitionDto("INNER", + [ + new CommandDefinitionDto(CommandType.TiVo, "Select", "Select", null, "Sent Select", null, "Select"), + ]), + ])); - RemoteLayoutDefinitionService sut = new(_mockStore.Object); + RemoteLayoutDefinitionService sut = new(layout); // Act - RemoteLayoutElement actualRoot = sut.RemoteRoot; + LayoutGroup root = (LayoutGroup)sut.RemoteRoot; + LayoutGroup outer = (LayoutGroup)root.Elements.First(); + LayoutGroup inner = (LayoutGroup)outer.Elements.First(); // Assert - actualRoot.Should().BeSameAs(expectedLayout); + outer.CSSID.Should().Be("OUTER"); + inner.CSSID.Should().Be("INNER"); + inner.Elements.First().Should().BeOfType(); } } From e86a1fd1c12e2fe31280284c6fabc0200f580894 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Sun, 19 Apr 2026 18:25:58 -0700 Subject: [PATCH 05/14] [ADR-178] ICloudAsset abstraction, JsonCloudAsset, and stub file downloader (#148) --- .../CloudAssetServiceExtensions.cs | 7 +- .../Configuration/HostBuilderExtensions.cs | 7 +- .../Logging/MessageLogger.cs | 8 ++ .../Services/CloudAssets/BasicCloudAsset.cs | 19 ++++ .../CloudAssets/CloudAssetOrchestrator.cs | 88 +++++++---------- .../CloudAssets/CloudAssetStoreExtensions.cs | 14 --- .../Services/CloudAssets/CloudSettings.cs | 7 +- .../CloudAssets/FileCloudAssetDownloader.cs | 29 ++++++ .../Services/CloudAssets/ICloudAsset.cs | 4 +- .../Services/CloudAssets/JsonCloudAsset.cs | 20 ++++ src/AdaptiveRemote.Contracts/CommandType.cs | 3 + .../AdaptiveRemote.Headless.csproj | 5 + .../appsettings.Development.json | 5 + src/AdaptiveRemote.Headless/dev/layout.json | 58 +++++++++++ src/AdaptiveRemote/AdaptiveRemote.csproj | 1 + .../appsettings.Development.json | 3 + src/AdaptiveRemote/dev/layout.json | 58 +++++++++++ .../CloudAssetOrchestratorTests.cs | 99 +++++++++++++++++++ .../FileCloudAssetDownloaderTests.cs | 59 +++++++++++ .../CloudAssets/JsonCloudAssetTests.cs | 39 ++++++++ .../Features/Shared/LayoutButtons.feature | 1 + 21 files changed, 457 insertions(+), 77 deletions(-) create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs delete mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/FileCloudAssetDownloader.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs create mode 100644 src/AdaptiveRemote.Headless/appsettings.Development.json create mode 100644 src/AdaptiveRemote.Headless/dev/layout.json create mode 100644 src/AdaptiveRemote/dev/layout.json create mode 100644 test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs create mode 100644 test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs create mode 100644 test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs diff --git a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs index 3fd77152..c4134412 100644 --- a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs @@ -9,12 +9,15 @@ internal static class CloudAssetServiceExtensions internal static IServiceCollection AddCloudAssetServices(this IServiceCollection services) => services .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton(sp => sp.GetRequiredService()) .AddHostedService(sp => sp.GetRequiredService()); internal static IServiceCollection AddScopedCloudAsset( - this IServiceCollection services, string name) + this IServiceCollection services, ICloudAsset asset) where T : class - => services.AddScoped(sp => sp.GetRequiredService().Get(name)); + => services + .AddSingleton(asset) + .AddScoped(sp => sp.GetRequiredService().Get(asset.Name)); } diff --git a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs index 24ef8d16..2f4f80a5 100644 --- a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs @@ -29,7 +29,12 @@ internal static IServiceCollection AddRemoteServices(this IServiceCollection ser => services .AddApplicationLifecycleServices() .AddCloudAssetServices() - .AddScopedCloudAsset("layout") + .AddScopedCloudAsset(new JsonCloudAsset( + name: "layout", + streamUrl: "/notifications/layouts/stream", + eventName: "layout-ready", + resourcePath: "/layouts/compiled", + jsonContext: LayoutContractsJsonContext.Default)) .AddScopedLifecycleService() .AddScoped() .AddScoped() diff --git a/src/AdaptiveRemote.App/Logging/MessageLogger.cs b/src/AdaptiveRemote.App/Logging/MessageLogger.cs index 9e3b3224..6858032e 100644 --- a/src/AdaptiveRemote.App/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.App/Logging/MessageLogger.cs @@ -349,6 +349,14 @@ public MessageLogger(ILogger logger) // 1600–1699: CognitoTokenService + // 1700–1799: CloudAssetOrchestrator + + [LoggerMessage(EventId = 1700, Level = LogLevel.Information, Message = "Downloading asset '{AssetName}'")] + public partial void CloudAssetOrchestrator_Downloading(string assetName); + + [LoggerMessage(EventId = 1701, Level = LogLevel.Error, Message = "Failed to initialize cloud assets")] + public partial void CloudAssetOrchestrator_Failed(Exception error); + [LoggerMessage(EventId = 1600, Level = LogLevel.Information, Message = "Acquiring Cognito access token via Client Credentials flow")] public partial void CognitoTokenService_AcquiringToken(); diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs b/src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs new file mode 100644 index 00000000..4bdc1fda --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs @@ -0,0 +1,19 @@ +namespace AdaptiveRemote.Services.CloudAssets; + +internal abstract class BasicCloudAsset : ICloudAsset +{ + public string Name { get; } + public string StreamUrl { get; } + public string EventName { get; } + public string ResourcePath { get; } + + protected BasicCloudAsset(string name, string streamUrl, string eventName, string resourcePath) + { + Name = name; + StreamUrl = streamUrl; + EventName = eventName; + ResourcePath = resourcePath; + } + + public abstract Task DeserializeAsync(Stream stream, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs index 9dbeceb7..4d7b74fd 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs @@ -1,71 +1,53 @@ -using AdaptiveRemote.Contracts; +using AdaptiveRemote.Logging; using AdaptiveRemote.Services.Lifecycle; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace AdaptiveRemote.Services.CloudAssets; internal class CloudAssetOrchestrator : BackgroundService, IPreScopeInitializer { + private readonly IEnumerable _assets; + private readonly ICloudAssetDownloader _downloader; private readonly ICloudAssetStore _store; + private readonly MessageLogger _log; private readonly TaskCompletionSource _initCompleted = new(); - public CloudAssetOrchestrator(ICloudAssetStore store) + public CloudAssetOrchestrator( + IEnumerable assets, + ICloudAssetDownloader downloader, + ICloudAssetStore store, + ILogger logger) { + _assets = assets; + _downloader = downloader; _store = store; + _log = new MessageLogger(logger); } - protected override Task ExecuteAsync(CancellationToken stoppingToken) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - CompiledLayout layout = new( - Id: Guid.Empty, - RawLayoutId: Guid.Empty, - UserId: "stub", - IsActive: true, - Version: 1, - Elements: - [ - new LayoutGroupDefinitionDto("DPAD", - [ - new CommandDefinitionDto(CommandType.TiVo, "Up", "Up", null, "Sent Up", "Down", "Up"), - new CommandDefinitionDto(CommandType.TiVo, "Down", "Down", null, "Sent Down", "Up", "Down"), - new CommandDefinitionDto(CommandType.TiVo, "Left", "Left", null, "Sent Left", "Right", "Left"), - new CommandDefinitionDto(CommandType.TiVo, "Right", "Right", null, "Sent Right", "Left", "Right"), - new CommandDefinitionDto(CommandType.TiVo, "Select", "Select", null, "Sent Select", null, "Select"), - new CommandDefinitionDto(CommandType.TiVo, "Back", "Back", null, "Sent Back", null, "Back"), - new CommandDefinitionDto(CommandType.IR, "Power", "Power", null, "Sent Power", "Power", "Power"), - new CommandDefinitionDto(CommandType.IR, "PowerOn", "PowerOn", null, "Sent PowerOn", "PowerOff", "PowerOn"), - new CommandDefinitionDto(CommandType.IR, "PowerOff", "PowerOff", null, "Sent PowerOff", "PowerOn", "PowerOff"), - ]), - new LayoutGroupDefinitionDto("WELL", - [ - new CommandDefinitionDto(CommandType.TiVo, "TiVo", "TiVo", null, "Sent TiVo", null, "TiVo"), - new CommandDefinitionDto(CommandType.TiVo, "Netflix", "Netflix", null, "Sent Netflix", null, "Netflix"), - new CommandDefinitionDto(CommandType.TiVo, "Guide", "Guide", null, "Sent Guide", null, "Guide"), - ]), - new LayoutGroupDefinitionDto("PLAYBACK", - [ - new CommandDefinitionDto(CommandType.TiVo, "Play", "Play", null, "Sent Play", "Pause", "Play"), - new CommandDefinitionDto(CommandType.TiVo, "Pause", "Pause", null, "Sent Pause", "Play", "Pause"), - new CommandDefinitionDto(CommandType.TiVo, "Record", "Record", null, "Sent Record", null, "Record"), - new CommandDefinitionDto(CommandType.TiVo, "Skip", "Skip", null, "Sent Skip", "Replay", "Skip"), - new CommandDefinitionDto(CommandType.TiVo, "Replay", "Replay", null, "Sent Replay", "Skip", "Replay"), - ]), - new LayoutGroupDefinitionDto("CHANNELANDVOLUME", - [ - new CommandDefinitionDto(CommandType.TiVo, "ChannelUp", "Up", null, "Sent Channel Up", "ChannelDown", "ChannelUp"), - new CommandDefinitionDto(CommandType.TiVo, "ChannelDown", "Down", null, "Sent Channel Down", "ChannelUp", "ChannelDown"), - new CommandDefinitionDto(CommandType.IR, "VolumeUp", "Up", null, "Sent Volume Up", "VolumeDown", "VolumeUp"), - new CommandDefinitionDto(CommandType.IR, "VolumeDown", "Down", null, "Sent Volume Down", "VolumeUp", "VolumeDown"), - new CommandDefinitionDto(CommandType.IR, "Mute", "Mute", null, "Sent Mute", "Mute", "Mute"), - ]), - ], - CssDefinitions: "", - CompiledAt: DateTimeOffset.UtcNow); - - _store.SetLayout(layout); - _initCompleted.SetResult(); - - return Task.CompletedTask; + try + { + foreach (ICloudAsset asset in _assets) + { + _log.CloudAssetOrchestrator_Downloading(asset.Name); + Stream stream = await _downloader.GetActiveAsync(asset.ResourcePath, stoppingToken) + ?? throw new InvalidOperationException($"Failed to download asset '{asset.Name}'."); + await using (stream) + { + object value = await asset.DeserializeAsync(stream, stoppingToken); + _store.Set(asset.Name, value); + } + } + _initCompleted.SetResult(); + } + catch (Exception ex) + { + _log.CloudAssetOrchestrator_Failed(ex); + _initCompleted.TrySetException(ex); + throw; + } } public Task WaitAsync(ILifecycleActivity activity, CancellationToken ct) diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs deleted file mode 100644 index 9b387a7d..00000000 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStoreExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using AdaptiveRemote.Contracts; - -namespace AdaptiveRemote.Services.CloudAssets; - -internal static class CloudAssetStoreExtensions -{ - private const string LayoutAssetName = "layout"; - - public static CompiledLayout GetLayout(this ICloudAssetStore store) - => store.Get(LayoutAssetName); - - public static void SetLayout(this ICloudAssetStore store, CompiledLayout layout) - => store.Set(LayoutAssetName, layout); -} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs index b720f491..5d863198 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs @@ -1,15 +1,12 @@ namespace AdaptiveRemote.Services.CloudAssets; /// -/// Shared connection and auth settings for all cloud asset services. +/// Shared settings for all cloud asset services. /// internal class CloudSettings { - public string BackendBaseUrl { get; set; } = ""; - public string CognitoTokenEndpointUrl { get; set; } = ""; - public string ClientId { get; set; } = ""; - public string ClientSecret { get; set; } = ""; public int IdleCooldownSeconds { get; set; } = 30; public int SseMaxConsecutiveFailures { get; set; } = 10; public string CachePath { get; set; } = @"%LocalAppData%\AdaptiveRemote\CloudAssets"; + public string StubFilePath { get; set; } = "dev/layout.json"; } diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/FileCloudAssetDownloader.cs b/src/AdaptiveRemote.App/Services/CloudAssets/FileCloudAssetDownloader.cs new file mode 100644 index 00000000..043d3f80 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/FileCloudAssetDownloader.cs @@ -0,0 +1,29 @@ +using AdaptiveRemote.Services; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Services.CloudAssets; + +internal sealed class FileCloudAssetDownloader : ICloudAssetDownloader +{ + private readonly CloudSettings _settings; + private readonly IFileSystem _fileSystem; + + public FileCloudAssetDownloader(IOptions options, IFileSystem fileSystem) + { + _settings = options.Value; + _fileSystem = fileSystem; + } + + public Task GetActiveAsync(string resourcePath, CancellationToken ct) + { + string path = Environment.ExpandEnvironmentVariables(_settings.StubFilePath); + if (!_fileSystem.FileExists(path)) + { + return Task.FromResult(null); + } + return Task.FromResult(_fileSystem.OpenRead(path)); + } + + public Task GetByIdAsync(string resourcePath, Guid id, CancellationToken ct) + => Task.FromResult(null); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs index 4d439864..415376c7 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs @@ -26,9 +26,9 @@ internal interface ICloudAsset string ResourcePath { get; } /// - /// Parses downloaded or cached bytes into the asset's runtime type. + /// Deserializes downloaded or cached bytes into the asset's runtime type. /// - Task ParseAsync(Stream stream, CancellationToken ct); + Task DeserializeAsync(Stream stream, CancellationToken ct); } /// diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs b/src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs new file mode 100644 index 00000000..e36b4fb7 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AdaptiveRemote.Services.CloudAssets; + +internal sealed class JsonCloudAsset( + string name, + string streamUrl, + string eventName, + string resourcePath, + JsonSerializerContext jsonContext) + : BasicCloudAsset(name, streamUrl, eventName, resourcePath) +{ + public override async Task DeserializeAsync(Stream stream, CancellationToken ct) + { + object? result = await JsonSerializer.DeserializeAsync(stream, typeof(T), jsonContext, ct); + return result ?? throw new InvalidOperationException( + $"Deserialized null for asset '{Name}'."); + } +} diff --git a/src/AdaptiveRemote.Contracts/CommandType.cs b/src/AdaptiveRemote.Contracts/CommandType.cs index 5ea8a4f9..240b57fe 100644 --- a/src/AdaptiveRemote.Contracts/CommandType.cs +++ b/src/AdaptiveRemote.Contracts/CommandType.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace AdaptiveRemote.Contracts; // Identifies the runtime command type. The client uses this to instantiate the correct @@ -6,4 +8,5 @@ namespace AdaptiveRemote.Contracts; // TiVo — CommandId = Name.ToUpperInvariant() (existing convention) // IR — payload programmed via remote, stored in ProgrammaticSettings // Others — keyed by Name +[JsonConverter(typeof(JsonStringEnumConverter))] public enum CommandType { Lifecycle, TiVo, IR } diff --git a/src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj b/src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj index 695eb357..8110e8dc 100644 --- a/src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj +++ b/src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj @@ -7,6 +7,11 @@ enable + + + + + diff --git a/src/AdaptiveRemote.Headless/appsettings.Development.json b/src/AdaptiveRemote.Headless/appsettings.Development.json new file mode 100644 index 00000000..87ad47dc --- /dev/null +++ b/src/AdaptiveRemote.Headless/appsettings.Development.json @@ -0,0 +1,5 @@ +{ + "CloudSettings": { + "StubFilePath": "dev/layout.json" + } +} diff --git a/src/AdaptiveRemote.Headless/dev/layout.json b/src/AdaptiveRemote.Headless/dev/layout.json new file mode 100644 index 00000000..5f75ef12 --- /dev/null +++ b/src/AdaptiveRemote.Headless/dev/layout.json @@ -0,0 +1,58 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "rawLayoutId": "00000000-0000-0000-0000-000000000000", + "userId": "stub", + "isActive": true, + "version": 1, + "elements": [ + { + "$type": "group", + "cssId": "DPAD", + "children": [ + { "$type": "command", "type": "TiVo", "name": "Up", "label": "Up", "glyph": null, "speakPhrase": "Sent Up", "reverse": "Down", "cssId": "Up" }, + { "$type": "command", "type": "TiVo", "name": "Down", "label": "Down", "glyph": null, "speakPhrase": "Sent Down", "reverse": "Up", "cssId": "Down" }, + { "$type": "command", "type": "TiVo", "name": "Left", "label": "Left", "glyph": null, "speakPhrase": "Sent Left", "reverse": "Right", "cssId": "Left" }, + { "$type": "command", "type": "TiVo", "name": "Right", "label": "Right", "glyph": null, "speakPhrase": "Sent Right", "reverse": "Left", "cssId": "Right" }, + { "$type": "command", "type": "TiVo", "name": "Select", "label": "Select", "glyph": null, "speakPhrase": "Sent Select", "reverse": null, "cssId": "Select" }, + { "$type": "command", "type": "TiVo", "name": "Back", "label": "Back", "glyph": null, "speakPhrase": "Sent Back", "reverse": null, "cssId": "Back" }, + { "$type": "command", "type": "IR", "name": "Power", "label": "Power", "glyph": null, "speakPhrase": "Sent Power", "reverse": "Power", "cssId": "Power" }, + { "$type": "command", "type": "IR", "name": "PowerOn", "label": "PowerOn", "glyph": null, "speakPhrase": "Sent PowerOn", "reverse": "PowerOff", "cssId": "PowerOn" }, + { "$type": "command", "type": "IR", "name": "PowerOff", "label": "PowerOff", "glyph": null, "speakPhrase": "Sent PowerOff", "reverse": "PowerOn", "cssId": "PowerOff" } + ] + }, + { + "$type": "group", + "cssId": "WELL", + "children": [ + { "$type": "command", "type": "TiVo", "name": "TiVo", "label": "TiVo", "glyph": null, "speakPhrase": "Sent TiVo", "reverse": null, "cssId": "TiVo" }, + { "$type": "command", "type": "TiVo", "name": "Netflix", "label": "Netflix", "glyph": null, "speakPhrase": "Sent Netflix", "reverse": null, "cssId": "Netflix" }, + { "$type": "command", "type": "TiVo", "name": "Guide", "label": "Guide", "glyph": null, "speakPhrase": "Sent Guide", "reverse": null, "cssId": "Guide" }, + { "$type": "command", "type": "TiVo", "name": "Info", "label": "Info", "glyph": null, "speakPhrase": "Sent Info", "reverse": null, "cssId": "Info" } + ] + }, + { + "$type": "group", + "cssId": "PLAYBACK", + "children": [ + { "$type": "command", "type": "TiVo", "name": "Play", "label": "Play", "glyph": null, "speakPhrase": "Sent Play", "reverse": "Pause", "cssId": "Play" }, + { "$type": "command", "type": "TiVo", "name": "Pause", "label": "Pause", "glyph": null, "speakPhrase": "Sent Pause", "reverse": "Play", "cssId": "Pause" }, + { "$type": "command", "type": "TiVo", "name": "Record", "label": "Record", "glyph": null, "speakPhrase": "Sent Record", "reverse": null, "cssId": "Record" }, + { "$type": "command", "type": "TiVo", "name": "Skip", "label": "Skip", "glyph": null, "speakPhrase": "Sent Skip", "reverse": "Replay", "cssId": "Skip" }, + { "$type": "command", "type": "TiVo", "name": "Replay", "label": "Replay", "glyph": null, "speakPhrase": "Sent Replay", "reverse": "Skip", "cssId": "Replay" } + ] + }, + { + "$type": "group", + "cssId": "CHANNELANDVOLUME", + "children": [ + { "$type": "command", "type": "TiVo", "name": "ChannelUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Channel Up", "reverse": "ChannelDown", "cssId": "ChannelUp" }, + { "$type": "command", "type": "TiVo", "name": "ChannelDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Channel Down", "reverse": "ChannelUp", "cssId": "ChannelDown" }, + { "$type": "command", "type": "IR", "name": "VolumeUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Volume Up", "reverse": "VolumeDown", "cssId": "VolumeUp" }, + { "$type": "command", "type": "IR", "name": "VolumeDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Volume Down", "reverse": "VolumeUp", "cssId": "VolumeDown" }, + { "$type": "command", "type": "IR", "name": "Mute", "label": "Mute", "glyph": null, "speakPhrase": "Sent Mute", "reverse": "Mute", "cssId": "Mute" } + ] + } + ], + "cssDefinitions": "", + "compiledAt": "2026-04-19T00:00:00+00:00" +} diff --git a/src/AdaptiveRemote/AdaptiveRemote.csproj b/src/AdaptiveRemote/AdaptiveRemote.csproj index a9085492..fb8df2fe 100644 --- a/src/AdaptiveRemote/AdaptiveRemote.csproj +++ b/src/AdaptiveRemote/AdaptiveRemote.csproj @@ -23,6 +23,7 @@ + diff --git a/src/AdaptiveRemote/appsettings.Development.json b/src/AdaptiveRemote/appsettings.Development.json index 27804e97..65b56f7f 100644 --- a/src/AdaptiveRemote/appsettings.Development.json +++ b/src/AdaptiveRemote/appsettings.Development.json @@ -7,5 +7,8 @@ "clientSecret": "", "scope": "" } + }, + "CloudSettings": { + "StubFilePath": "dev/layout.json" } } diff --git a/src/AdaptiveRemote/dev/layout.json b/src/AdaptiveRemote/dev/layout.json new file mode 100644 index 00000000..5f75ef12 --- /dev/null +++ b/src/AdaptiveRemote/dev/layout.json @@ -0,0 +1,58 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "rawLayoutId": "00000000-0000-0000-0000-000000000000", + "userId": "stub", + "isActive": true, + "version": 1, + "elements": [ + { + "$type": "group", + "cssId": "DPAD", + "children": [ + { "$type": "command", "type": "TiVo", "name": "Up", "label": "Up", "glyph": null, "speakPhrase": "Sent Up", "reverse": "Down", "cssId": "Up" }, + { "$type": "command", "type": "TiVo", "name": "Down", "label": "Down", "glyph": null, "speakPhrase": "Sent Down", "reverse": "Up", "cssId": "Down" }, + { "$type": "command", "type": "TiVo", "name": "Left", "label": "Left", "glyph": null, "speakPhrase": "Sent Left", "reverse": "Right", "cssId": "Left" }, + { "$type": "command", "type": "TiVo", "name": "Right", "label": "Right", "glyph": null, "speakPhrase": "Sent Right", "reverse": "Left", "cssId": "Right" }, + { "$type": "command", "type": "TiVo", "name": "Select", "label": "Select", "glyph": null, "speakPhrase": "Sent Select", "reverse": null, "cssId": "Select" }, + { "$type": "command", "type": "TiVo", "name": "Back", "label": "Back", "glyph": null, "speakPhrase": "Sent Back", "reverse": null, "cssId": "Back" }, + { "$type": "command", "type": "IR", "name": "Power", "label": "Power", "glyph": null, "speakPhrase": "Sent Power", "reverse": "Power", "cssId": "Power" }, + { "$type": "command", "type": "IR", "name": "PowerOn", "label": "PowerOn", "glyph": null, "speakPhrase": "Sent PowerOn", "reverse": "PowerOff", "cssId": "PowerOn" }, + { "$type": "command", "type": "IR", "name": "PowerOff", "label": "PowerOff", "glyph": null, "speakPhrase": "Sent PowerOff", "reverse": "PowerOn", "cssId": "PowerOff" } + ] + }, + { + "$type": "group", + "cssId": "WELL", + "children": [ + { "$type": "command", "type": "TiVo", "name": "TiVo", "label": "TiVo", "glyph": null, "speakPhrase": "Sent TiVo", "reverse": null, "cssId": "TiVo" }, + { "$type": "command", "type": "TiVo", "name": "Netflix", "label": "Netflix", "glyph": null, "speakPhrase": "Sent Netflix", "reverse": null, "cssId": "Netflix" }, + { "$type": "command", "type": "TiVo", "name": "Guide", "label": "Guide", "glyph": null, "speakPhrase": "Sent Guide", "reverse": null, "cssId": "Guide" }, + { "$type": "command", "type": "TiVo", "name": "Info", "label": "Info", "glyph": null, "speakPhrase": "Sent Info", "reverse": null, "cssId": "Info" } + ] + }, + { + "$type": "group", + "cssId": "PLAYBACK", + "children": [ + { "$type": "command", "type": "TiVo", "name": "Play", "label": "Play", "glyph": null, "speakPhrase": "Sent Play", "reverse": "Pause", "cssId": "Play" }, + { "$type": "command", "type": "TiVo", "name": "Pause", "label": "Pause", "glyph": null, "speakPhrase": "Sent Pause", "reverse": "Play", "cssId": "Pause" }, + { "$type": "command", "type": "TiVo", "name": "Record", "label": "Record", "glyph": null, "speakPhrase": "Sent Record", "reverse": null, "cssId": "Record" }, + { "$type": "command", "type": "TiVo", "name": "Skip", "label": "Skip", "glyph": null, "speakPhrase": "Sent Skip", "reverse": "Replay", "cssId": "Skip" }, + { "$type": "command", "type": "TiVo", "name": "Replay", "label": "Replay", "glyph": null, "speakPhrase": "Sent Replay", "reverse": "Skip", "cssId": "Replay" } + ] + }, + { + "$type": "group", + "cssId": "CHANNELANDVOLUME", + "children": [ + { "$type": "command", "type": "TiVo", "name": "ChannelUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Channel Up", "reverse": "ChannelDown", "cssId": "ChannelUp" }, + { "$type": "command", "type": "TiVo", "name": "ChannelDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Channel Down", "reverse": "ChannelUp", "cssId": "ChannelDown" }, + { "$type": "command", "type": "IR", "name": "VolumeUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Volume Up", "reverse": "VolumeDown", "cssId": "VolumeUp" }, + { "$type": "command", "type": "IR", "name": "VolumeDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Volume Down", "reverse": "VolumeUp", "cssId": "VolumeDown" }, + { "$type": "command", "type": "IR", "name": "Mute", "label": "Mute", "glyph": null, "speakPhrase": "Sent Mute", "reverse": "Mute", "cssId": "Mute" } + ] + } + ], + "cssDefinitions": "", + "compiledAt": "2026-04-19T00:00:00+00:00" +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs new file mode 100644 index 00000000..aaf847a4 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs @@ -0,0 +1,99 @@ +using AdaptiveRemote.Services.Lifecycle; +using AdaptiveRemote.TestUtilities; +using FluentAssertions; +using Moq; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class CloudAssetOrchestratorTests +{ + private const string AssetName = "test-asset"; + private const string ResourcePath = "/test"; + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private readonly Mock MockAsset = new(); + private readonly Mock MockDownloader = new(); + private readonly Mock MockStore = new(); + private readonly MockLogger MockLogger = new(); + private readonly Mock MockActivity = new(); + + private CloudAssetOrchestrator MakeSut(IEnumerable? assets = null) + => new(assets ?? [MockAsset.Object], MockDownloader.Object, MockStore.Object, MockLogger); + + [TestInitialize] + public void SetupMocks() + { + MockAsset.SetupGet(a => a.Name).Returns(AssetName); + MockAsset.SetupGet(a => a.ResourcePath).Returns(ResourcePath); + MockActivity.SetupSet(a => a.Description = It.IsAny()); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_DownloadsAndStoresAsset() + { + // Arrange + object parsedValue = new(); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(parsedValue); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeCompleteWithin(Timeout); + waitTask.Should().BeSuccessful(); + MockStore.Verify(s => s.Set(AssetName, parsedValue), Times.Once); + MockLogger.VerifyMessages(log => { log.CloudAssetOrchestrator_Downloading(AssetName); }); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_FaultsWhenDownloadReturnsNull() + { + // Arrange + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync((Stream?)null); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + InvalidOperationException expectedException = new($"Failed to download asset '{AssetName}'."); + waitTask.Should().BeFaultedWith(expectedException, within: Timeout); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Failed(expectedException); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_FaultsWhenDeserializationFails() + { + // Arrange + InvalidOperationException parseException = new("Deserialization failed"); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(parseException); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeFaultedWith(parseException, within: Timeout); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Failed(parseException); + }); + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs new file mode 100644 index 00000000..c33a4c70 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class FileCloudAssetDownloaderTests +{ + private const string FilePath = "dev/layout.json"; + private const string ResourcePath = "/layouts/compiled"; + + private static FileCloudAssetDownloader MakeSut( + string stubFilePath, MockFileSystem fileSystem) => + new(new MockOptions(new CloudSettings { StubFilePath = stubFilePath }), + fileSystem.Object); + + [TestMethod] + public async Task FileCloudAssetDownloader_GetActiveAsync_ReturnsStreamForConfiguredPathAsync() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddFile(FilePath, "{}"); + FileCloudAssetDownloader sut = MakeSut(FilePath, fileSystem); + + // Act + Stream? result = await sut.GetActiveAsync(ResourcePath, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + } + + [TestMethod] + public async Task FileCloudAssetDownloader_GetActiveAsync_ReturnsNullWhenFileAbsentAsync() + { + // Arrange + MockFileSystem fileSystem = new(); + FileCloudAssetDownloader sut = MakeSut("nonexistent/layout.json", fileSystem); + + // Act + Stream? result = await sut.GetActiveAsync(ResourcePath, CancellationToken.None); + + // Assert + result.Should().BeNull(); + } + + [TestMethod] + public async Task FileCloudAssetDownloader_GetByIdAsync_ReturnsNullAsync() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddFile(FilePath, "{}"); + FileCloudAssetDownloader sut = MakeSut(FilePath, fileSystem); + + // Act + Stream? result = await sut.GetByIdAsync(ResourcePath, Guid.NewGuid(), CancellationToken.None); + + // Assert + result.Should().BeNull(); + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs new file mode 100644 index 00000000..c65237f9 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs @@ -0,0 +1,39 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; + +namespace AdaptiveRemote.Services.CloudAssets; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(TestAsset))] +internal partial class TestAssetJsonContext : JsonSerializerContext { } + +internal record TestAsset(string Name, int Value); + +[TestClass] +public class JsonCloudAssetTests +{ + private static JsonCloudAsset MakeSut() => + new("asset", "/stream", "asset-ready", "/assets", + TestAssetJsonContext.Default); + + [TestMethod] + public async Task JsonCloudAsset_DeserializeAsync_CorrectlyDeserializesAsync() + { + // Arrange + TestAsset expected = new("test-name", 42); + string json = JsonSerializer.Serialize(expected, TestAssetJsonContext.Default.TestAsset); + using MemoryStream stream = new(Encoding.UTF8.GetBytes(json)); + JsonCloudAsset sut = MakeSut(); + + // Act + object result = await sut.DeserializeAsync(stream, CancellationToken.None); + + // Assert + result.Should().BeOfType(); + TestAsset asset = (TestAsset)result; + asset.Name.Should().Be("test-name"); + asset.Value.Should().Be(42); + } +} diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature index 4b35f786..ac8194b5 100644 --- a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature @@ -29,6 +29,7 @@ Scenario: All expected buttons from layout are present And I should see the 'TiVo' button is enabled And I should see the 'Netflix' button is enabled And I should see the 'Guide' button is enabled + And I should see the 'Info' button is enabled # PLAYBACK group And I should see the 'Play' button is enabled And I should see the 'Pause' button is enabled From 784d4f35b1df6c272952526ada0f17e43ca15349 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Sat, 18 Apr 2026 16:07:49 -0700 Subject: [PATCH 06/14] Tweak the layout E2E tests so they don't depend on button state, cover more buttons, and don't shut down or restart the application. --- .../Features/Shared/LayoutButtons.feature | 52 +++++++++---------- .../UISteps.cs | 10 +++- .../IUITestServiceExtensions.cs | 26 ++++++++++ 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature index ac8194b5..27d058e9 100644 --- a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature @@ -9,36 +9,34 @@ Scenario: Layout CSS rules are present And the stylesheet selector '#ROOT' should define 'grid-template-rows' as '6fr 3fr 1fr' And the stylesheet selector '#ROOT' should define 'grid-template-columns' as '3fr 2fr' And the stylesheet selector '#ROOT' should define 'grid-gap' as '20px' - When I click on the 'Exit' button - And I wait for the application to shut down - Then I should not see any error messages in the logs Scenario: All expected buttons from layout are present - Given the application is not running - When I start the application - Then I should see the application in the Ready phase + Given the application is in the Ready phase # DPAD group - And I should see the 'Up' button is enabled - And I should see the 'Down' button is enabled - And I should see the 'Left' button is enabled - And I should see the 'Right' button is enabled - And I should see the 'Select' button is enabled - And I should see the 'Back' button is enabled - And I should see the 'Power' button is enabled + Then I should see the 'Up' button exists + And I should see the 'Down' button exists + And I should see the 'Left' button exists + And I should see the 'Right' button exists + And I should see the 'Select' button exists + And I should see the 'Back' button exists + And I should see the 'Power' button exists # WELL group - And I should see the 'TiVo' button is enabled - And I should see the 'Netflix' button is enabled - And I should see the 'Guide' button is enabled - And I should see the 'Info' button is enabled + 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 is enabled - And I should see the 'Pause' button is enabled - And I should see the 'Record' button is enabled - And I should see the 'Skip' button is enabled - And I should see the 'Replay' button is enabled + And I should see the 'Play' button exists + And I should see the 'Pause' button exists + And I should see the 'Record' button exists + And I should see the 'Skip' button exists + And I should see the 'Replay' button exists + # CHANNELANDVOLUME group + And I should see the 'Channel Up' button exists + And I should see the 'Channel Down' button exists + And I should see the 'Mute' button exists + And I should see the 'Volume Up' button exists + And I should see the 'Volume Down' button exists # GUTTER group - And I should see the 'Learn' button is enabled - And I should see the 'Exit' button is enabled - When I click on the 'Exit' button - And I wait for the application to shut down - Then I should not see any error messages in the logs + And I should see the 'Learn' button exists + And I should see the 'Exit' button exists diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs index 37ad1222..6d22fe60 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs @@ -16,13 +16,19 @@ public void WhenIClickOnTheButton(string buttonLabel) [Then(@"I should see the {string} button is enabled")] public void ThenIShouldSeeTheButtonIsEnabled(string buttonLabel) { - Assert.IsTrue(Host.UI.WaitForButtonEnabled(buttonLabel, enabled: true), "Button {0} was not enabled", buttonLabel); + Assert.IsTrue(Host.UI.WaitForButtonEnabled(buttonLabel, enabled: true), "Button '{0}' was not enabled", buttonLabel); } [Then(@"I should see the {string} button is disabled")] public void ThenIShouldSeeTheButtonIsDisabled(string buttonLabel) { - Assert.IsTrue(Host.UI.WaitForButtonEnabled(buttonLabel, enabled: false), "Button {0} was not disabled", buttonLabel); + Assert.IsTrue(Host.UI.WaitForButtonEnabled(buttonLabel, enabled: false), "Button '{0}' was not disabled", buttonLabel); + } + + [Then(@"I should see the {string} button exists")] + public void ThenIShouldSeeTheButtonExists(string buttonLabel) + { + Assert.IsTrue(Host.UI.WaitForButtonExists(buttonLabel), "Button '{0}' did not exist", buttonLabel); } [Then(@"I should see the {string} button is programmed")] diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs index 502ddba8..25e43f4f 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs @@ -61,6 +61,32 @@ public static bool WaitForButtonEnabled(this IUITestService service, string labe return WaitHelpers.WaitForState(button.IsEnabledAsync, enabled, timeout); } + /// + /// Waits for a button with the specified label to exist. + /// + /// The UI test service. + /// The exact visible text of the button (case-sensitive, trimmed). + /// Optional timeout for the operation. + /// True if the button exists within the timeout, false otherwise. + public static bool WaitForButtonExists(this IUITestService service, string label, int timeoutInSeconds = DefaultUITimeoutInSeconds) + => service.WaitForButtonExists(label, TimeSpan.FromSeconds(timeoutInSeconds)); + + /// + /// Waits for a button with the specified label to exist. + /// + /// The UI test service. + /// The exact visible text of the button (case-sensitive, trimmed). + /// Timeout for the operation. + /// True if the button exists within the timeout, false otherwise. + public static bool WaitForButtonExists(this IUITestService service, string label, TimeSpan timeout) + { + return WaitHelpers.ExecuteWithRetries(async ct => + { + using IUIButtonTestObject? button = await service.FindButtonByLabelAsync(label, ct); + return button is not null; + }, timeout); + } + /// /// Clicks a button with the specified label in the UI (synchronous wrapper). /// From fad647a6bdc8a6b0b8b7df07806852c4840800ab Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Mon, 20 Apr 2026 12:56:51 -0700 Subject: [PATCH 07/14] Some code clean-up --- src/AdaptiveRemote.App/Components/Remote.razor | 2 +- .../Configuration/CloudAssetServiceExtensions.cs | 6 ++++-- .../Configuration/HostBuilderExtensions.cs | 5 ++--- src/AdaptiveRemote.App/Configuration/SettingsKeys.cs | 5 +++++ .../Services/{Layout => }/IDynamicStylesheetProvider.cs | 2 +- .../PlaywrightUITestService.cs | 2 ++ 6 files changed, 15 insertions(+), 7 deletions(-) rename src/AdaptiveRemote.App/Services/{Layout => }/IDynamicStylesheetProvider.cs (79%) diff --git a/src/AdaptiveRemote.App/Components/Remote.razor b/src/AdaptiveRemote.App/Components/Remote.razor index 3b123202..8214921e 100644 --- a/src/AdaptiveRemote.App/Components/Remote.razor +++ b/src/AdaptiveRemote.App/Components/Remote.razor @@ -1,5 +1,5 @@ @inject Services.IRemoteDefinitionService RemoteDefinitions -@inject Services.Layout.IDynamicStylesheetProvider Stylesheet +@inject Services.IDynamicStylesheetProvider Stylesheet @if (Stylesheet.GetCss() is { } css) { diff --git a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs index c4134412..2292551e 100644 --- a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs @@ -1,18 +1,20 @@ using AdaptiveRemote.Services.CloudAssets; using AdaptiveRemote.Services.Lifecycle; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace AdaptiveRemote.Configuration; internal static class CloudAssetServiceExtensions { - internal static IServiceCollection AddCloudAssetServices(this IServiceCollection services) + internal static IServiceCollection AddCloudAssetServices(this IServiceCollection services, IConfiguration configuration) => services .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(sp => sp.GetRequiredService()) - .AddHostedService(sp => sp.GetRequiredService()); + .AddHostedService(sp => sp.GetRequiredService()) + .Configure(configuration); internal static IServiceCollection AddScopedCloudAsset( this IServiceCollection services, ICloudAsset asset) diff --git a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs index 2f4f80a5..07a79f2e 100644 --- a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs @@ -28,7 +28,7 @@ internal static IHostBuilder AddRemoteServices(this IHostBuilder builder) internal static IServiceCollection AddRemoteServices(this IServiceCollection services, IConfiguration configuration) => services .AddApplicationLifecycleServices() - .AddCloudAssetServices() + .AddCloudAssetServices(configuration.GetSection(SettingsKeys.CloudSettings)) .AddScopedCloudAsset(new JsonCloudAsset( name: "layout", streamUrl: "/notifications/layouts/stream", @@ -39,8 +39,7 @@ internal static IServiceCollection AddRemoteServices(this IServiceCollection ser .AddScoped() .AddScoped() .AddSingleton() - .Configure(configuration.GetSection(SettingsKeys.ProgrammaticSettings)) - .Configure(configuration.GetSection("CloudSettings")); + .Configure(configuration.GetSection(SettingsKeys.ProgrammaticSettings)); internal static IServiceCollection AddScopedLifecycleService(this IServiceCollection services) where ServiceType : class, IScopedLifecycle diff --git a/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs b/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs index 2fcf1cf3..e40da858 100644 --- a/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs +++ b/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs @@ -46,4 +46,9 @@ internal class SettingsKeys /// Configuration section for backend service settings. /// public const string Backend = "backend"; + + /// + /// Configuration section for backend service settings. + /// + public const string CloudSettings = "cloud"; } diff --git a/src/AdaptiveRemote.App/Services/Layout/IDynamicStylesheetProvider.cs b/src/AdaptiveRemote.App/Services/IDynamicStylesheetProvider.cs similarity index 79% rename from src/AdaptiveRemote.App/Services/Layout/IDynamicStylesheetProvider.cs rename to src/AdaptiveRemote.App/Services/IDynamicStylesheetProvider.cs index e644f696..8645c875 100644 --- a/src/AdaptiveRemote.App/Services/Layout/IDynamicStylesheetProvider.cs +++ b/src/AdaptiveRemote.App/Services/IDynamicStylesheetProvider.cs @@ -1,4 +1,4 @@ -namespace AdaptiveRemote.Services.Layout; +namespace AdaptiveRemote.Services; /// /// Scoped. Returns the CSS for the active layout in this scope. diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs index ce80d0c5..fcf6e6e1 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs @@ -80,6 +80,8 @@ private ILocator GetButtonLocatorByLabel(string label) { // Use Playwright's getByRole with exact match - it will throw meaningful errors // if there are no matches or ambiguous matches + + // TODO: Find by aria name instead return label switch { // DPAD buttons (Nth(0) - first occurrence) From ff95f66ffed3f899dbcccdff6b40cc775fde8ecd Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Wed, 22 Apr 2026 12:42:54 -0700 Subject: [PATCH 08/14] [ADR-179] ApplicationLifecycle recycle loop and BlazorAppScope.RecycleAsync (#153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ADR-179] ApplicationLifecycle recycle loop and BlazorAppScope.RecycleAsync Implements the scope recycle loop in ApplicationLifecycle.ExecuteAsync: - IApplicationRecycleSignal / ApplicationRecycleSignal: new cross-service interface backed by a CancellationTokenSource; RequestRecycle() cancels it, Token is linked into the scope work item, Reset() creates a fresh CTS - ExecuteAsync refactored to a while loop; linked token from stoppingToken + signal.Token passed into InvokeInScopeAsync each iteration - Steady-state path: signal fires during Task.Delay(Infinite) → OCE → cleanup → log RecyclingScope → RecycleScopeAsync() → signal.Reset() → loop - Init-phase path: signal fires during InitializeAllAsync → cancel → cleanup → signal.Reset() → loop without RecycleScopeAsync (scope TCS still valid) - IPreScopeInitializer services awaited only before the first scope (before the while loop), not re-awaited on recycles - BlazorAppScope.RecycleAsync: now calls IJSRuntime.InvokeVoidAsync("location.reload") - Registered IApplicationRecycleSignal as singleton in DI - Added EventId 712 ApplicationLifecycle_RecyclingScope log message - Added 5 unit tests covering both recycle paths, loop continuation, pre-initializer not re-awaited, and signal.Reset() called after recycle - Updated _doc_Lifecycle.md: removed "Future Plans" stub, documented the implemented recycle loop, signal, and two recycle paths https://claude.ai/code/session_01VENkux7qyWvUsKWEzgNC3s * Add sandbox setup script and update E2E test docs for Playwright browsers Claude Code cloud sandbox environments block cdn.playwright.dev, so the standard `playwright.ps1 install` fails. Browsers are pre-installed at /opt/pw-browsers but under a different revision (1194) than the current Playwright package expects (1208). - scripts/setup-playwright-sandbox.sh: auto-detects the expected version from the Playwright registry JS, finds the highest installed version in /opt/pw-browsers, and creates symlinks + INSTALLATION_COMPLETE markers - CLAUDE.md: updated E2E test instructions to document both the developer machine path and the sandbox workaround, with PLAYWRIGHT_BROWSERS_PATH https://claude.ai/code/session_01VENkux7qyWvUsKWEzgNC3s * Address PR review comments: refactor recycle loop, fix thread safety, add tests - ApplicationLifecycle.ExecuteAsync: replace Task.Delay(Timeout.Infinite) / exception-based control flow with WaitForCancelledAsync (returns normally, no OCE for steady-state transitions) - Add ScopeReady log message (EventId 713); ScopeReleased now only logged on init/construction failure - Cleanup now runs inside the loop before ShuttingDown, rather than after the loop - BlazorAppScope.RecycleAsync: use GetRequiredService(); remove unused using - ApplicationRecycleSignal: full lock-based thread safety; implement IDisposable - IApplicationRecycleSignal: add XML doc comments to all interface methods - HostBuilderExtensions: restore missing using AdaptiveRemote.Services.CloudAssets - CLAUDE.md: update cloud sandbox E2E guidance — don't fall back to setup script; report broken environment - _doc_Lifecycle.md: update recycle loop description to match current WaitForCancelledAsync design - ApplicationLifecycleTests: fix 3 tests for new log ordering; add 6 recycle scenario tests (second signal during cleanup, blocks until cleanup, delay during init after recycle, error during cleanup continues recycle, error during init exits loop, signal during pre-init is no-op) https://claude.ai/code/session_01VENkux7qyWvUsKWEzgNC3s * Improve ApplicationLifecycle unit tests, behavior, and logging. * Delete the playwright workaround script since it shouldn't be necessary --------- Co-authored-by: Claude Co-authored-by: Joe Davis --- CLAUDE.md | 19 +- .../Components/BlazorAppScope.cs | 11 +- .../Configuration/HostBuilderExtensions.cs | 3 +- .../Logging/MessageLogger.cs | 14 +- src/AdaptiveRemote.App/Models/Phrases.cs | 5 +- .../CloudAssets/CloudAssetOrchestrator.cs | 2 + .../Lifecycle/ApplicationLifecycle.cs | 154 ++-- .../Lifecycle/ApplicationRecycleSignal.cs | 48 ++ .../Lifecycle/IApplicationRecycleSignal.cs | 28 + .../Lifecycle/IPreScopeInitializer.cs | 5 + .../Services/Lifecycle/_doc_Lifecycle.md | 55 +- .../Lifecycle/ApplicationLifecycleTests.cs | 804 +++++++++++++++--- .../TestUtilities/MockLogger.cs | 34 +- 13 files changed, 977 insertions(+), 205 deletions(-) create mode 100644 src/AdaptiveRemote.App/Services/Lifecycle/ApplicationRecycleSignal.cs create mode 100644 src/AdaptiveRemote.App/Services/Lifecycle/IApplicationRecycleSignal.cs diff --git a/CLAUDE.md b/CLAUDE.md index 42dc8855..d2e42168 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,16 +75,29 @@ mock verification. Group setup calls into `Expect_*` helper methods. ### E2E tests Prefer the Headless host for new E2E tests — cross-platform, no display required: -**IMPORTANT:** Before running E2E tests for the first time, you must install Playwright browsers: +**IMPORTANT:** Before running E2E tests for the first time, you must set up Playwright browsers. + +**On developer machines (Windows/Mac/Linux with internet access):** ```bash # Build the Headless host first (required to generate the Playwright installation script) dotnet build src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj + +# Install Playwright browsers (one-time setup) pwsh src/AdaptiveRemote.Headless/bin/Debug/net10.0/playwright.ps1 install chromium # if tests crash at startup with a JSON-RPC disconnect + +dotnet test --filter "FullyQualifiedName~Host.Headless" +``` + +**In Claude Code cloud sandbox environments** (where `cdn.playwright.dev` is blocked by network +policy): browsers are pre-installed at `/opt/pw-browsers` and the environment is configured to point +Playwright there automatically. No extra setup is required: +```bash dotnet test --filter "FullyQualifiedName~Host.Headless" ``` -If E2E tests fail with JSON-RPC connection errors, the most likely cause is that Playwright browsers -are not installed. Run the `playwright.ps1 install chromium` command above to fix this. +If Headless E2E tests fail with JSON-RPC connection errors in a cloud sandbox environment, this indicates +the environment configuration is broken — stop and report the problem rather than working around it with +the setup script. The goal is to be alerted when the environment stops working, not to silently fall back. ## Documentation diff --git a/src/AdaptiveRemote.App/Components/BlazorAppScope.cs b/src/AdaptiveRemote.App/Components/BlazorAppScope.cs index 358c1815..7dc762b7 100644 --- a/src/AdaptiveRemote.App/Components/BlazorAppScope.cs +++ b/src/AdaptiveRemote.App/Components/BlazorAppScope.cs @@ -1,5 +1,7 @@ -using AdaptiveRemote.Services.Lifecycle; +using AdaptiveRemote.Services.Lifecycle; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; namespace AdaptiveRemote.Components; @@ -47,12 +49,11 @@ public Task InvokeInScopeAsync(Func w return workItem(_serviceProvider, cancellationToken); } - public Task RecycleAsync() + public async Task RecycleAsync() { _logger.LogInformation("Recycling Blazor application scope."); - // In a real implementation, this would refresh the browser which should result - // in a new scope - throw new NotImplementedException(); + IJSRuntime jsRuntime = _serviceProvider.GetRequiredService(); + await jsRuntime.InvokeVoidAsync("location.reload"); } } diff --git a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs index 07a79f2e..443a20ed 100644 --- a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs @@ -59,5 +59,6 @@ private static IServiceCollection AddApplicationLifecycleServices(this IServiceC .AddScoped() .AddScoped() .AddSingleton() - .AddSingleton(sp => (IApplicationScopeProvider)sp.GetRequiredService()); + .AddSingleton(sp => (IApplicationScopeProvider)sp.GetRequiredService()) + .AddSingleton(); } diff --git a/src/AdaptiveRemote.App/Logging/MessageLogger.cs b/src/AdaptiveRemote.App/Logging/MessageLogger.cs index 6858032e..d0487c97 100644 --- a/src/AdaptiveRemote.App/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.App/Logging/MessageLogger.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Runtime.Intrinsics.Arm; using Microsoft.Extensions.Logging; namespace AdaptiveRemote.Logging; @@ -42,8 +43,17 @@ public MessageLogger(ILogger logger) [LoggerMessage(EventId = 710, Level = LogLevel.Information, Message = "Waiting for application scope")] public partial void ApplicationLifecycle_WaitingForScope(); - [LoggerMessage(EventId = 711, Level = LogLevel.Information, Message = "Application scope released, shutting down")] - public partial void ApplicationLifecycle_ScopeReleased(); + [LoggerMessage(EventId = 712, Level = LogLevel.Information, Message = "Recycling application scope")] + public partial void ApplicationLifecycle_RecyclingScope(); + + [LoggerMessage(EventId = 713, Level = LogLevel.Information, Message = "Application scope ready")] + public partial void ApplicationLifecycle_ScopeReady(); + + [LoggerMessage(EventId = 714, Level = LogLevel.Information, Message = "Waiting for preinitializer: {Name}")] + public partial void ApplicationLifecycle_WaitingForPreinitializer(string name); + + [LoggerMessage(EventId = 715, Level = LogLevel.Error, Message = "Preinitializer failed: {Name}")] + public partial void ApplicationLifecycle_PreinitializerFailed(string name, Exception ex); [LoggerMessage(EventId = 205, Level = LogLevel.Warning, Message = "Not restarting after {ErrorCount} error(s)")] public partial void ConversationController_RetryLimitReached(int errorCount); diff --git a/src/AdaptiveRemote.App/Models/Phrases.cs b/src/AdaptiveRemote.App/Models/Phrases.cs index b939b0b2..e4a46b22 100644 --- a/src/AdaptiveRemote.App/Models/Phrases.cs +++ b/src/AdaptiveRemote.App/Models/Phrases.cs @@ -1,4 +1,6 @@ -namespace AdaptiveRemote.Models; +using AdaptiveRemote.Services.Lifecycle; + +namespace AdaptiveRemote.Models; internal static class Phrases { @@ -20,6 +22,7 @@ internal static class Phrases public static string Startup_StartingApplication => "Starting application"; 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_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/CloudAssets/CloudAssetOrchestrator.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs index 4d7b74fd..2899f2e1 100644 --- a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs @@ -25,6 +25,8 @@ public CloudAssetOrchestrator( _log = new MessageLogger(logger); } + public string Name => "Loading cloud assets"; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs index fe2280d3..6e176456 100644 --- a/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs +++ b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs @@ -1,4 +1,5 @@ -using AdaptiveRemote.Logging; +using AdaptiveRemote.Logging; +using AdaptiveRemote.Models; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -9,6 +10,7 @@ internal class ApplicationLifecycle : BackgroundService { private readonly IApplicationScopeProvider _scopeProvider; private readonly ILifecycleViewController _viewController; + private readonly IApplicationRecycleSignal _signal; private readonly IEnumerable _preInitializers; private readonly MessageLogger _logger; private ScopedLifecycleContainer? _currentContainer; @@ -16,11 +18,13 @@ internal class ApplicationLifecycle : BackgroundService public ApplicationLifecycle( IApplicationScopeProvider scopeProvider, ILifecycleViewController viewController, + IApplicationRecycleSignal signal, IEnumerable preInitializers, ILogger logger) { _scopeProvider = scopeProvider; _viewController = viewController; + _signal = signal; _preInitializers = preInitializers; _logger = new(logger); } @@ -29,13 +33,46 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - // Await all IPreScopeInitializer services before creating the first scope - await RunPreInitializersAsync(stoppingToken); - - _logger.ApplicationLifecycle_WaitingForScope(); - - await _scopeProvider.InvokeInScopeAsync(InitializeLifecycleAsync, stoppingToken); - _logger.ApplicationLifecycle_ScopeReleased(); + // Await all IPreScopeInitializer services before creating the first scope. + // Not re-awaited on scope recycles — the store is already populated. + if (await RunPreInitializersAsync(stoppingToken)) + { + while (!stoppingToken.IsCancellationRequested) + { + _signal.Reset(); + + using CancellationTokenSource linkedCts = + CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _signal.Token); + + _logger.ApplicationLifecycle_WaitingForScope(); + + bool initialized = await InitializeScopeAsync(linkedCts.Token); + if (!linkedCts.Token.IsCancellationRequested) + { + if (initialized) + { + // Scope is ready; block until stoppingToken or signal.Token fires. + _logger.ApplicationLifecycle_ScopeReady(); + await linkedCts.Token.WaitForCancelledAsync(); + } + else + { + // A fatal error occurred and was logged. + break; + } + } + + if (stoppingToken.IsCancellationRequested) + { + break; + } + + await CleanUpCurrentContainerAsync(default); + + _logger.ApplicationLifecycle_RecyclingScope(); + await _scopeProvider.RecycleScopeAsync(); + } + } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -44,76 +81,85 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) catch (Exception ex) { _logger.ApplicationLifecycle_UnhandledError(ex); - await CleanUpCurrentContainerAsync(default); - } - - try - { - await stoppingToken.WaitForCancelledAsync(); - } - catch (OperationCanceledException) - { - // Expected when stopping } _logger.ApplicationLifecycle_ShuttingDown(); - await CleanUpCurrentContainerAsync(default); } - private async Task RunPreInitializersAsync(CancellationToken stoppingToken) + private async Task RunPreInitializersAsync(CancellationToken stoppingToken) { - Task[] initTasks = _preInitializers.Select(init => RunSinglePreInitializerAsync(init, stoppingToken)).ToArray(); - await Task.WhenAll(initTasks); + try + { + Task[] initTasks = _preInitializers.Select(init => RunSinglePreInitializerAsync(init, stoppingToken)).ToArray(); + await Task.WhenAll(initTasks); + return true; + } + catch + { + return false; + } } private async Task RunSinglePreInitializerAsync(IPreScopeInitializer initializer, CancellationToken stoppingToken) { - ILifecycleActivity activity = _viewController.StartTask($"Initializing {initializer.GetType().Name}"); + using ILifecycleActivity activity = _viewController.StartTask(Phrases.Startup_Preinitializing(initializer.Name)); try { - await initializer.WaitAsync(activity, stoppingToken); + Task waitTask = initializer.WaitAsync(activity, stoppingToken); + if (!waitTask.IsCompleted) + { + _logger.ApplicationLifecycle_WaitingForPreinitializer(initializer.Name); + } + await waitTask; } - finally + catch (Exception error) { - activity.Dispose(); + _logger.ApplicationLifecycle_PreinitializerFailed(initializer.Name, error); + activity.SetFatalError(error); + throw; } } - private async Task InitializeLifecycleAsync(IServiceProvider provider, CancellationToken cancellationToken) + private async Task InitializeScopeAsync(CancellationToken cancellationToken) { - _currentContainer = SafeGetContainer(provider); - - if (_currentContainer is not null) + bool initialized = false; + try { - try + await _scopeProvider.InvokeInScopeAsync(async (provider, ct) => { - await _currentContainer.InitializeAllAsync(cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch - { - // Service initialization failures are already logged in ScopedLifecycleContainer. - // Clean up and return normally so ExecuteAsync can log ScopeReleased. - await CleanUpCurrentContainerAsync(default); - } + _currentContainer = SafeGetContainer(provider); + if (_currentContainer is null) + { + return; + } + await _currentContainer.InitializeAllAsync(ct); + initialized = true; + }, cancellationToken); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cancelled by stoppingToken or signal + } + catch + { + // Exceptions from scope creation or initialization are already handled and logged; no need to log again. + } + + return initialized; + } - ScopedLifecycleContainer? SafeGetContainer(IServiceProvider provider) + private ScopedLifecycleContainer? SafeGetContainer(IServiceProvider provider) + { + try { - try - { - return provider.GetRequiredService(); - } - catch (Exception ex) - { - _logger.ApplicationLifecycle_ScopeConstructionFailed(ex); - _viewController.SetFatalError(ex); - return null; - } + return provider.GetRequiredService(); + } + catch (Exception ex) + { + _logger.ApplicationLifecycle_ScopeConstructionFailed(ex); + _viewController.SetFatalError(ex); + return null; } } diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationRecycleSignal.cs b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationRecycleSignal.cs new file mode 100644 index 00000000..c802ac1d --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationRecycleSignal.cs @@ -0,0 +1,48 @@ +namespace AdaptiveRemote.Services.Lifecycle; + +internal sealed class ApplicationRecycleSignal : IApplicationRecycleSignal, IDisposable +{ + private readonly object _sync = new(); + private CancellationTokenSource _cts = new(); + + public CancellationToken Token + { + get + { + lock (_sync) + { + return _cts.Token; + } + } + } + + public void RequestRecycle() + { + lock (_sync) + { + _cts.Cancel(); + } + } + + public void Reset() + { + CancellationTokenSource old; + lock (_sync) + { + old = _cts; + _cts = new CancellationTokenSource(); + } + old.Dispose(); + } + + public void Dispose() + { + CancellationTokenSource old; + lock (_sync) + { + old = _cts; + _cts = new CancellationTokenSource(); + } + old.Dispose(); + } +} diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/IApplicationRecycleSignal.cs b/src/AdaptiveRemote.App/Services/Lifecycle/IApplicationRecycleSignal.cs new file mode 100644 index 00000000..5ea83d35 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Lifecycle/IApplicationRecycleSignal.cs @@ -0,0 +1,28 @@ +namespace AdaptiveRemote.Services.Lifecycle; + +/// +/// Signals that a scope recycle has been requested. ApplicationLifecycle links this token +/// into its scope work item; RequestRecycle() cancels that token whether init is in progress +/// or the loop is in steady-state wait. Reset() is called by ApplicationLifecycle after +/// cleanup, before starting the next init cycle. +/// +internal interface IApplicationRecycleSignal +{ + /// + /// Requests a scope recycle. Cancels . + /// Safe to call from any thread, including concurrently with . + /// + void RequestRecycle(); + + /// + /// The cancellation token that fires when is called. + /// Linked into the scope work item by ApplicationLifecycle. + /// + CancellationToken Token { get; } + + /// + /// Resets the signal after cleanup, replacing the cancelled token with a fresh one. + /// Called by ApplicationLifecycle before starting the next scope iteration. + /// + void Reset(); +} diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs b/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs index d0f14879..c15babfe 100644 --- a/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs +++ b/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs @@ -7,6 +7,11 @@ namespace AdaptiveRemote.Services.Lifecycle; /// internal interface IPreScopeInitializer { + /// + /// Gets a friendly name for the initializer, used for logging and error messages. + /// + string Name { get; } + /// /// Waits for the service to be ready before scope creation can proceed. /// diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md b/src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md index 16982538..14a1ba28 100644 --- a/src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md +++ b/src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md @@ -1,13 +1,16 @@ # Lifecycle Subsystem Architecture & Design ## Overview -The Lifecycle subsystem orchestrates application startup, shutdown, and scoped updates. Its main role is to manage -DI scopes for services that need to be re-initialized when configuration or data changes. It is not responsible for -configuration or service orchestration itself; those are handled by the .NET hosting model. +The Lifecycle subsystem orchestrates application startup, scope recycling, and shutdown. Its main role is to manage +DI scopes for services that need to be re-initialized when configuration or data changes (e.g., a new compiled layout +downloaded from the backend). It is not responsible for configuration or service orchestration itself; those are +handled by the .NET hosting model. ## Responsibilities & Boundaries -- **Scope management:** Creates and recycles DI scopes for "scoped lifecycle services" when updates occur. +- **Scope management:** Creates and recycles DI scopes for "scoped lifecycle services" when layout updates arrive. - **Lifecycle hooks:** Calls `InitializeAsync` and `CleanUpAsync` on services implementing [`IScopedLifecycle`](../IScopedLifecycle.cs) at the start and end of each scope. +- **Pre-scope initialization:** Awaits all [`IPreScopeInitializer`](./IPreScopeInitializer.cs) services (e.g., `CloudAssetOrchestrator`) before creating the first scope. Not re-awaited on recycles — the store is already populated. +- **Recycle signaling:** Responds to [`IApplicationRecycleSignal`](./IApplicationRecycleSignal.cs) to trigger a scope recycle; the signal is fired by cloud asset services when a new layout is available. - **UI independence:** Keeps orchestration logic separate from UI concerns; UI updates are handled via [`ILifecycleViewController`](../ILifecycleViewController.cs) when needed. ## Key Abstractions @@ -15,23 +18,53 @@ configuration or service orchestration itself; those are handled by the .NET hos - [`ScopedBackgroundProcess`](../ScopedBackgroundProcess.cs): Base class for background tasks that run within a scope, adapting `IScopedLifecycle` hooks for async method execution. - [`ILifecycleActivity`](../ILifecycleActivity.cs): Allows services to report progress/status during lifecycle events (useful for UI feedback). - [`IApplicationScope`/`IApplicationScopeProvider`](../IApplicationScopeFactory.cs): Abstracts DI scope creation, enabling sharing of Blazor-created scopes with other services. +- [`IApplicationRecycleSignal`](./IApplicationRecycleSignal.cs): Cross-service mechanism to request a scope recycle without coupling callers to the scope machinery. +- [`IPreScopeInitializer`](./IPreScopeInitializer.cs): Implemented by singleton services that must fully initialize before the first scope is created. ## Scope provider abstraction Blazor creates a DI scope for its own components, and that needs to be shared with all the other application services so that Blazor components can access initialized components. This is handled by `IApplicationScopeProvider`, which will execute work using a scoped IServiceProvider. The components involved are: - [`IApplicationScope`](./IApplicationScope.cs): Represents a DI scope in which work can be run. -- [`BlazorAppScope`](../../Components/BlazorAppScope.cs): Implements `IApplicationScope`. This object is created for the root Blazor component, and pushes itself into the `IApplicationScopeContainer`. +- [`BlazorAppScope`](../../Components/BlazorAppScope.cs): Implements `IApplicationScope`. Created for the root Blazor component; pushes itself into `IApplicationScopeContainer`. `RecycleAsync()` calls `IJSRuntime.InvokeVoidAsync("location.reload")`, causing the browser to reload and create a new Blazor scope. - [`IApplicationScopeContainer`](./IApplicationScopeContainer.cs): A scope object (such as `BlazorAppScope`) can be pushed into this interface, which is then used by `IApplicationScopeProvider` as the current scope. -- [`IApplicationScopeProvider`](./IApplicationScopeProvider.cs): Provides the ability to run work items in the current scope. -- [`ApplicationLifecycle`](./ApplicationLifecycle.cs): Uses `IApplicationScopeProvider` to get a `ScopedServiceContainer`. -- [`ScopedLifecycleContainer`](./ScopedLifecycleContainer.cs): A scoped service that resolves all the `IScopedLifecycle` services and manages calls to `InitializeAsync` and `CleanUpAsync` for the lifetime of the scope. +- [`IApplicationScopeProvider`](./IApplicationScopeProvider.cs): Provides the ability to run work items in the current scope, and to recycle (replace) the current scope. +- [`ApplicationLifecycle`](./ApplicationLifecycle.cs): Uses `IApplicationScopeProvider` to get a `ScopedLifecycleContainer`. +- [`ScopedLifecycleContainer`](./ScopedLifecycleContainer.cs): A scoped service that resolves all the `IScopedLifecycle` services and manages calls to `InitializeAsync` and `CleanUpAsync` for the lifetime of the scope. + +## Recycle loop +`ApplicationLifecycle.ExecuteAsync` runs as a `while` loop. Each iteration: + +1. Creates a linked `CancellationToken` from `stoppingToken + signal.Token`. +2. Calls `TryInitializeScopeAsync`, which invokes `InvokeInScopeAsync` to initialize all scoped services. Returns `true` if initialization completed successfully. +3. If init succeeded, logs `ScopeReady` and waits for either token to fire via `WaitForCancelledAsync` (returns normally — no exception). +4. Runs cleanup unconditionally, then branches: + + **Steady-state recycle** (signal fires after init completes, `stoppingToken` not set): + cleanup → `RecycleScopeAsync()` (triggers browser reload) → `signal.Reset()` → next iteration. + + **Init-phase recycle** (signal fires while `InitializeAllAsync` is running): + `InitializeAllAsync` cancels → `TryInitializeScopeAsync` returns `false` → cleanup → `signal.Reset()` → next iteration without a browser reload (the scope TCS is still valid). + + **Shutdown** (stoppingToken fires at any point): break the loop. + + **Init failure** (non-OCE exception during init): cleanup → log `ScopeReleased` → break the loop. + +5. After the loop: wait for `stoppingToken` (already cancelled), log `ShuttingDown`, run final cleanup. + +The **pre-initializers** (`IPreScopeInitializer`) are only awaited once — before the first scope — and are not re-awaited on recycles, since the asset store is already populated after the first successful scope. + +## Recycle signal +[`IApplicationRecycleSignal`](./IApplicationRecycleSignal.cs) / [`ApplicationRecycleSignal`](./ApplicationRecycleSignal.cs): +- `RequestRecycle()`: cancels the internal `CancellationTokenSource`. +- `Token`: the `CancellationToken` linked into the scope work item. +- `Reset()`: disposes the old CTS and creates a fresh one; called by `ApplicationLifecycle` after cleanup, before the next loop iteration. + +Callers (cloud asset services) call `RequestRecycle()` without knowing how the recycle is executed. ## Testability - The subsystem is unit tested using mock `IScopedLifecycle` services to verify correct orchestration and error handling. - -## Future Plans -- The update cycle is not yet implemented, but the architecture is designed to support live updates (e.g., remote layouts, speech models, configuration) so that services can reinitialize with new data without a full restart. +- Recycle behavior is tested by injecting a real `ApplicationRecycleSignal` and firing it at specific points. ## Updating This Document Update this document only when the overall design or boundaries of the Lifecycle subsystem change, or when new features are added. For implementation details, refer to source code and inline comments. diff --git a/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs b/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs index f1f84ff2..a28b9ca5 100644 --- a/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs @@ -1,4 +1,5 @@ -using FluentAssertions; +using AdaptiveRemote.Models; +using FluentAssertions; using Moq; namespace AdaptiveRemote.Services.Lifecycle; @@ -16,17 +17,36 @@ public class ApplicationLifecycleTests private readonly Mock MockLifecycleViewController = new(); private readonly Mock MockActivity = new(); private readonly Mock MockServiceProvider = new(); + private readonly Mock MockSignal = new(); private readonly MockLogger MockLogger = new(); public TestContext? TestContext { get; set; } public LifecyclePhase LatestLifecyclePhase { get; private set; } - private ApplicationLifecycle CreateSut() => new ApplicationLifecycle( - MockScopeProvider.Object, - MockLifecycleViewController.Object, - [], // Empty IPreScopeInitializer collection - MockLogger); + private ApplicationLifecycle CreateSut(params Mock[] preScopeInitializers) + => CreateSutWithSignal(MockSignal.Object, preScopeInitializers); + + private ApplicationLifecycle CreateSutWithSignal(IApplicationRecycleSignal signal, params Mock[] preInitializers) + => new ApplicationLifecycle( + MockScopeProvider.Object, + MockLifecycleViewController.Object, + signal, + preInitializers.Select(x => x.Object), + MockLogger); + + private static Mock CreatePreScopeInitializer(string name, Task? result = null) + { + Mock mockPreInit = new(); + mockPreInit + .SetupGet(x => x.Name) + .Returns(name); + mockPreInit + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(result ?? Task.CompletedTask) + .Verifiable(Times.Once); + return mockPreInit; + } [TestInitialize] public void SetupMocks() @@ -38,11 +58,6 @@ public void SetupMocks() return workItem.Invoke(MockServiceProvider.Object, cancellationToken); }); - MockServiceProvider - .Setup(x => x.GetService(typeof(ScopedLifecycleContainer))) - .Returns(() => new ScopedLifecycleContainer([MockService1.Object, MockService2.Object, MockService3.Object], MockLifecycleViewController.Object, MockLogger)) - .Verifiable(Times.Between(1, 2, Moq.Range.Inclusive)); - MockService1 .SetupGet(x => x.Name) .Returns(nameof(MockService1)); @@ -84,6 +99,15 @@ public void SetupMocks() .Setup(x => x.SetFatalError(It.IsAny())) .Callback(delegate (Exception ex) { Assert.Fail("SetFatalError was called on the activity: {0}", ex); }); + MockSignal + .SetupGet(x => x.Token) + .Returns(CancellationToken.None); // Never fires; existing tests don't exercise recycle + + MockScopeProvider + .Setup(x => x.RecycleScopeAsync()) + .Throws(() => new AssertFailedException($"Unexpected call to {nameof(IApplicationScopeProvider.RecycleScopeAsync)}")) + .Verifiable(Times.Never); + MockLogger.OutputWriter = TestContext; } @@ -92,6 +116,7 @@ public void VerifyMocks() { Verify(MockServiceProvider, nameof(MockServiceProvider)); Verify(MockScopeProvider, nameof(MockScopeProvider)); + Verify(MockSignal, nameof(MockSignal)); Verify(MockService1, nameof(MockService1)); Verify(MockService2, nameof(MockService2)); @@ -108,7 +133,7 @@ static void Verify(Mock mock, string name) } catch (MockException e) { - throw new Exception($"Verify failed on {name}: {e.Message}"); + throw new AssertFailedException($"Verify failed on {name}: {e.Message}"); } } } @@ -119,6 +144,8 @@ public void ApplicationLifecycle_StartAsync_StartsExecuteTaskAndInitializesScope // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); Expect_InitializeAsyncOn(MockService3); @@ -136,7 +163,7 @@ public void ApplicationLifecycle_StartAsync_StartsExecuteTaskAndInitializesScope log.ApplicationLifecycle_Initialized(MockService2.Object.Name); log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ScopeReady(); }); startTask.Should().BeComplete(because: "StartAsync should complete after all services are initialized"); @@ -150,6 +177,8 @@ public void ApplicationLifecycle_StartAsync_InitializesAllServicesWhileSomeCompl // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1, IncompleteTask); Expect_InitializeAsyncOn(MockService2, IncompleteTask); Expect_InitializeAsyncOn(MockService3, IncompleteTask); @@ -179,6 +208,8 @@ public void ApplicationLifecycle_StartAsync_ImmediateFailure_LogsErrorAndDoesNot Exception expectedError1 = new InvalidOperationException("Error 1"); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1, Task.CompletedTask); Expect_InitializeAsyncOn(MockService2, Task.FromException(expectedError1)); @@ -198,15 +229,15 @@ public void ApplicationLifecycle_StartAsync_ImmediateFailure_LogsErrorAndDoesNot log.ApplicationLifecycle_Initialized(MockService1.Object.Name); log.ApplicationLifecycle_Initializing(MockService2.Object.Name); log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, expectedError1); + log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); }); startTask.Should().BeComplete(because: "StartAsync should complete after all services are initialized"); - sut.ExecuteTask.Should().NotBeComplete(because: "ExecuteTask should remain running after startup"); + sut.ExecuteTask.Should().BeComplete(because: "ExecuteTask should exit if the loop ends"); LatestLifecyclePhase.Should().Be(LifecyclePhase.CleaningUp, because: "Services are being cleaned up after failure"); } @@ -219,6 +250,8 @@ public void ApplicationLifecycle_StartAsync_DelayedFailure_LogsErrorAndDoesNotSt Exception expectedError1 = new InvalidOperationException("Error 1"); TaskCompletionSource tcs = new(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2, tcs.Task); Expect_InitializeAsyncOn(MockService3); @@ -253,16 +286,16 @@ public void ApplicationLifecycle_StartAsync_DelayedFailure_LogsErrorAndDoesNotSt log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, expectedError1); + log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService3.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); }); - sut.ExecuteTask.Should().NotBeComplete(because: "ExecuteTask should remain running after startup"); + sut.ExecuteTask.Should().BeComplete(because: "ExecuteTask should exit if the loop ends"); LatestLifecyclePhase.Should().Be(LifecyclePhase.CleaningUp, because: "Services are being cleaned up after failure"); } @@ -292,7 +325,7 @@ public void ApplicationLifecycle_StartAsync_ErrorDuringConstructor_SetsFatalErro { log.ApplicationLifecycle_WaitingForScope(); log.ApplicationLifecycle_ScopeConstructionFailed(expectedError1); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ShuttingDown(); }); } @@ -326,7 +359,6 @@ public void ApplicationLifecycle_StopAsync_AfterErrorDuringConstructor_DoesNothi { log.ApplicationLifecycle_WaitingForScope(); log.ApplicationLifecycle_ScopeConstructionFailed(expectedError1); - log.ApplicationLifecycle_ScopeReleased(); log.ApplicationLifecycle_ShuttingDown(); }); } @@ -339,6 +371,8 @@ public void ApplicationLifecycle_StartAsync_ImmediateFailure_CancelsStartupThatI Exception expectedError1 = new InvalidOperationException("Error 1"); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1, IncompleteTask); Expect_InitializeAsyncOn(MockService2, Task.FromException(expectedError1)); @@ -357,15 +391,15 @@ public void ApplicationLifecycle_StartAsync_ImmediateFailure_CancelsStartupThatI log.ApplicationLifecycle_Initializing(MockService1.Object.Name); log.ApplicationLifecycle_Initializing(MockService2.Object.Name); log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, expectedError1); + log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); }); startTask.Should().BeComplete(because: "StartAsync should complete after all services are initialized"); - sut.ExecuteTask.Should().NotBeComplete(because: "ExecuteTask should remain running after startup"); + sut.ExecuteTask.Should().BeComplete(because: "ExecuteTask should exit if the loop ends"); LatestLifecyclePhase.Should().Be(LifecyclePhase.CleaningUp, because: "Services are being cleaned up after failure"); } @@ -375,6 +409,8 @@ public void ApplicationLifecycle_StopAsync_DisposesScopeAndCompletesTask() // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); Expect_InitializeAsyncOn(MockService3); @@ -402,7 +438,7 @@ public void ApplicationLifecycle_StopAsync_DisposesScopeAndCompletesTask() log.ApplicationLifecycle_Initialized(MockService2.Object.Name); log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ScopeReady(); log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); @@ -423,6 +459,8 @@ public void ApplicationLifecycle_StopAsync_BlocksUntilServicesAreCleanedUp() // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); Expect_InitializeAsyncOn(MockService3); @@ -450,7 +488,7 @@ public void ApplicationLifecycle_StopAsync_BlocksUntilServicesAreCleanedUp() log.ApplicationLifecycle_Initialized(MockService2.Object.Name); log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ScopeReady(); log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); @@ -471,6 +509,8 @@ public void ApplicationLifecycle_StopAsync_ReportsErrorsInCleanUp() Exception expectedError1 = new InvalidOperationException("Error 1"); Exception expectedError2 = new FormatException("Error 2"); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); Expect_InitializeAsyncOn(MockService3); @@ -499,7 +539,7 @@ public void ApplicationLifecycle_StopAsync_ReportsErrorsInCleanUp() log.ApplicationLifecycle_Initialized(MockService2.Object.Name); log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ScopeReady(); log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUpFailed(MockService1.Object.Name, expectedError1); @@ -520,6 +560,8 @@ public void ApplicationLifecycle_StopAsync_CancelsInitializeMethodsThatAreWaitin // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2, result: IncompleteTask); Expect_InitializeAsyncOn(MockService3); @@ -568,6 +610,8 @@ public void ApplicationLifecycle_StopAsync_AfterInitializeFailure_DoesNothing() Exception expectedError1 = new InvalidOperationException("Error 1"); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1, Task.CompletedTask); Expect_InitializeAsyncOn(MockService2, Task.FromException(expectedError1)); @@ -596,12 +640,11 @@ public void ApplicationLifecycle_StopAsync_AfterInitializeFailure_DoesNothing() log.ApplicationLifecycle_Initialized(MockService1.Object.Name); log.ApplicationLifecycle_Initializing(MockService2.Object.Name); log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, expectedError1); + log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); - log.ApplicationLifecycle_ShuttingDown(); }); sut.ExecuteTask.Should().BeComplete(because: "ExecuteTask should complete after all services have stopped"); @@ -614,12 +657,24 @@ private static void Expect_InitializeAsyncOn(Mock service, Tas .WithStandardTaskBehavior(result) .Verifiable(Times.Once); + private static void Expect_InitializeAsyncAtLeastOnce(Mock service, Task? result = default) + => service + .Setup(x => x.InitializeAsync(It.IsAny(), It.IsAny())) + .WithStandardTaskBehavior(result) + .Verifiable(Times.AtLeastOnce); + private static void Expect_CleanupAsyncOn(Mock service, Task? result = default) => service .Setup(x => x.CleanUpAsync(It.IsAny(), It.IsAny())) .WithStandardTaskBehavior(result) .Verifiable(Times.Once); + private static void Expect_CleanupAsyncAtLeastOnce(Mock service, Task? result = default) + => service + .Setup(x => x.CleanUpAsync(It.IsAny(), It.IsAny())) + .WithStandardTaskBehavior(result) + .Verifiable(Times.AtLeastOnce); + private static void Expect_SetFatalErrorOn(Mock activity, params Exception[] expectedExceptions) => activity .Setup(x => x.SetFatalError(It.IsAny())) @@ -640,17 +695,521 @@ private static void Expect_SetFatalErrorOn(Mock contro }) .Verifiable(Times.Exactly(expectedExceptions.Length)); + private static void Expect_RecycleScopeAsyncOn(Mock provider, Times? times = null) + => provider + .Setup(x => x.RecycleScopeAsync()) + .WithStandardTaskBehavior() + .Verifiable(times ?? Times.Once()); + + private void Expect_GetServiceScopedLifecycleContainerOn(Mock provider, Times? times = null) + => provider + .Setup(x => x.GetService(typeof(ScopedLifecycleContainer))) + .Returns(() => new ScopedLifecycleContainer([MockService1.Object, MockService2.Object, MockService3.Object], MockLifecycleViewController.Object, MockLogger)) + .Verifiable(times ?? Times.Once()); + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringReadyState_CallsRecycleScope() + { + // Arrange + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for first scope to enter steady state + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act: fire recycle signal during steady state + signal.RequestRecycle(); + + // Assert: RecycleScopeAsync is called (and RecyclingScope is logged before it) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Once); + + // Arrange + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + + // Stop to end the second scope + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringReadyState_LoopsToNextScope() + { + // Arrange + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for steady state + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + // Act: fire recycle + signal.RequestRecycle(); + + // Assert: loop continues — second scope's ScopeReleased eventually logged + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // WaitingForScope appears twice: once at start and once after Reset + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_Initializing(MockService1.Object.Name), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2), because: "loop should re-enter a new scope and start initializing again"); + + // Arrange + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + + // Act + Task stopTask = sut.StopAsync(default); + + // Assert + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + + int waitingForScopeCount = MockLogger.CountMessages(log => log.ApplicationLifecycle_WaitingForScope()); + waitingForScopeCount.Should().Be(2, because: "the loop should iterate twice: initial scope and post-recycle scope"); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringInit_CancelsAndCallsRecycleScope() + { + // Arrange: MockService2 init hangs until the signal fires and cancels it + TaskCompletionSource hangingInitTcs = new(); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2, hangingInitTcs.Task); + Expect_InitializeAsyncOn(MockService3); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for MockService2 to start initializing (confirming we are mid-init) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_Initializing(MockService2.Object.Name), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + Expect_RecycleScopeAsyncOn(MockScopeProvider); + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2, hangingInitTcs.Task); + Expect_InitializeAsyncOn(MockService3); + + // Act: fire recycle while init is in progress + signal.RequestRecycle(); + + // Assert: cleanup starts (proves signal was processed) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + + log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); + log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); + log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); + log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); + log.ApplicationLifecycle_CleaningUp(MockService3.Object.Name); + log.ApplicationLifecycle_CleanedUp(MockService3.Object.Name); + log.ApplicationLifecycle_RecyclingScope(); + + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + }); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DoesNotReawaitPreInitializers() + { + // Arrange + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit)); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal, mockPreInit); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for first scope to reach steady state + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + Expect_RecycleScopeAsyncOn(MockScopeProvider); + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act: fire recycle signal + signal.RequestRecycle(); + + // Wait for recycle to complete and loop to continue + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for the second scope to start initializing — confirms the loop re-entered without re-running pre-inits + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + + // Assert: pre-initializer was called exactly once (Times.Once is verified by VerifyMocks) + mockPreInit.Verify(x => x.WaitAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringCleanup_SecondSignalIsNoOp() + { + // Arrange: service1 cleanup hangs so we can observe the cleanup phase + TaskCompletionSource cleanupTcs = new(); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + Expect_InitializeAsyncAtLeastOnce(MockService2); + Expect_InitializeAsyncAtLeastOnce(MockService3); + Expect_CleanupAsyncAtLeastOnce(MockService1, cleanupTcs.Task); + Expect_CleanupAsyncAtLeastOnce(MockService2); + Expect_CleanupAsyncAtLeastOnce(MockService3); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Act: first recycle during steady state + signal.RequestRecycle(); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Verify not recycled yet while cleanup is in progress + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Never); + + // Second signal during cleanup — already cancelled, so this is a no-op + signal.RequestRecycle(); + + // Complete cleanup — recycle should proceed exactly once + cleanupTcs.SetResult(); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Once); + + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringReadyState_BlocksUntilCleanupCompletes() + { + // Arrange: service1 cleanup hangs so we can verify RecycleScopeAsync is not called prematurely + TaskCompletionSource cleanupTcs = new(); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + Expect_InitializeAsyncAtLeastOnce(MockService2); + Expect_InitializeAsyncAtLeastOnce(MockService3); + Expect_CleanupAsyncAtLeastOnce(MockService1, cleanupTcs.Task); + Expect_CleanupAsyncAtLeastOnce(MockService2); + Expect_CleanupAsyncAtLeastOnce(MockService3); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Act: fire recycle — cleanup starts but is incomplete + signal.RequestRecycle(); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // RecycleScopeAsync must not be called while cleanup is still in progress + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Never); + + // Complete cleanup — RecycleScopeAsync should now be called + cleanupTcs.SetResult(); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Once); + + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_AfterRecycle_WaitsForInitializationBeforeReady() + { + // Arrange: after recycle, service2 init hangs in the second scope + int service2InitCalls = 0; + TaskCompletionSource service2SecondInitTcs = new(); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + MockService2 + .Setup(x => x.InitializeAsync(It.IsAny(), It.IsAny())) + .Returns(delegate (IInvocation invocation) + { + TaskCompletionSource tcs = new(); + foreach (object arg in invocation.Arguments) + { + if (arg is CancellationToken ct) + { + ct.Register(() => tcs.TrySetCanceled()); + break; + } + } + if (Interlocked.Increment(ref service2InitCalls) == 1) + { + tcs.TrySetResult(); + } + else + { + service2SecondInitTcs.Task.ContinueWith(_ => tcs.TrySetResult(), TaskContinuationOptions.ExecuteSynchronously); + } + return tcs.Task; + }) + .Verifiable(Times.AtLeast(2)); + Expect_InitializeAsyncAtLeastOnce(MockService3); + Expect_CleanupAsyncAtLeastOnce(MockService1); + Expect_CleanupAsyncAtLeastOnce(MockService2); + Expect_CleanupAsyncAtLeastOnce(MockService3); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for first scope to reach steady state, then recycle + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + signal.RequestRecycle(); + + // Wait for second scope init to start + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_Initializing(MockService2.Object.Name), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2), because: "second scope should begin initializing after recycle"); + + // Assert: scope is not yet ready (still initializing service2) + MockLogger.CountMessages(log => log.ApplicationLifecycle_ScopeReady()) + .Should().Be(1, because: "ScopeReady should only appear once — the second scope is still initializing"); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_ErrorDuringCleanup_ContinuesToRecycle() + { + // Arrange: service2 cleanup throws; lifecycle should log the error but still call RecycleScopeAsync + Exception cleanupError = new InvalidOperationException("Cleanup failure"); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + Expect_InitializeAsyncAtLeastOnce(MockService2); + Expect_InitializeAsyncAtLeastOnce(MockService3); + Expect_CleanupAsyncAtLeastOnce(MockService1); + Expect_CleanupAsyncAtLeastOnce(MockService2, Task.FromException(cleanupError)); + Expect_CleanupAsyncAtLeastOnce(MockService3); + // SetFatalError is called once per failing cleanup; service2 cleanup fails in each scope iteration + Expect_SetFatalErrorOn(MockActivity, cleanupError, cleanupError); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Act + signal.RequestRecycle(); + + // Assert: error is logged and RecycleScopeAsync is still called despite the cleanup failure + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_CleaningUpFailed(MockService2.Object.Name, cleanupError), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Once); + + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_AfterRecycle_ErrorDuringInit_ExitsLoop() + { + // Arrange: service2 init succeeds in first scope but fails in second scope after recycle + Exception initError = new InvalidOperationException("Init failure after recycle"); + int service2InitCalls = 0; + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + MockService2 + .Setup(x => x.InitializeAsync(It.IsAny(), It.IsAny())) + .Returns(delegate (IInvocation invocation) + { + return Interlocked.Increment(ref service2InitCalls) == 1 + ? Task.CompletedTask + : Task.FromException(initError); + }) + .Verifiable(Times.AtLeast(2)); + Expect_InitializeAsyncOn(MockService3); // only initialized in first scope (second scope aborts before service3) + Expect_CleanupAsyncAtLeastOnce(MockService1); + Expect_CleanupAsyncAtLeastOnce(MockService2); + Expect_CleanupAsyncOn(MockService3); // only cleaned up in first scope + + Expect_SetFatalErrorOn(MockActivity, initError); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for first scope to reach steady state, then recycle + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + signal.RequestRecycle(); + + // Wait for second scope to fail and exit the loop + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ShuttingDown(), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2), because: "init failure in second scope should exit the loop"); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, initError), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringPreInit_IsNoOp() + { + // Arrange: pre-init hangs; signal fires while it is waiting + TaskCompletionSource preInitTcs = new(); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit), preInitTcs.Task); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal, mockPreInit); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Fire signal while pre-init is still waiting — no scope exists yet to recycle + signal.RequestRecycle(); + + // Complete pre-init — scope should be created normally despite the signal + preInitTcs.SetResult(); + + // Assert: scope reaches ready state (lifecycle continues normally after the no-op recycle) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2), because: "scope should be created normally after pre-init completes"); + } + [TestMethod] public void ApplicationLifecycle_StartAsync_WaitsForPreInitializers() { // Arrange - Mock mockPreInit = new(); - mockPreInit - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit)); + + ApplicationLifecycle sut = CreateSut(mockPreInit); - ApplicationLifecycle sut = new(MockScopeProvider.Object, MockLifecycleViewController.Object, [mockPreInit.Object], MockLogger); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); @@ -665,6 +1224,17 @@ public void ApplicationLifecycle_StartAsync_WaitsForPreInitializers() .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); // Assert + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); mockPreInit.Verify(); } @@ -673,13 +1243,11 @@ public void ApplicationLifecycle_StartAsync_PreInitializerDelayed_WaitsBeforeSta { // Arrange TaskCompletionSource preInitTcs = new(); - Mock mockPreInit = new(); - mockPreInit - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(preInitTcs.Task) - .Verifiable(Times.Once); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit)); - ApplicationLifecycle sut = new(MockScopeProvider.Object, MockLifecycleViewController.Object, [mockPreInit.Object], MockLogger); + ApplicationLifecycle sut = CreateSut(mockPreInit); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); @@ -698,6 +1266,19 @@ public void ApplicationLifecycle_StartAsync_PreInitializerDelayed_WaitsBeforeSta // Assert MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(1)) .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); + mockPreInit.Verify(); } @@ -705,28 +1286,13 @@ public void ApplicationLifecycle_StartAsync_PreInitializerDelayed_WaitsBeforeSta public void ApplicationLifecycle_StartAsync_MultiplePreInitializers_WaitsForAll() { // Arrange - Mock mockPreInit1 = new(); - Mock mockPreInit2 = new(); - Mock mockPreInit3 = new(); + Mock mockPreInit1 = CreatePreScopeInitializer(nameof(mockPreInit1)); + Mock mockPreInit2 = CreatePreScopeInitializer(nameof(mockPreInit2)); + Mock mockPreInit3 = CreatePreScopeInitializer(nameof(mockPreInit3)); - mockPreInit1 - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); - mockPreInit2 - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); - mockPreInit3 - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); + ApplicationLifecycle sut = CreateSut(mockPreInit1, mockPreInit2, mockPreInit3); - ApplicationLifecycle sut = new( - MockScopeProvider.Object, - MockLifecycleViewController.Object, - [mockPreInit1.Object, mockPreInit2.Object, mockPreInit3.Object], - MockLogger); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); @@ -741,6 +1307,18 @@ public void ApplicationLifecycle_StartAsync_MultiplePreInitializers_WaitsForAll( .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); // Assert + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); + mockPreInit1.Verify(); mockPreInit2.Verify(); mockPreInit3.Verify(); @@ -751,27 +1329,23 @@ public void ApplicationLifecycle_StartAsync_PreInitializerFails_SetsActivityErro { // Arrange Exception expectedError = new InvalidOperationException("PreInit failed"); - Mock mockPreInit = new(); - - mockPreInit - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.FromException(expectedError)) - .Verifiable(Times.Once); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit), Task.FromException(expectedError)); - // Don't expect scope to be created when pre-init fails - MockServiceProvider - .Setup(x => x.GetService(typeof(ScopedLifecycleContainer))) - .Verifiable(Times.Never); + Expect_SetFatalErrorOn(MockActivity, expectedError); - ApplicationLifecycle sut = new(MockScopeProvider.Object, MockLifecycleViewController.Object, [mockPreInit.Object], MockLogger); + ApplicationLifecycle sut = CreateSut(mockPreInit); // Act Task startTask = sut.StartAsync(default); startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); // Assert - ExecuteTask should fault with unhandled error - MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_UnhandledError(expectedError), TimeSpan.FromSeconds(1)) - .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_PreinitializerFailed(mockPreInit.Object.Name, expectedError); + log.ApplicationLifecycle_ShuttingDown(); + }); + mockPreInit.Verify(); } @@ -780,35 +1354,17 @@ public void ApplicationLifecycle_StartAsync_LastPreInitializerFails_StopsBeforeS { // Arrange Exception expectedError = new InvalidOperationException("Last PreInit failed"); - Mock mockPreInit1 = new(); - Mock mockPreInit2 = new(); - - mockPreInit1 - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); - mockPreInit2 - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.FromException(expectedError)) - .Verifiable(Times.Once); - - // Don't expect scope to be created when pre-init fails - MockServiceProvider - .Setup(x => x.GetService(typeof(ScopedLifecycleContainer))) - .Verifiable(Times.Never); + Mock mockPreInit1 = CreatePreScopeInitializer(nameof(mockPreInit1)); + Mock mockPreInit2 = CreatePreScopeInitializer(nameof(mockPreInit2), Task.FromException(expectedError)); - ApplicationLifecycle sut = new( - MockScopeProvider.Object, - MockLifecycleViewController.Object, - [mockPreInit1.Object, mockPreInit2.Object], - MockLogger); + ApplicationLifecycle sut = CreateSut(mockPreInit1, mockPreInit2); // Act Task startTask = sut.StartAsync(default); startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); // Assert - execution should fail before creating scope - MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_UnhandledError(expectedError), TimeSpan.FromSeconds(1)) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_PreinitializerFailed(mockPreInit2.Object.Name, expectedError), TimeSpan.FromSeconds(1)) .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); MockLogger.Messages.Should().NotContain(m => m.Contains("WaitingForScope")); mockPreInit1.Verify(); @@ -819,20 +1375,17 @@ public void ApplicationLifecycle_StartAsync_LastPreInitializerFails_StopsBeforeS public void ApplicationLifecycle_StartAsync_PreInitializerCreatesActivityForEach() { // Arrange - Mock mockPreInit = new(); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit)); Mock mockActivity = new(); - mockPreInit - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); - MockLifecycleViewController - .Setup(x => x.StartTask(It.Is(s => s.Contains("CloudAssetOrchestrator") || s.Contains("PreScopeInitializer")))) + .Setup(x => x.StartTask(Phrases.Startup_Preinitializing(mockPreInit.Object.Name))) .Returns(mockActivity.Object) .Verifiable(Times.Once); - ApplicationLifecycle sut = new(MockScopeProvider.Object, MockLifecycleViewController.Object, [mockPreInit.Object], MockLogger); + ApplicationLifecycle sut = CreateSut(mockPreInit); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); @@ -852,6 +1405,18 @@ public void ApplicationLifecycle_StartAsync_PreInitializerCreatesActivityForEach // Assert - Activity should be disposed after pre-initializer completes mockActivity.Verify(x => x.Dispose(), Times.Once, "Activity should be disposed after pre-initializer completes"); + + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); } [TestMethod] @@ -859,23 +1424,13 @@ public void ApplicationLifecycle_StartAsync_PreInitializerActivity_DisposesImmed { // Arrange TaskCompletionSource slowPreInitTcs = new(); - Mock fastPreInit = new(); - Mock slowPreInit = new(); + Mock fastPreInit = CreatePreScopeInitializer(nameof(fastPreInit)); + Mock slowPreInit = CreatePreScopeInitializer(nameof(slowPreInit), slowPreInitTcs.Task); Mock fastActivity = new(); Mock slowActivity = new(); int callCount = 0; - fastPreInit - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask) - .Verifiable(Times.Once); - - slowPreInit - .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) - .Returns(slowPreInitTcs.Task) - .Verifiable(Times.Once); - // Mock StartTask to return different activities based on call order MockLifecycleViewController .Setup(x => x.StartTask(It.IsAny())) @@ -894,11 +1449,9 @@ public void ApplicationLifecycle_StartAsync_PreInitializerActivity_DisposesImmed return MockActivity.Object; }); - ApplicationLifecycle sut = new( - MockScopeProvider.Object, - MockLifecycleViewController.Object, - [fastPreInit.Object, slowPreInit.Object], - MockLogger); + ApplicationLifecycle sut = CreateSut(fastPreInit, slowPreInit); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); @@ -924,6 +1477,19 @@ public void ApplicationLifecycle_StartAsync_PreInitializerActivity_DisposesImmed // Assert - Now slow activity should be disposed too slowActivity.Verify(x => x.Dispose(), Times.Once, "Slow activity should be disposed after completing"); - } + // The first log message is for waiting for the slow preinitializer + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForPreinitializer("slowPreInit"); + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); + } } diff --git a/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs b/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs index da8e1165..ccace17b 100644 --- a/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs +++ b/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs @@ -42,15 +42,8 @@ exception is AssertInconclusiveException || OutputWriter?.WriteLine(message); } - public void VerifyMessages(Action expected) - { - MockLogger expectedLog = new(); - expectedLog.ReplaceStrings.AddRange(ReplaceStrings); - MessageLogger messageLogger = new(expectedLog); - expected(messageLogger); - - VerifyMessages(expectedLog._messages.ToArray()); - } + public void VerifyMessages(Action expected) + => VerifyMessages(GetMessageLogMessages(expected)); public void VerifyMessages(params string[] expected) { @@ -102,6 +95,16 @@ public void VerifyMessages(params string[] expected) } } + private string[] GetMessageLogMessages(Action expected) + { + MockLogger expectedLog = new(); + expectedLog.ReplaceStrings.AddRange(ReplaceStrings); + MessageLogger messageLogger = new(expectedLog); + expected(messageLogger); + + return expectedLog._messages.ToArray(); + } + private static List GetRemaining(IEnumerator iter, ref int count) { List remaining = new(); @@ -161,6 +164,19 @@ internal async Task WaitForMessageAsync(string expectedMessage, TimeSpan timeout } } + internal int CountMessages(Action messages) + => CountMessages(GetMessageLogMessages(messages)); + + public int CountMessages(params string[] messages) + { + int count = 0; + foreach (string expected in messages) + { + count += _messages.Count(m => m.StartsWith(expected)); + } + return count; + } + internal void ClearMessages() { _messages.Clear(); From 9933634ae2faae13708b0c4f32fd2ba3a56aa292 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 20:05:11 +0000 Subject: [PATCH 09/14] [ADR-180] Idle detection: IIdleDetector, IdleDetector, and three ViewModel adapter services --- .../CloudAssetServiceExtensions.cs | 2 + .../ConversationHostBuilderExtensions.cs | 3 +- .../Configuration/HostBuilderExtensions.cs | 2 + .../Services/CloudAssets/IdleDetector.cs | 38 ++++++ .../Commands/CommandExecutionIdleAdapter.cs | 16 +++ .../Services/Commands/CommandIdleAdapter.cs | 12 ++ .../Conversation/ConversationIdleAdapter.cs | 11 ++ .../Services/IIdleDetector.cs | 8 ++ .../Services/IUserActivityDetector.cs | 6 + .../Lifecycle/ProgrammingModeIdleAdapter.cs | 11 ++ .../Services/MvvmPropertyIdleAdapter.cs | 48 +++++++ .../Services/CloudAssets/IdleDetectorTests.cs | 107 +++++++++++++++ .../Services/MvvmPropertyIdleAdapterTests.cs | 127 ++++++++++++++++++ 13 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/IdleDetector.cs create mode 100644 src/AdaptiveRemote.App/Services/Commands/CommandExecutionIdleAdapter.cs create mode 100644 src/AdaptiveRemote.App/Services/Commands/CommandIdleAdapter.cs create mode 100644 src/AdaptiveRemote.App/Services/Conversation/ConversationIdleAdapter.cs create mode 100644 src/AdaptiveRemote.App/Services/IIdleDetector.cs create mode 100644 src/AdaptiveRemote.App/Services/IUserActivityDetector.cs create mode 100644 src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeIdleAdapter.cs create mode 100644 src/AdaptiveRemote.App/Services/MvvmPropertyIdleAdapter.cs create mode 100644 test/AdaptiveRemote.App.Tests/Services/CloudAssets/IdleDetectorTests.cs create mode 100644 test/AdaptiveRemote.App.Tests/Services/MvvmPropertyIdleAdapterTests.cs diff --git a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs index 2292551e..4f18ac6c 100644 --- a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs @@ -1,3 +1,4 @@ +using AdaptiveRemote.Services; using AdaptiveRemote.Services.CloudAssets; using AdaptiveRemote.Services.Lifecycle; using Microsoft.Extensions.Configuration; @@ -10,6 +11,7 @@ internal static class CloudAssetServiceExtensions internal static IServiceCollection AddCloudAssetServices(this IServiceCollection services, IConfiguration configuration) => services .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(sp => sp.GetRequiredService()) diff --git a/src/AdaptiveRemote.App/Configuration/ConversationHostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/ConversationHostBuilderExtensions.cs index 2ac09a0c..0593a61f 100644 --- a/src/AdaptiveRemote.App/Configuration/ConversationHostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/ConversationHostBuilderExtensions.cs @@ -22,7 +22,8 @@ internal static IServiceCollection AddConversationServices(this IServiceCollecti .AddSingleton() .AddScoped(GetConversationViewModel) .AddSingleton() - .AddSingleton(); + .AddSingleton() + .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 443a20ed..0316a349 100644 --- a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs @@ -36,6 +36,8 @@ internal static IServiceCollection AddRemoteServices(this IServiceCollection ser resourcePath: "/layouts/compiled", jsonContext: LayoutContractsJsonContext.Default)) .AddScopedLifecycleService() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddSingleton() diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/IdleDetector.cs b/src/AdaptiveRemote.App/Services/CloudAssets/IdleDetector.cs new file mode 100644 index 00000000..4fcababa --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/IdleDetector.cs @@ -0,0 +1,38 @@ +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/Commands/CommandExecutionIdleAdapter.cs b/src/AdaptiveRemote.App/Services/Commands/CommandExecutionIdleAdapter.cs new file mode 100644 index 00000000..f4ba5334 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Commands/CommandExecutionIdleAdapter.cs @@ -0,0 +1,16 @@ +namespace AdaptiveRemote.Services.Commands; + +// Creates one CommandIdleAdapter per command and delegates lifecycle calls to them. +internal class CommandExecutionIdleAdapter : IUserActivityDetector +{ + private readonly IReadOnlyList _adapters; + + public DateTime LastActivityTime => _adapters.Select(x => x.LastActivityTime).Max(); + + public CommandExecutionIdleAdapter(IRemoteDefinitionService remoteDefinition, IIdleDetector idleDetector) + { + _adapters = remoteDefinition.GetCommands() + .Select(cmd => new CommandIdleAdapter(cmd, idleDetector)) + .ToList(); + } +} diff --git a/src/AdaptiveRemote.App/Services/Commands/CommandIdleAdapter.cs b/src/AdaptiveRemote.App/Services/Commands/CommandIdleAdapter.cs new file mode 100644 index 00000000..8fd0fa00 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Commands/CommandIdleAdapter.cs @@ -0,0 +1,12 @@ +using AdaptiveRemote.Models; + +namespace AdaptiveRemote.Services.Commands; + +// Tracks IsActive for a single Command; created by CommandExecutionIdleAdapter. +internal sealed class CommandIdleAdapter : MvvmPropertyIdleAdapter +{ + internal CommandIdleAdapter(Command command, IIdleDetector idleDetector) + : base(command, Command.IsActiveProperty) + { + } +} diff --git a/src/AdaptiveRemote.App/Services/Conversation/ConversationIdleAdapter.cs b/src/AdaptiveRemote.App/Services/Conversation/ConversationIdleAdapter.cs new file mode 100644 index 00000000..53a26444 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Conversation/ConversationIdleAdapter.cs @@ -0,0 +1,11 @@ +using AdaptiveRemote.Models; + +namespace AdaptiveRemote.Services.Conversation; + +internal class ConversationIdleAdapter : MvvmPropertyIdleAdapter +{ + public ConversationIdleAdapter(IRemoteDefinitionService remoteDefinition, IIdleDetector idleDetector) + : base(remoteDefinition.GetElement(), ConversationView.IsListeningProperty) + { + } +} diff --git a/src/AdaptiveRemote.App/Services/IIdleDetector.cs b/src/AdaptiveRemote.App/Services/IIdleDetector.cs new file mode 100644 index 00000000..dbf4e71d --- /dev/null +++ b/src/AdaptiveRemote.App/Services/IIdleDetector.cs @@ -0,0 +1,8 @@ +namespace AdaptiveRemote.Services; + +/// +/// +internal interface IIdleDetector +{ + Task WaitForIdleAsync(CancellationToken cancellationToken); +} diff --git a/src/AdaptiveRemote.App/Services/IUserActivityDetector.cs b/src/AdaptiveRemote.App/Services/IUserActivityDetector.cs new file mode 100644 index 00000000..ed174a7e --- /dev/null +++ b/src/AdaptiveRemote.App/Services/IUserActivityDetector.cs @@ -0,0 +1,6 @@ +namespace AdaptiveRemote.Services; + +internal interface IUserActivityDetector +{ + DateTime LastActivityTime { get; } +} diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeIdleAdapter.cs b/src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeIdleAdapter.cs new file mode 100644 index 00000000..5ec12e3b --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeIdleAdapter.cs @@ -0,0 +1,11 @@ +using AdaptiveRemote.Models; + +namespace AdaptiveRemote.Services.Lifecycle; + +internal class ProgrammingModeIdleAdapter : MvvmPropertyIdleAdapter +{ + public ProgrammingModeIdleAdapter(LifecycleView lifecycleView, IIdleDetector idleDetector) + : base(lifecycleView, LifecycleView.IsProgrammingModeProperty) + { + } +} diff --git a/src/AdaptiveRemote.App/Services/MvvmPropertyIdleAdapter.cs b/src/AdaptiveRemote.App/Services/MvvmPropertyIdleAdapter.cs new file mode 100644 index 00000000..1af8f5ca --- /dev/null +++ b/src/AdaptiveRemote.App/Services/MvvmPropertyIdleAdapter.cs @@ -0,0 +1,48 @@ +using System.ComponentModel; +using AdaptiveRemote.Mvvm; + +namespace AdaptiveRemote.Services; + +// 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 +{ + private readonly MvvmObject _target; + private readonly MvvmProperty _property; + private DateTime? _lastActivityTime; + + protected MvvmPropertyIdleAdapter(MvvmObject target, MvvmProperty property) + { + _target = target ?? throw new ArgumentNullException(nameof(target)); + _property = property ?? throw new ArgumentNullException(nameof(property)); + + _target.PropertyChanged += OnPropertyChanged; + _lastActivityTime = target.GetValue(property) ? null : DateTime.MinValue; + } + + public DateTime LastActivityTime => _lastActivityTime ?? DateTime.Now; + + private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != _property.Name) + { + return; + } + + if (_target.GetValue(_property)) + { + _lastActivityTime = null; + } + else + { + _lastActivityTime ??= DateTime.Now; + } + } + + public void Dispose() + { + _target.PropertyChanged -= OnPropertyChanged; + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/IdleDetectorTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/IdleDetectorTests.cs new file mode 100644 index 00000000..45c2a4dd --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/IdleDetectorTests.cs @@ -0,0 +1,107 @@ +using System.Diagnostics; +using FluentAssertions; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class IdleDetectorTests +{ + private readonly MockOptions MockOptions = new(); + + [TestMethod] + public void WaitForIdleAsync_Completes_WhenNoActivity() + { + // Arrange + FakeUserActivityDetector fake = new(DateTime.MinValue); + IdleDetector sut = new(new[] { fake }, new FakeOptions(0)); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); + + // Act + Task resultTask = sut.WaitForIdleAsync(cts.Token); + + // Assert + resultTask.Should().BeComplete(); + } + + [TestMethod] + public void WaitForIdleAsync_WaitsForCooldown() + { + // Arrange + DateTime now = DateTime.Now; + FakeUserActivityDetector fake = new(now); + int cooldown = 1; // seconds + IdleDetector sut = new(new[] { fake }, new FakeOptions(cooldown)); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(5)); + Stopwatch sw = System.Diagnostics.Stopwatch.StartNew(); + + // Act + Task waitTask = sut.WaitForIdleAsync(cts.Token); + + // Assert + waitTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(cooldown + 1)); + sw.Elapsed.TotalSeconds.Should().BeGreaterThanOrEqualTo(cooldown); + } + + [TestMethod] + public void WaitForIdleAsync_Cancels_IfTokenCancelled() + { + // Arrange + FakeUserActivityDetector fake = new(DateTime.Now); + IdleDetector sut = new(new[] { fake }, new FakeOptions(10)); + CancellationTokenSource cts = new(); + Task task = sut.WaitForIdleAsync(cts.Token); + + // Act + cts.Cancel(); + + // Assert + task.Should().BeCanceledWithin(TimeSpan.FromSeconds(1)); + } + + [TestMethod] + public void WaitForIdleAsync_Throws_IfDetectorThrows() + { + // Arrange + ThrowingUserActivityDetector fake = new(); + IdleDetector sut = new(new[] { fake }, new FakeOptions(1)); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); + + // Act + Task resultTask = sut.WaitForIdleAsync(cts.Token); + + // Assert + resultTask.Should().BeFaultedWith(new InvalidOperationException("Simulated error")); + } + + [TestMethod] + public void WaitForIdleAsync_Handles_EmptyDetectorList() + { + // Arrange + IdleDetector sut = new(new List(), new FakeOptions(0)); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); + + // Act + Task task = sut.WaitForIdleAsync(cts.Token); + + // Assert + task.Should().BeComplete(because: "there are no activity detectors, so it is always idle"); + } + + // Helpers + private class FakeUserActivityDetector : IUserActivityDetector + { + public DateTime LastActivityTime { get; set; } + public FakeUserActivityDetector(DateTime t) => LastActivityTime = t; + } + + private class ThrowingUserActivityDetector : IUserActivityDetector + { + public DateTime LastActivityTime => throw new InvalidOperationException("Simulated error"); + } + + private class FakeOptions : Microsoft.Extensions.Options.IOptions + { + public CloudSettings Value { get; } + public FakeOptions(int cooldownSeconds) => Value = new CloudSettings { IdleCooldownSeconds = cooldownSeconds }; + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/MvvmPropertyIdleAdapterTests.cs b/test/AdaptiveRemote.App.Tests/Services/MvvmPropertyIdleAdapterTests.cs new file mode 100644 index 00000000..a4a13dfb --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/MvvmPropertyIdleAdapterTests.cs @@ -0,0 +1,127 @@ +using AdaptiveRemote.Mvvm; +using FluentAssertions; + +namespace AdaptiveRemote.Services; + +[TestClass] +public class MvvmPropertyIdleAdapterTests +{ + [TestMethod] + public void LastActivityTime_InitiallyMinValue_WhenPropertyIsFalse() + { + // Arrange + TestTarget target = new() { IsActive = false }; + TestIdleAdapter sut = new(target); + + // Act + DateTime result = sut.LastActivityTime; + + // Assert + result.Should().Be(DateTime.MinValue); + } + + [TestMethod] + public void LastActivityTime_Now_WhenPropertyIsTrue() + { + // Arrange + TestTarget target = new() { IsActive = true }; + TestIdleAdapter sut = new(target); + + // Act + DateTime result = sut.LastActivityTime; + + // Assert + result.Should().BeCloseTo(DateTime.Now, TimeSpan.FromSeconds(1)); + } + + [TestMethod] + public void LastActivityTime_Updates_WhenPropertyChangesToFalse() + { + // Arrange + TestTarget target = new() { IsActive = true }; + TestIdleAdapter sut = new(target); + DateTime before = DateTime.Now.AddSeconds(-1); + + // Act + target.IsActive = false; + DateTime result = sut.LastActivityTime; + + // Assert + result.Should().BeOnOrAfter(before); + } + + [TestMethod] + public void LastActivityTime_Resets_WhenPropertyChangesToTrue() + { + // Arrange + TestTarget target = new() { IsActive = false }; + TestIdleAdapter sut = new(target); + + // Act + target.IsActive = true; + DateTime result = sut.LastActivityTime; + + // Assert + result.Should().BeCloseTo(DateTime.Now, TimeSpan.FromSeconds(1)); + } + + [TestMethod] + public void Dispose_Unsubscribes_PropertyChanged() + { + // Arrange + TestTarget target = new() { IsActive = false }; + TestIdleAdapter sut = new(target); + + // Act + sut.Dispose(); + + // Assert + target.IsActive = true; + // Should not throw or update after dispose + sut.LastActivityTime.Should().Be(DateTime.MinValue); + } + + [TestMethod] + public void Throws_OnNullTarget() + { + // Arrange + Action act = () => _ = new TestIdleAdapter(null!); + + // Act & Assert + act.Should().Throw(); + } + + [TestMethod] + public void Throws_OnNullProperty() + { + // Arrange + TestTarget target = new(); + Action act = () => _ = new BrokenIdleAdapter(target); + + // Act & Assert + act.Should().Throw(); + } + + // Helpers + internal class TestTarget : MvvmObject + { + internal static readonly MvvmProperty IsActiveProperty = new(nameof(IsActive)); + internal bool IsActive + { + get => GetValue(IsActiveProperty); + set => SetValue(IsActiveProperty, value); + } + } + + private sealed class TestIdleAdapter : MvvmPropertyIdleAdapter + { + public TestIdleAdapter(TestTarget target) + : base(target, TestTarget.IsActiveProperty) { } + } + + private sealed class BrokenIdleAdapter : MvvmPropertyIdleAdapter + { + public BrokenIdleAdapter(TestTarget target) + : base(target, null!) { } + } +} From 56f1f4ce817ca147cd0ca823806cbbefef42d76a Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Wed, 29 Apr 2026 16:09:21 -0700 Subject: [PATCH 10/14] [ADR-181] Real CloudAssetOrchestrator with cache-first load, background refresh, and file-watch loop (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ADR-181] Real CloudAssetOrchestrator with cache-first load, background refresh, and file-watch loop Replaces the stub CloudAssetOrchestrator with a three-phase implementation: - Phase 1: cache-first parallel load — reads from {CachePath}/{name}.cache if present, otherwise downloads from server and saves to cache; signals IPreScopeInitializer once all assets are in the store (or faults on failure). - Phase 2: background server refresh — for cache-loaded assets, compares SHA-256 of server bytes against cached bytes; updates store/cache and schedules an idle-deferred scope recycle when content differs. - Phase 3: file-change loop — waits on IAssetChangeNotifier.WaitForChangeAsync; on each notification re-downloads all assets, updates cache/store, and schedules a recycle. New components: - CloudAssetCache: IFileSystem-backed implementation of ICloudAssetCache - IAssetChangeNotifier: decoupled notification contract for Phase 3 - FileSystemCloudAssetWatchService: debounced FileSystemWatcher (100 ms cancel-restart) implementing IAssetChangeNotifier as a singleton BackgroundService - Log messages 1702-1706 (LoadedFromCache, AssetUpToDate, AssetUpdated, BackgroundFetchFailed, FileChangeDetected) Phase 1 failure sets the lifecycle FatalError state via the faulted WaitAsync and returns cleanly rather than re-throwing, preventing BackgroundServiceExceptionBehavior.StopHost from killing the process before the FatalError UI state can be observed by tests. E2E test infrastructure: - primary-layout.json / updated-layout.json fixtures (Guide absent/present in WELL group) - Cloud paths configured once per test run in BeforeTestRun; @cloud-layout scenarios stop the host in AfterScenario to prevent state leakage - ApplicationTestService resolves IRemoteDefinitionService lazily so GetCurrentPhaseAsync works in FatalError state where the layout asset is absent https://claude.ai/code/session_018JQNh2RgmSxxNxzH9FM2o3 * Renames and namespace reorganization * Refactor CloudAssetOrchestrator for better code reuse and behavior consistency. - Added a few more logging messages - Changed the output of ICloudAssetChangeNotifier to be an ICloudAsset - Fixed unit tests to expect the new, consistent behavior --------- Co-authored-by: Claude Co-authored-by: Joe Davis --- scripts/validate-tests.cmd | 2 +- scripts/validate-tests.sh | 2 +- .../CloudAssetServiceExtensions.cs | 9 +- .../ConversationHostBuilderExtensions.cs | 2 +- .../Configuration/HostBuilderExtensions.cs | 26 +- .../Logging/MessageLogger.cs | 36 +- .../CloudAssets/BasicCloudAsset.cs | 2 +- .../CloudAssets/ICloudAsset.cs | 2 +- .../CloudAssets/JsonCloudAsset.cs | 2 +- src/AdaptiveRemote.App/Models/Phrases.cs | 1 + .../MvvmPropertyActivityDetector.cs} | 8 +- .../Services/CloudAssets/CloudAssetCache.cs | 44 ++ .../CloudAssets/CloudAssetOrchestrator.cs | 247 +++++++- ...r.cs => FileSystemCloudAssetDownloader.cs} | 4 +- .../FileSystemCloudAssetWatchService.cs | 86 +++ .../CloudAssets/ICloudAssetChangeNotifier.cs | 11 + .../Services/CloudAssets/IdleDetector.cs | 38 -- .../Services/CloudAssets/_doc_CloudAssets.md | 60 ++ ...eAdapter.cs => CommandActivityDetector.cs} | 5 +- ...Adapter.cs => CommandsActivityDetector.cs} | 6 +- ...ter.cs => ConversationActivityDetector.cs} | 5 +- .../Services/IdleDetection/IdleDetector.cs | 70 +++ .../ProgrammingModeActivityDetector.cs | 12 + .../Lifecycle/ProgrammingModeIdleAdapter.cs | 11 - .../CloudAssets/CloudAssetCacheTests.cs | 128 +++++ .../CloudAssetOrchestratorTests.cs | 543 +++++++++++++++++- .../FileCloudAssetDownloaderTests.cs | 8 +- .../FileSystemCloudAssetWatchServiceTests.cs | 123 ++++ .../Services/CloudAssets/IdleDetectorTests.cs | 74 ++- .../CloudAssets/JsonCloudAssetTests.cs | 1 + .../Services/MvvmPropertyIdleAdapterTests.cs | 4 +- .../TestUtilities/MockLogger.cs | 6 + .../Features/Shared/CloudLayoutUpdate.feature | 118 ++++ .../Shared/ConversationModalUI.feature | 1 + .../Features/Shared/LayoutButtons.feature | 1 - .../CloudLayoutSteps.cs | 110 ++++ .../Hooks/EnvironmentSetupHooks.cs | 17 +- .../LogVerificationSteps.cs | 60 ++ ...veRemote.EndtoEndTests.TestServices.csproj | 6 + .../ApplicationTestService.cs | 16 +- .../Host/ISimulatedEnvironment.cs | 16 + .../Host/SimulatedEnvironment.cs | 29 + .../IApplicationTestServiceExtensions.cs | 2 +- .../Layout/primary-layout.json | 57 ++ .../Layout/updated-layout.json | 58 ++ 45 files changed, 1946 insertions(+), 123 deletions(-) rename src/AdaptiveRemote.App/{Services => Models}/CloudAssets/BasicCloudAsset.cs (91%) rename src/AdaptiveRemote.App/{Services => Models}/CloudAssets/ICloudAsset.cs (95%) rename src/AdaptiveRemote.App/{Services => Models}/CloudAssets/JsonCloudAsset.cs (92%) rename src/AdaptiveRemote.App/{Services/MvvmPropertyIdleAdapter.cs => Mvvm/MvvmPropertyActivityDetector.cs} (84%) create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetCache.cs rename src/AdaptiveRemote.App/Services/CloudAssets/{FileCloudAssetDownloader.cs => FileSystemCloudAssetDownloader.cs} (81%) create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetWatchService.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetChangeNotifier.cs delete mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/IdleDetector.cs create mode 100644 src/AdaptiveRemote.App/Services/CloudAssets/_doc_CloudAssets.md rename src/AdaptiveRemote.App/Services/Commands/{CommandIdleAdapter.cs => CommandActivityDetector.cs} (58%) rename src/AdaptiveRemote.App/Services/Commands/{CommandExecutionIdleAdapter.cs => CommandsActivityDetector.cs} (60%) rename src/AdaptiveRemote.App/Services/Conversation/{ConversationIdleAdapter.cs => ConversationActivityDetector.cs} (51%) create mode 100644 src/AdaptiveRemote.App/Services/IdleDetection/IdleDetector.cs create mode 100644 src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeActivityDetector.cs delete mode 100644 src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeIdleAdapter.cs create mode 100644 test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetCacheTests.cs create mode 100644 test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileSystemCloudAssetWatchServiceTests.cs create mode 100644 test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/CloudLayoutUpdate.feature create mode 100644 test/AdaptiveRemote.EndToEndTests.Steps/CloudLayoutSteps.cs create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/primary-layout.json create mode 100644 test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/updated-layout.json 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" +} From 2476d595725c2ec7c77a28037c17e9bc767279b1 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Fri, 1 May 2026 21:11:32 -0700 Subject: [PATCH 11/14] [ADR-182] CSS from the CompiledLayout --- .claude/commands/team-task.md | 87 ++++++-- scripts/validate-tests.sh | 0 .../AdaptiveRemote.App.csproj | 4 - .../Layout/LayoutStylesheetProvider.cs | 17 +- .../Services/Layout/_doc_LayoutConsumption.md | 113 +++++++++++ .../Services/Layout/layout-grid.css | 190 ------------------ src/AdaptiveRemote.App/wwwroot/css/app.css | 18 ++ src/AdaptiveRemote.App/wwwroot/css/app.less | 20 ++ .../wwwroot/css/app.min.css | 2 +- .../Layout/LayoutStylesheetProviderTests.cs | 12 +- .../Layout/primary-layout.json | 2 +- .../Layout/updated-layout.json | 2 +- 12 files changed, 242 insertions(+), 225 deletions(-) mode change 100644 => 100755 scripts/validate-tests.sh create mode 100644 src/AdaptiveRemote.App/Services/Layout/_doc_LayoutConsumption.md delete mode 100644 src/AdaptiveRemote.App/Services/Layout/layout-grid.css diff --git a/.claude/commands/team-task.md b/.claude/commands/team-task.md index 198d6041..9ddb809a 100644 --- a/.claude/commands/team-task.md +++ b/.claude/commands/team-task.md @@ -20,12 +20,16 @@ this task from spec to merged PR. Keep your own context lean: - Direct sub-agents to read from and write to GitHub PR and Jira directly — do not relay large payloads through yourself -**Before starting**, capture the current working branch — this is the **base branch** -(PR target, Developer branches off this): +**Before starting**, capture the base branch — this is the PR target and the branch the +Developer will branch off. The skill may be invoked on an ephemeral harness branch, so +find the upstream remote branch at the current commit rather than using `git branch --show-current`: +```bash +# Find the remote branch this harness branch was created from +git branch -r --contains HEAD | grep -v 'HEAD\|claude/' | head -1 | sed 's|.*origin/||' | xargs echo ``` -git branch --show-current -``` + +If that returns nothing (no matching remote branch), fall back to `git branch --show-current`. --- @@ -126,6 +130,11 @@ Developer instructions: > **Task key:** TASK_KEY > **Base branch:** BASE_BRANCH > +> **Non-negotiable build rule:** After making any changes, you MUST run +> `scripts/validate-build.sh` before committing. Never run `dotnet build` directly. +> Never run `dotnet test`, `scripts/validate-tests.sh`, or any test command — a +> dedicated Tester agent handles all testing. +> > **Task brief:** > TASK_BRIEF > @@ -168,7 +177,7 @@ Developer instructions: > > **Step 4 — Build** > -> Do not run tests — that is the Tester agent's responsibility. Only run the build: +> Run the build validation script and nothing else: > > ``` > scripts/validate-build.sh @@ -177,6 +186,9 @@ Developer instructions: > The script stages new files and cleans before building — do not run `git add -A` > separately. Fix all warnings and errors and re-run until the build is clean. > +> **NEVER run `dotnet test`, `scripts/validate-tests.sh`, or any other test command.** +> A dedicated Tester agent handles all testing. +> > **Step 5 — Commit and push** > > Commit format: `feat: description [TASK_KEY]` @@ -250,8 +262,8 @@ Tester instructions: > > Check out the branch and fix each failure. You may re-run individual tests to > verify a specific fix (e.g. `dotnet test --filter "FullyQualifiedName~TEST_NAME"`), -> but do not run the full suite — that is the Tester agent's job. When done, confirm -> the build is still clean, then commit and push: +> but **NEVER run `scripts/validate-tests.sh` or the full test suite** — that is the +> Tester agent's job. When done, confirm the build is still clean, then commit and push: > > ``` > git checkout BRANCH_NAME @@ -338,12 +350,32 @@ Reviewer instructions: > headless E2E coverage > - `.editorconfig` compliance > -> For each issue found, post a comment **directly to the PR**: -> ``` -> gh pr review PR_URL --comment -b "path/to/File.cs:LINE — your comment" +> Collect all issues into a JSON array (schema below). If the array is non-empty, +> post them as a **single formal PR review** with line-anchored comments so each +> becomes a resolvable discussion thread: +> +> ```bash +> # Extract PR number from PR_URL (e.g. .../pull/171 → 171) +> PR_NUMBER= +> COMMIT_SHA=$(git rev-parse HEAD) +> +> # Write review payload to a temp file +> cat > /tmp/review.json << EOF +> { +> "commit_id": "$COMMIT_SHA", +> "event": "COMMENT", +> "body": "Automated review — see inline comments.", +> "comments": [ +> { "path": "relative/path/to/File.cs", "line": 42, "body": "your comment" } +> ] +> } +> EOF +> +> gh api repos/jodavis/adaptiveremote/pulls/$PR_NUMBER/reviews \ +> -X POST --input /tmp/review.json > ``` > -> After posting all comments, return a JSON array. Return only the JSON — no other text. +> Return a JSON array. Return only the JSON — no other text. > An empty array means no issues. > > ```json @@ -374,6 +406,11 @@ Developer instructions: > **Branch:** BRANCH_NAME > **PR URL:** PR_URL > +> **Non-negotiable build rule:** After making any changes, you MUST run +> `scripts/validate-build.sh` before committing. Never run `dotnet build` directly. +> Never run `dotnet test`, `scripts/validate-tests.sh`, or any test command — a +> dedicated Tester agent handles all testing. +> > **Task brief:** > TASK_BRIEF > @@ -395,13 +432,16 @@ Developer instructions: > gh pr review PR_URL --comment -b "File.cs:LINE — [your rebuttal]" > ``` > -> **Step 4** — Build, commit, and push: +> **Step 4** — Build, commit, and push. Run only the build script — no test commands: > ``` > scripts/validate-build.sh > git commit -m "review: address feedback [TASK_KEY]" > git push > ``` > +> **NEVER run `dotnet test`, `scripts/validate-tests.sh`, or any other test command.** +> A dedicated Tester agent handles all testing. +> > Return: `{ "status": "done", "branch": "BRANCH_NAME" }` After Developer finishes, update `REVIEWER_BASELINE` to the current HEAD commit: @@ -437,12 +477,27 @@ Then spawn **Tester and scoped Reviewer in parallel** and wait for both. > accessibility regressions, or clear spec non-compliance. Do not raise style, naming, > or minor cleanup issues. > -> For each issue, post a comment directly to the PR: -> ``` -> gh pr review PR_URL --comment -b "path/to/File.cs:LINE — your comment" +> Collect all issues into a JSON array (same schema as Phase 5). If non-empty, +> post them as a single formal PR review with line-anchored comments: +> +> ```bash +> PR_NUMBER= +> COMMIT_SHA=$(git rev-parse HEAD) +> cat > /tmp/review.json << EOF +> { +> "commit_id": "$COMMIT_SHA", +> "event": "COMMENT", +> "body": "Follow-up review — see inline comments.", +> "comments": [ +> { "path": "relative/path/to/File.cs", "line": 42, "body": "your comment" } +> ] +> } +> EOF +> gh api repos/jodavis/adaptiveremote/pulls/$PR_NUMBER/reviews \ +> -X POST --input /tmp/review.json > ``` > -> Return a JSON array (same schema as before). Return only the JSON — no other text. +> Return a JSON array. Return only the JSON — no other text. > An empty array means all previous comments are resolved and no new significant issues exist. **Routing after both complete:** diff --git a/scripts/validate-tests.sh b/scripts/validate-tests.sh old mode 100644 new mode 100755 diff --git a/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj b/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj index f5ea8c04..e104217b 100644 --- a/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj +++ b/src/AdaptiveRemote.App/AdaptiveRemote.App.csproj @@ -31,10 +31,6 @@ - - - - PreserveNewest diff --git a/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs b/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs index 6571394b..a4eb3761 100644 --- a/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs +++ b/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs @@ -1,19 +1,18 @@ -using System.Reflection; +using AdaptiveRemote.Contracts; namespace AdaptiveRemote.Services.Layout; internal sealed class LayoutStylesheetProvider : IDynamicStylesheetProvider { - private static readonly string _css = LoadCss(); + private readonly CompiledLayout _layout; - public string? GetCss() => _css; + public LayoutStylesheetProvider(CompiledLayout layout) + { + _layout = layout; + } - private static string LoadCss() + public string? GetCss() { - Assembly assembly = typeof(LayoutStylesheetProvider).Assembly; - using Stream stream = assembly.GetManifestResourceStream( - "AdaptiveRemote.Services.Layout.layout-grid.css")!; - using StreamReader reader = new(stream); - return reader.ReadToEnd(); + return _layout.CssDefinitions; } } diff --git a/src/AdaptiveRemote.App/Services/Layout/_doc_LayoutConsumption.md b/src/AdaptiveRemote.App/Services/Layout/_doc_LayoutConsumption.md new file mode 100644 index 00000000..44a8a0e0 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Layout/_doc_LayoutConsumption.md @@ -0,0 +1,113 @@ +# Client-Side Layout Consumption + +## Overview + +AdaptiveRemote downloads its compiled remote control layout from the backend +`CompiledLayoutService`, caches it locally, and applies it at startup. When the backend +publishes a new layout, the client downloads it and automatically applies it the next time +the user is idle. The downloaded `CompiledLayout` (defined in `AdaptiveRemote.Contracts`) +is mapped to the runtime `Command` and `LayoutGroup` types used by all other subsystems. +CSS from the compiled layout is injected into the Blazor page as a `