From 407c6ff3c7996c055dd11e8d17b7ebe3da602dff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 03:25:53 +0000 Subject: [PATCH 1/7] feat: implement LayoutCompilerService with CSS grid compilation and HTTP client [ADR-171] Implements the LayoutCompilerService as a stateless ASP.NET Core service providing POST /compile and POST /compile/preview endpoints. The service compiles RawLayout definitions into CSS Grid-based CompiledLayouts (stripping authoring properties) and generates preview HTML/CSS for the editor. - New project AdaptiveRemote.Backend.LayoutCompilerService with PublishAot=true - LayoutCompilationEngine: pure grid-to-CSS transformation and payload stripping - CompileEndpoints: POST /compile and POST /compile/preview using LayoutContractsJsonContext - HttpLayoutCompilerClient in LayoutProcessingService replaces StubLayoutCompilerClient - LayoutCompilerServiceSettings config class and appsettings wiring - Structured logging via [LoggerMessage] methods (IDs 1180-1184) in backend MessageLogger - Unit tests in AdaptiveRemote.Backend.LayoutCompilerService.Tests covering CSS generation, payload stripping, group hierarchy, and preview rendering - docker-compose.yml adds layoutcompilerservice container; LayoutProcessingService depends on it - backend.slnf and AdaptiveRemote.sln updated to include new projects https://claude.ai/code/session_01XXBBBKTbQw5WmSegFiwtsz --- AdaptiveRemote.sln | 30 ++ backend.slnf | 2 + docker-compose.yml | 14 + .../Logging/MessageLogger.cs | 23 +- ...emote.Backend.LayoutCompilerService.csproj | 16 + .../Dockerfile | 26 ++ .../Endpoints/CompileEndpoints.cs | 111 ++++++ .../GlobalAttributes.cs | 3 + .../LayoutCompilationEngine.cs | 216 +++++++++++ .../Program.cs | 40 ++ .../Properties/launchSettings.json | 32 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../LayoutCompilerServiceSettings.cs | 13 + .../Program.cs | 16 +- .../Services/HttpLayoutCompilerClient.cs | 51 +++ .../appsettings.Development.json | 3 + .../appsettings.json | 3 + ...Backend.LayoutCompilerService.Tests.csproj | 29 ++ .../Engine/LayoutCompilationEngineTests.cs | 367 ++++++++++++++++++ 20 files changed, 1009 insertions(+), 3 deletions(-) create mode 100644 src/AdaptiveRemote.Backend.LayoutCompilerService/AdaptiveRemote.Backend.LayoutCompilerService.csproj create mode 100644 src/AdaptiveRemote.Backend.LayoutCompilerService/Dockerfile create mode 100644 src/AdaptiveRemote.Backend.LayoutCompilerService/Endpoints/CompileEndpoints.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutCompilerService/GlobalAttributes.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutCompilerService/LayoutCompilationEngine.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutCompilerService/Program.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutCompilerService/Properties/launchSettings.json create mode 100644 src/AdaptiveRemote.Backend.LayoutCompilerService/appsettings.Development.json create mode 100644 src/AdaptiveRemote.Backend.LayoutCompilerService/appsettings.json create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/LayoutCompilerServiceSettings.cs create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpLayoutCompilerClient.cs create mode 100644 test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/AdaptiveRemote.Backend.LayoutCompilerService.Tests.csproj create mode 100644 test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/Engine/LayoutCompilationEngineTests.cs diff --git a/AdaptiveRemote.sln b/AdaptiveRemote.sln index 452270af..89a04969 100644 --- a/AdaptiveRemote.sln +++ b/AdaptiveRemote.sln @@ -68,6 +68,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.Layo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.Common", "src\AdaptiveRemote.Backend.Common\AdaptiveRemote.Backend.Common.csproj", "{1F36A31B-299C-480C-B974-F4CE67C6F034}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.LayoutCompilerService", "src\AdaptiveRemote.Backend.LayoutCompilerService\AdaptiveRemote.Backend.LayoutCompilerService.csproj", "{42CD5829-E4EC-4135-9575-536F0074D21E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdaptiveRemote.Backend.LayoutCompilerService.Tests", "test\AdaptiveRemote.Backend.LayoutCompilerService.Tests\AdaptiveRemote.Backend.LayoutCompilerService.Tests.csproj", "{D8D4D86A-7882-4DA3-B35A-0357801D9D4C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -318,6 +322,30 @@ Global {1F36A31B-299C-480C-B974-F4CE67C6F034}.Release|x64.Build.0 = Release|Any CPU {1F36A31B-299C-480C-B974-F4CE67C6F034}.Release|x86.ActiveCfg = Release|Any CPU {1F36A31B-299C-480C-B974-F4CE67C6F034}.Release|x86.Build.0 = Release|Any CPU + {42CD5829-E4EC-4135-9575-536F0074D21E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42CD5829-E4EC-4135-9575-536F0074D21E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42CD5829-E4EC-4135-9575-536F0074D21E}.Debug|x64.ActiveCfg = Debug|Any CPU + {42CD5829-E4EC-4135-9575-536F0074D21E}.Debug|x64.Build.0 = Debug|Any CPU + {42CD5829-E4EC-4135-9575-536F0074D21E}.Debug|x86.ActiveCfg = Debug|Any CPU + {42CD5829-E4EC-4135-9575-536F0074D21E}.Debug|x86.Build.0 = Debug|Any CPU + {42CD5829-E4EC-4135-9575-536F0074D21E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42CD5829-E4EC-4135-9575-536F0074D21E}.Release|Any CPU.Build.0 = Release|Any CPU + {42CD5829-E4EC-4135-9575-536F0074D21E}.Release|x64.ActiveCfg = Release|Any CPU + {42CD5829-E4EC-4135-9575-536F0074D21E}.Release|x64.Build.0 = Release|Any CPU + {42CD5829-E4EC-4135-9575-536F0074D21E}.Release|x86.ActiveCfg = Release|Any CPU + {42CD5829-E4EC-4135-9575-536F0074D21E}.Release|x86.Build.0 = Release|Any CPU + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C}.Debug|x64.ActiveCfg = Debug|Any CPU + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C}.Debug|x64.Build.0 = Debug|Any CPU + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C}.Debug|x86.ActiveCfg = Debug|Any CPU + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C}.Debug|x86.Build.0 = Debug|Any CPU + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C}.Release|Any CPU.Build.0 = Release|Any CPU + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C}.Release|x64.ActiveCfg = Release|Any CPU + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C}.Release|x64.Build.0 = Release|Any CPU + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C}.Release|x86.ActiveCfg = Release|Any CPU + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -340,6 +368,8 @@ Global {F341B9BA-8517-447F-93B3-7E09AAD0F0E1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {A829A88B-C42D-4D3B-8CDE-621862E4B39C} = {0C88DD14-F956-CE84-757C-A364CCF449FC} {1F36A31B-299C-480C-B974-F4CE67C6F034} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {42CD5829-E4EC-4135-9575-536F0074D21E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {D8D4D86A-7882-4DA3-B35A-0357801D9D4C} = {0C88DD14-F956-CE84-757C-A364CCF449FC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {556A11E4-2F89-4600-9831-8162F067EC3E} diff --git a/backend.slnf b/backend.slnf index 57da75f3..069fdaec 100644 --- a/backend.slnf +++ b/backend.slnf @@ -6,9 +6,11 @@ "src\\AdaptiveRemote.Backend.CompiledLayoutService\\AdaptiveRemote.Backend.CompiledLayoutService.csproj", "src\\AdaptiveRemote.Backend.RawLayoutService\\AdaptiveRemote.Backend.RawLayoutService.csproj", "src\\AdaptiveRemote.Backend.LayoutProcessingService\\AdaptiveRemote.Backend.LayoutProcessingService.csproj", + "src\\AdaptiveRemote.Backend.LayoutCompilerService\\AdaptiveRemote.Backend.LayoutCompilerService.csproj", "test\\AdaptiveRemote.Backend.ApiTests\\AdaptiveRemote.Backend.ApiTests.csproj", "test\\AdaptiveRemote.Backend.RawLayoutService.Tests\\AdaptiveRemote.Backend.RawLayoutService.Tests.csproj", "test\\AdaptiveRemote.Backend.LayoutProcessingService.Tests\\AdaptiveRemote.Backend.LayoutProcessingService.Tests.csproj", + "test\\AdaptiveRemote.Backend.LayoutCompilerService.Tests\\AdaptiveRemote.Backend.LayoutCompilerService.Tests.csproj", "test\\AdaptiveRemote.TestUtilities\\AdaptiveRemote.TestUtilities.csproj" ] } diff --git a/docker-compose.yml b/docker-compose.yml index 257ee7a2..d181a036 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,6 +62,18 @@ services: networks: - backend + layoutcompilerservice: + build: + context: . + dockerfile: src/AdaptiveRemote.Backend.LayoutCompilerService/Dockerfile + ports: + - "8083:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:8080 + networks: + - backend + layoutprocessingservice: build: context: . @@ -81,6 +93,7 @@ services: # Upstream service URLs (Docker Compose DNS) - RawLayoutService__BaseUrl=http://rawlayoutservice:8080 - CompiledLayoutService__BaseUrl=http://compiledlayoutservice:8080 + - LayoutCompilerService__BaseUrl=http://layoutcompilerservice:8080 - AWS_ACCESS_KEY_ID=test - AWS_SECRET_ACCESS_KEY=test - LocalStack__BaseUrl=http://localstack:4566 @@ -88,6 +101,7 @@ services: - localstack - rawlayoutservice - compiledlayoutservice + - layoutcompilerservice networks: - backend diff --git a/src/AdaptiveRemote.Backend.Common/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.Common/Logging/MessageLogger.cs index a7e0ed58..8f7097ad 100644 --- a/src/AdaptiveRemote.Backend.Common/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.Backend.Common/Logging/MessageLogger.cs @@ -3,10 +3,13 @@ namespace AdaptiveRemote.Backend.Common.Logging; /// -/// Centralized logging messages for CompiledLayoutService. +/// Centralized logging messages for backend services. /// All log messages MUST be defined here as [LoggerMessage] source-generated methods. /// Event ID ranges: -/// 1100-1199: CompiledLayoutService +/// 1100-1199: shared backend messages (1100-1107: common; 1180-1199: LayoutCompilerService) +/// 1200-1299: RawLayoutService +/// 1300-1399: CompiledLayoutService +/// 1700-1799: LayoutProcessingService /// public static partial class MessageLogger { @@ -38,6 +41,22 @@ public static partial class MessageLogger Message = "LocalStack dependency check failed at {HealthUrl}: {FailureReason}. LocalStack is required for local development. See docs/local-dev.md for setup instructions")] public static partial void LocalStackDependencyUnavailable(this ILogger logger, string healthUrl, string failureReason, Exception? exception); + // LayoutCompilerService-specific messages + [LoggerMessage(EventId = 1180, Level = LogLevel.Information, Message = "Compilation started; rawLayoutId={RawLayoutId} elementCount={ElementCount}")] + public static partial void CompilationStarted(this ILogger logger, Guid rawLayoutId, int elementCount); + + [LoggerMessage(EventId = 1181, Level = LogLevel.Information, Message = "Compilation succeeded; rawLayoutId={RawLayoutId} compiledElementCount={CompiledElementCount}")] + public static partial void CompilationSucceeded(this ILogger logger, Guid rawLayoutId, int compiledElementCount); + + [LoggerMessage(EventId = 1182, Level = LogLevel.Information, Message = "Preview compilation started; elementCount={ElementCount}")] + public static partial void PreviewCompilationStarted(this ILogger logger, int elementCount); + + [LoggerMessage(EventId = 1183, Level = LogLevel.Information, Message = "Preview compilation succeeded; elementCount={ElementCount}")] + public static partial void PreviewCompilationSucceeded(this ILogger logger, int elementCount); + + [LoggerMessage(EventId = 1184, Level = LogLevel.Error, Message = "Compilation failed; rawLayoutId={RawLayoutId} reason={Reason}")] + public static partial void CompilationFailed(this ILogger logger, Guid rawLayoutId, string reason, Exception? exception); + // CompiledLayoutService-specific messages [LoggerMessage(EventId = 1301, Level = LogLevel.Information, Message = "Returning active compiled layout Id={LayoutId}")] public static partial void ReturningActiveLayout(this ILogger logger, Guid layoutId); diff --git a/src/AdaptiveRemote.Backend.LayoutCompilerService/AdaptiveRemote.Backend.LayoutCompilerService.csproj b/src/AdaptiveRemote.Backend.LayoutCompilerService/AdaptiveRemote.Backend.LayoutCompilerService.csproj new file mode 100644 index 00000000..0047a8e0 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/AdaptiveRemote.Backend.LayoutCompilerService.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + enable + enable + AdaptiveRemote.Backend.LayoutCompilerService + true + + + + + + + + diff --git a/src/AdaptiveRemote.Backend.LayoutCompilerService/Dockerfile b/src/AdaptiveRemote.Backend.LayoutCompilerService/Dockerfile new file mode 100644 index 00000000..03f38e2d --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/Dockerfile @@ -0,0 +1,26 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy csproj files and restore +COPY ["src/AdaptiveRemote.Contracts/AdaptiveRemote.Contracts.csproj", "AdaptiveRemote.Contracts/"] +COPY ["src/AdaptiveRemote.Backend.Common/AdaptiveRemote.Backend.Common.csproj", "AdaptiveRemote.Backend.Common/"] +COPY ["src/AdaptiveRemote.Backend.LayoutCompilerService/AdaptiveRemote.Backend.LayoutCompilerService.csproj", "AdaptiveRemote.Backend.LayoutCompilerService/"] +COPY ["Directory.Build.props", "./"] +COPY ["Directory.Packages.props", "./"] +RUN dotnet restore "AdaptiveRemote.Backend.LayoutCompilerService/AdaptiveRemote.Backend.LayoutCompilerService.csproj" + +# Copy source and build +COPY ["src/AdaptiveRemote.Contracts/", "AdaptiveRemote.Contracts/"] +COPY ["src/AdaptiveRemote.Backend.Common/", "AdaptiveRemote.Backend.Common/"] +COPY ["src/AdaptiveRemote.Backend.LayoutCompilerService/", "AdaptiveRemote.Backend.LayoutCompilerService/"] +WORKDIR "/src/AdaptiveRemote.Backend.LayoutCompilerService" +RUN dotnet build "AdaptiveRemote.Backend.LayoutCompilerService.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "AdaptiveRemote.Backend.LayoutCompilerService.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +COPY --from=publish /app/publish . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "AdaptiveRemote.Backend.LayoutCompilerService.dll"] diff --git a/src/AdaptiveRemote.Backend.LayoutCompilerService/Endpoints/CompileEndpoints.cs b/src/AdaptiveRemote.Backend.LayoutCompilerService/Endpoints/CompileEndpoints.cs new file mode 100644 index 00000000..c383c78a --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/Endpoints/CompileEndpoints.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using AdaptiveRemote.Backend.Common.Logging; +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.LayoutCompilerService.Endpoints; + +public static class CompileEndpoints +{ + public static void MapCompileEndpoints(this IEndpointRouteBuilder app) + { + app.MapPost("/compile", CompileLayout) + .WithName(nameof(CompileLayout)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest); + + app.MapPost("/compile/preview", CompilePreview) + .WithName(nameof(CompilePreview)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest); + } + + private static async Task CompileLayout( + HttpContext httpContext, + ILogger logger, + CancellationToken cancellationToken) + { + using IDisposable _ = logger.StartRequestScope("POST", "/compile"); + + RawLayout? raw; + try + { + raw = await JsonSerializer + .DeserializeAsync(httpContext.Request.Body, LayoutContractsJsonContext.Default.RawLayout, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + logger.CompilationFailed(Guid.Empty, $"Failed to deserialize request body: {ex.Message}", ex); + return Results.BadRequest("Invalid request body: could not deserialize RawLayout."); + } + + if (raw is null) + { + logger.CompilationFailed(Guid.Empty, "Request body deserialized to null", exception: null); + return Results.BadRequest("Invalid request body: RawLayout was null."); + } + + logger.CompilationStarted(raw.Id, raw.Elements.Count); + + CompiledLayout compiled; + try + { + compiled = LayoutCompilationEngine.Compile(raw); + } + catch (Exception ex) + { + logger.CompilationFailed(raw.Id, ex.Message, ex); + return Results.BadRequest($"Compilation failed: {ex.Message}"); + } + + logger.CompilationSucceeded(raw.Id, compiled.Elements.Count); + + string responseJson = JsonSerializer.Serialize(compiled, LayoutContractsJsonContext.Default.CompiledLayout); + return Results.Content(responseJson, "application/json"); + } + + private static async Task CompilePreview( + HttpContext httpContext, + ILogger logger, + CancellationToken cancellationToken) + { + using IDisposable _ = logger.StartRequestScope("POST", "/compile/preview"); + + IReadOnlyList? elements; + try + { + elements = await JsonSerializer + .DeserializeAsync(httpContext.Request.Body, LayoutContractsJsonContext.Default.IReadOnlyListRawLayoutElementDto, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + logger.CompilationFailed(Guid.Empty, $"Failed to deserialize preview request body: {ex.Message}", ex); + return Results.BadRequest("Invalid request body: could not deserialize element list."); + } + + if (elements is null) + { + logger.CompilationFailed(Guid.Empty, "Preview request body deserialized to null", exception: null); + return Results.BadRequest("Invalid request body: element list was null."); + } + + logger.PreviewCompilationStarted(elements.Count); + + PreviewLayout preview; + try + { + preview = LayoutCompilationEngine.CompilePreview(elements); + } + catch (Exception ex) + { + logger.CompilationFailed(Guid.Empty, $"Preview compilation failed: {ex.Message}", ex); + return Results.BadRequest($"Preview compilation failed: {ex.Message}"); + } + + logger.PreviewCompilationSucceeded(elements.Count); + + string responseJson = JsonSerializer.Serialize(preview, LayoutContractsJsonContext.Default.PreviewLayout); + return Results.Content(responseJson, "application/json"); + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutCompilerService/GlobalAttributes.cs b/src/AdaptiveRemote.Backend.LayoutCompilerService/GlobalAttributes.cs new file mode 100644 index 00000000..38329ceb --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/GlobalAttributes.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AdaptiveRemote.Backend.LayoutCompilerService.Tests")] diff --git a/src/AdaptiveRemote.Backend.LayoutCompilerService/LayoutCompilationEngine.cs b/src/AdaptiveRemote.Backend.LayoutCompilerService/LayoutCompilationEngine.cs new file mode 100644 index 00000000..f1f9b65e --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/LayoutCompilationEngine.cs @@ -0,0 +1,216 @@ +using System.Collections.ObjectModel; +using System.Text; +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.LayoutCompilerService; + +/// +/// Pure, stateless compilation logic: raw layout elements → compiled layout + CSS definitions. +/// +/// CSS Grid approach: +/// - The grid container uses a class selector (.layout-grid) and defines its column/row +/// template based on the maximum observed column/row extents in the element set. +/// - Each element is targeted by its CssId: #cssId { grid-row: R / span S; grid-column: C / span S; } +/// - Per-element AdditionalCss (if any) is appended inline under the same selector block. +/// +/// Compiled output strips all authoring properties (grid positions, AdditionalCss); only +/// behavioral properties (Type, Name, Label, Glyph, SpeakPhrase, Reverse, CssId) are kept. +/// +public static class LayoutCompilationEngine +{ + /// + /// Compiles a into a . + /// Version is inherited from ; Id is newly generated. + /// + public static CompiledLayout Compile(RawLayout raw) + { + string css = BuildCssDefinitions(raw.Elements); + IReadOnlyList elements = ConvertElements(raw.Elements); + + return new CompiledLayout( + Id: Guid.NewGuid(), + RawLayoutId: raw.Id, + UserId: raw.UserId, + IsActive: false, + Version: raw.Version, + Elements: elements, + CssDefinitions: css, + CompiledAt: DateTimeOffset.UtcNow); + } + + /// + /// Compiles a flat list of for live preview. + /// Returns HTML + CSS representations without a full layout context. + /// + public static PreviewLayout CompilePreview(IReadOnlyList elements) + { + string css = BuildCssDefinitions(elements); + string html = BuildPreviewHtml(elements); + + return new PreviewLayout( + RawLayoutId: Guid.Empty, + Version: 0, + RenderedHtml: html, + RenderedCss: css, + CompiledAt: DateTimeOffset.UtcNow, + ValidationResult: new ValidationResult(true, Array.Empty())); + } + + // ── CSS generation ──────────────────────────────────────────────────────── + + internal static string BuildCssDefinitions(IReadOnlyList elements) + { + (int maxCol, int maxRow) = ComputeGridExtents(elements); + + StringBuilder sb = new(); + + // Container rule — sets up an explicit grid sized to the observed extents. + sb.AppendLine(".layout-grid {"); + sb.AppendLine(" display: grid;"); + sb.AppendLine($" grid-template-columns: repeat({maxCol}, 1fr);"); + sb.AppendLine($" grid-template-rows: repeat({maxRow}, auto);"); + sb.AppendLine("}"); + + AppendElementCssRules(sb, elements); + + return sb.ToString(); + } + + private static void AppendElementCssRules(StringBuilder sb, IReadOnlyList elements) + { + foreach (RawLayoutElementDto element in elements) + { + sb.AppendLine(); + sb.AppendLine($"#{element.CssId} {{"); + sb.AppendLine($" grid-row: {element.GridRow} / span {element.GridRowSpan};"); + sb.AppendLine($" grid-column: {element.GridColumn} / span {element.GridColumnSpan};"); + + if (!string.IsNullOrWhiteSpace(element.AdditionalCss)) + { + // Inline per-element overrides inside the same rule block. + foreach (string line in element.AdditionalCss.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + sb.AppendLine($" {line.Trim()}"); + } + } + + sb.AppendLine("}"); + + // Recurse into groups. + if (element is RawLayoutGroupDefinitionDto group) + { + AppendElementCssRules(sb, group.Children); + } + } + } + + /// + /// Returns (maxEndColumn, maxEndRow) based on the grid positions + spans of all elements + /// (recursively). Values are 1-based to match CSS Grid semantics. + /// + private static (int MaxEndColumn, int MaxEndRow) ComputeGridExtents(IReadOnlyList elements) + { + int maxCol = 1; + int maxRow = 1; + AccumulateExtents(elements, ref maxCol, ref maxRow); + return (maxCol, maxRow); + } + + private static void AccumulateExtents(IReadOnlyList elements, ref int maxCol, ref int maxRow) + { + foreach (RawLayoutElementDto e in elements) + { + int endCol = e.GridColumn + e.GridColumnSpan - 1; + int endRow = e.GridRow + e.GridRowSpan - 1; + if (endCol > maxCol) + { + maxCol = endCol; + } + + if (endRow > maxRow) + { + maxRow = endRow; + } + + if (e is RawLayoutGroupDefinitionDto group) + { + AccumulateExtents(group.Children, ref maxCol, ref maxRow); + } + } + } + + // ── Element conversion ──────────────────────────────────────────────────── + + internal static ReadOnlyCollection ConvertElements(IReadOnlyList rawElements) + { + List result = new(rawElements.Count); + + foreach (RawLayoutElementDto element in rawElements) + { + LayoutElementDto compiled = element switch + { + RawCommandDefinitionDto cmd => new CommandDefinitionDto( + Type: cmd.Type, + Name: cmd.Name, + Label: cmd.Label, + Glyph: cmd.Glyph, + SpeakPhrase: cmd.SpeakPhrase, + Reverse: cmd.Reverse, + CssId: cmd.CssId), + RawLayoutGroupDefinitionDto group => new LayoutGroupDefinitionDto( + CssId: group.CssId, + Children: ConvertElements(group.Children)), + _ => throw new InvalidOperationException($"Unknown element type: {element.GetType().Name}") + }; + + result.Add(compiled); + } + + return result.AsReadOnly(); + } + + // ── HTML preview ────────────────────────────────────────────────────────── + + private static string BuildPreviewHtml(IReadOnlyList elements) + { + StringBuilder sb = new(); + sb.AppendLine("
"); + BuildPreviewHtmlChildren(sb, elements, indent: 2); + sb.Append("
"); + return sb.ToString(); + } + + private static void BuildPreviewHtmlChildren(StringBuilder sb, IReadOnlyList elements, int indent) + { + string pad = new(' ', indent); + foreach (RawLayoutElementDto element in elements) + { + switch (element) + { + case RawCommandDefinitionDto cmd: + sb.AppendLine($"{pad}"); + break; + + case RawLayoutGroupDefinitionDto group: + sb.AppendLine($"{pad}
"); + BuildPreviewHtmlChildren(sb, group.Children, indent + 2); + sb.AppendLine($"{pad}
"); + break; + } + } + } + + /// Minimal HTML encoding — covers the characters that matter in attribute values and text content. + private static string HtmlEncode(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + return value + .Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal) + .Replace("\"", """, StringComparison.Ordinal); + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutCompilerService/Program.cs b/src/AdaptiveRemote.Backend.LayoutCompilerService/Program.cs new file mode 100644 index 00000000..8b86adeb --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/Program.cs @@ -0,0 +1,40 @@ +using AdaptiveRemote.Backend.Common.Logging; +using AdaptiveRemote.Backend.LayoutCompilerService.Endpoints; + +string? logFilePath = null; +for (int i = 0; i < args.Length - 1; i++) +{ + if (args[i] == "--logFile") + { + logFilePath = args[i + 1]; + break; + } +} + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +if (!string.IsNullOrEmpty(logFilePath)) +{ + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(); + builder.Logging.AddFile(logFilePath); +} + +WebApplication app = builder.Build(); + +ILogger logger = app.Services.GetRequiredService>(); +logger.ServiceStarting("LayoutCompilerService"); + +// Map endpoints +app.MapCompileEndpoints(); + +// Log the configured listen address; fall back to Kestrel's default. +string listenAddress = app.Configuration["ASPNETCORE_URLS"] + ?? app.Configuration["urls"] + ?? "http://localhost:5000"; +logger.ServiceStarted("LayoutCompilerService", listenAddress); + +app.Run(); + +// Make Program visible for testing +public partial class Program { } diff --git a/src/AdaptiveRemote.Backend.LayoutCompilerService/Properties/launchSettings.json b/src/AdaptiveRemote.Backend.LayoutCompilerService/Properties/launchSettings.json new file mode 100644 index 00000000..e92b79ab --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/Properties/launchSettings.json @@ -0,0 +1,32 @@ +{ + "profiles": { + "Development": { + "commandName": "Project", + "launchBrowser": false, + "outputCapture": "None", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:5180" + }, + "AdaptiveRemote.Backend.LayoutCompilerService": { + "commandName": "Project", + "launchBrowser": false, + "outputCapture": "None", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:5180" + }, + "Lambda Test Tool": { + "commandName": "Project", + "launchBrowser": false, + "outputCapture": "None", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "AWS_LAMBDA_RUNTIME_API": "localhost:5050" + }, + "applicationUrl": "http://localhost:5180" + } + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutCompilerService/appsettings.Development.json b/src/AdaptiveRemote.Backend.LayoutCompilerService/appsettings.Development.json new file mode 100644 index 00000000..34f00ef1 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutCompilerService/appsettings.json b/src/AdaptiveRemote.Backend.LayoutCompilerService/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/LayoutCompilerServiceSettings.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/LayoutCompilerServiceSettings.cs new file mode 100644 index 00000000..aa763697 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Configuration/LayoutCompilerServiceSettings.cs @@ -0,0 +1,13 @@ +namespace AdaptiveRemote.Backend.LayoutProcessingService.Configuration; + +/// +/// Configuration for HTTP communication with LayoutCompilerService. +/// Maps to the "LayoutCompilerService" section in appsettings.json. +/// +public class LayoutCompilerServiceSettings +{ + /// + /// Base URL of LayoutCompilerService, e.g. http://layoutcompilerservice:5180 + /// + public string BaseUrl { get; set; } = string.Empty; +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs index 9c662f02..859e87e0 100644 --- a/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs @@ -122,8 +122,22 @@ void ConfigureRawLayoutClient(HttpClient client) } }); +// Configure HTTP client for LayoutCompilerService +LayoutCompilerServiceSettings layoutCompilerSettings = builder.Configuration + .GetSection("LayoutCompilerService") + .Get() ?? new LayoutCompilerServiceSettings(); + +builder.Services.Configure(builder.Configuration.GetSection("LayoutCompilerService")); + +builder.Services.AddHttpClient(client => +{ + if (!string.IsNullOrEmpty(layoutCompilerSettings.BaseUrl)) + { + client.BaseAddress = new Uri(layoutCompilerSettings.BaseUrl); + } +}); + // Register stub implementations (to be replaced in later tasks) -builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpLayoutCompilerClient.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpLayoutCompilerClient.cs new file mode 100644 index 00000000..1695d265 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/HttpLayoutCompilerClient.cs @@ -0,0 +1,51 @@ +using System.Text; +using System.Text.Json; +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.LayoutProcessingService.Services; + +/// +/// HTTP client implementation of ILayoutCompilerClient. +/// Calls LayoutCompilerService over HTTP to compile raw layouts and generate previews. +/// +public class HttpLayoutCompilerClient : ILayoutCompilerClient +{ + private readonly HttpClient _httpClient; + + public HttpLayoutCompilerClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task CompileAsync(RawLayout raw, CancellationToken ct) + { + string json = JsonSerializer.Serialize(raw, LayoutContractsJsonContext.Default.RawLayout); + StringContent content = new(json, Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _httpClient + .PostAsync("/compile", content, ct) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + string responseJson = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return JsonSerializer.Deserialize(responseJson, LayoutContractsJsonContext.Default.CompiledLayout) + ?? throw new InvalidOperationException("CompileAsync returned null from LayoutCompilerService"); + } + + public async Task CompilePreviewAsync(IReadOnlyList elements, CancellationToken ct) + { + string json = JsonSerializer.Serialize(elements, LayoutContractsJsonContext.Default.IReadOnlyListRawLayoutElementDto); + StringContent content = new(json, Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await _httpClient + .PostAsync("/compile/preview", content, ct) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + string responseJson = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return JsonSerializer.Deserialize(responseJson, LayoutContractsJsonContext.Default.PreviewLayout) + ?? throw new InvalidOperationException("CompilePreviewAsync returned null from LayoutCompilerService"); + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.Development.json b/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.Development.json index bbecec60..0d01ec49 100644 --- a/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.Development.json +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.Development.json @@ -16,6 +16,9 @@ "CompiledLayoutService": { "BaseUrl": "http://localhost:8080" }, + "LayoutCompilerService": { + "BaseUrl": "http://localhost:5180" + }, "LocalStack": { "BaseUrl": "http://localhost:4566" } diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.json b/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.json index bdc19468..8a1028a1 100644 --- a/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.json +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/appsettings.json @@ -24,5 +24,8 @@ }, "CompiledLayoutService": { "BaseUrl": "" + }, + "LayoutCompilerService": { + "BaseUrl": "" } } diff --git a/test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/AdaptiveRemote.Backend.LayoutCompilerService.Tests.csproj b/test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/AdaptiveRemote.Backend.LayoutCompilerService.Tests.csproj new file mode 100644 index 00000000..23ae1e85 --- /dev/null +++ b/test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/AdaptiveRemote.Backend.LayoutCompilerService.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + true + AdaptiveRemote.Backend.LayoutCompilerService.Tests + + + + + + + + + + + + + + + + + + + + diff --git a/test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/Engine/LayoutCompilationEngineTests.cs b/test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/Engine/LayoutCompilationEngineTests.cs new file mode 100644 index 00000000..bfaac0e8 --- /dev/null +++ b/test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/Engine/LayoutCompilationEngineTests.cs @@ -0,0 +1,367 @@ +using AdaptiveRemote.Backend.LayoutCompilerService; +using AdaptiveRemote.Contracts; +using FluentAssertions; + +namespace AdaptiveRemote.Backend.LayoutCompilerService.Tests.Engine; + +/// +/// Unit tests for LayoutCompilationEngine covering: +/// - Grid-to-CSS transformation +/// - Payload stripping (authoring props removed, behavioral props preserved) +/// - Group hierarchy conversion +/// - Preview HTML/CSS generation +/// +[TestClass] +public class LayoutCompilationEngineTests +{ + // ── CSS generation ───────────────────────────────────────────────────────── + + [TestMethod] + public void LayoutCompilationEngine_BuildCssDefinitions_SingleElement_ProducesGridContainerAndElementRule() + { + // Arrange + IReadOnlyList elements = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Up", + Label: "Up", + Glyph: null, + SpeakPhrase: "up", + Reverse: "Down", + CssId: "up-btn", + GridRow: 1, + GridColumn: 1) + }; + + // Act + string css = LayoutCompilationEngine.BuildCssDefinitions(elements); + + // Assert + css.Should().Contain(".layout-grid {"); + css.Should().Contain("display: grid;"); + css.Should().Contain("grid-template-columns: repeat(1, 1fr);"); + css.Should().Contain("grid-template-rows: repeat(1, auto);"); + css.Should().Contain("#up-btn {"); + css.Should().Contain("grid-row: 1 / span 1;"); + css.Should().Contain("grid-column: 1 / span 1;"); + } + + [TestMethod] + public void LayoutCompilationEngine_BuildCssDefinitions_ElementWithSpan_ProducesCorrectSpanRule() + { + // Arrange + IReadOnlyList elements = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Wide", + Label: "Wide", + Glyph: null, + SpeakPhrase: "wide", + Reverse: null, + CssId: "wide-btn", + GridRow: 2, + GridColumn: 1, + GridRowSpan: 1, + GridColumnSpan: 3) + }; + + // Act + string css = LayoutCompilationEngine.BuildCssDefinitions(elements); + + // Assert + css.Should().Contain("grid-column: 1 / span 3;"); + css.Should().Contain("grid-template-columns: repeat(3, 1fr);"); + } + + [TestMethod] + public void LayoutCompilationEngine_BuildCssDefinitions_ElementWithAdditionalCss_MergesIntoRule() + { + // Arrange + IReadOnlyList elements = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Power", + Label: "Power", + Glyph: null, + SpeakPhrase: "power", + Reverse: null, + CssId: "power-btn", + GridRow: 1, + GridColumn: 1, + AdditionalCss: "background-color: red;\ncolor: white;") + }; + + // Act + string css = LayoutCompilationEngine.BuildCssDefinitions(elements); + + // Assert + css.Should().Contain("#power-btn {"); + css.Should().Contain("background-color: red;"); + css.Should().Contain("color: white;"); + } + + [TestMethod] + public void LayoutCompilationEngine_BuildCssDefinitions_MultipleElements_GridExtentsBasedOnMaxValues() + { + // Arrange + IReadOnlyList elements = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "A", + Label: "A", + Glyph: null, + SpeakPhrase: "a", + Reverse: null, + CssId: "btn-a", + GridRow: 1, + GridColumn: 1), + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "B", + Label: "B", + Glyph: null, + SpeakPhrase: "b", + Reverse: null, + CssId: "btn-b", + GridRow: 3, + GridColumn: 4) + }; + + // Act + string css = LayoutCompilationEngine.BuildCssDefinitions(elements); + + // Assert + css.Should().Contain("grid-template-columns: repeat(4, 1fr);"); + css.Should().Contain("grid-template-rows: repeat(3, auto);"); + } + + // ── Element conversion (payload stripping) ───────────────────────────────── + + [TestMethod] + public void LayoutCompilationEngine_ConvertElements_Command_StripsAuthoringPropertiesPreservesBehavioral() + { + // Arrange + IReadOnlyList raw = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Vol+", + Label: "Volume Up", + Glyph: "volume_up", + SpeakPhrase: "volume up", + Reverse: "Vol-", + CssId: "vol-up", + GridRow: 2, + GridColumn: 3, + GridRowSpan: 1, + GridColumnSpan: 2, + AdditionalCss: "font-size: 2rem;") + }; + + // Act + System.Collections.ObjectModel.ReadOnlyCollection compiled = LayoutCompilationEngine.ConvertElements(raw); + + // Assert + compiled.Should().HaveCount(1); + CommandDefinitionDto cmd = compiled[0].Should().BeOfType().Subject; + cmd.Type.Should().Be(CommandType.TiVo); + cmd.Name.Should().Be("Vol+"); + cmd.Label.Should().Be("Volume Up"); + cmd.Glyph.Should().Be("volume_up"); + cmd.SpeakPhrase.Should().Be("volume up"); + cmd.Reverse.Should().Be("Vol-"); + cmd.CssId.Should().Be("vol-up"); + } + + [TestMethod] + public void LayoutCompilationEngine_ConvertElements_Group_RecursivelyConvertsChildren() + { + // Arrange + IReadOnlyList raw = new[] + { + new RawLayoutGroupDefinitionDto( + CssId: "nav-group", + Children: new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Up", + Label: "Up", + Glyph: null, + SpeakPhrase: "up", + Reverse: null, + CssId: "up-btn", + GridRow: 1, + GridColumn: 1) + }, + GridRow: 1, + GridColumn: 1) + }; + + // Act + System.Collections.ObjectModel.ReadOnlyCollection compiled = LayoutCompilationEngine.ConvertElements(raw); + + // Assert + compiled.Should().HaveCount(1); + LayoutGroupDefinitionDto group = compiled[0].Should().BeOfType().Subject; + group.CssId.Should().Be("nav-group"); + group.Children.Should().HaveCount(1); + group.Children[0].Should().BeOfType(); + } + + // ── Full Compile ─────────────────────────────────────────────────────────── + + [TestMethod] + public void LayoutCompilationEngine_Compile_InheritsVersionFromRaw() + { + // Arrange + RawLayout raw = CreateTestRawLayout(version: 7); + + // Act + CompiledLayout compiled = LayoutCompilationEngine.Compile(raw); + + // Assert + compiled.Version.Should().Be(7); + compiled.RawLayoutId.Should().Be(raw.Id); + compiled.UserId.Should().Be(raw.UserId); + } + + [TestMethod] + public void LayoutCompilationEngine_Compile_GeneratesNewId() + { + // Arrange + RawLayout raw = CreateTestRawLayout(); + + // Act + CompiledLayout compiled = LayoutCompilationEngine.Compile(raw); + + // Assert + compiled.Id.Should().NotBe(Guid.Empty); + compiled.Id.Should().NotBe(raw.Id); + } + + [TestMethod] + public void LayoutCompilationEngine_Compile_IsActiveDefaultsFalse() + { + // Arrange + RawLayout raw = CreateTestRawLayout(); + + // Act + CompiledLayout compiled = LayoutCompilationEngine.Compile(raw); + + // Assert + compiled.IsActive.Should().BeFalse(); + } + + // ── Preview ──────────────────────────────────────────────────────────────── + + [TestMethod] + public void LayoutCompilationEngine_CompilePreview_RenderedHtmlContainsGridContainer() + { + // Arrange + IReadOnlyList elements = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Menu", + Label: "Menu", + Glyph: null, + SpeakPhrase: "menu", + Reverse: null, + CssId: "menu-btn", + GridRow: 1, + GridColumn: 1) + }; + + // Act + PreviewLayout preview = LayoutCompilationEngine.CompilePreview(elements); + + // Assert + preview.RenderedHtml.Should().Contain("class=\"layout-grid\""); + preview.RenderedHtml.Should().Contain("id=\"menu-btn\""); + preview.RenderedHtml.Should().Contain("Menu"); + } + + [TestMethod] + public void LayoutCompilationEngine_CompilePreview_RenderedCssContainsGridDefinitions() + { + // Arrange + IReadOnlyList elements = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Back", + Label: "Back", + Glyph: null, + SpeakPhrase: "back", + Reverse: null, + CssId: "back-btn", + GridRow: 1, + GridColumn: 1) + }; + + // Act + PreviewLayout preview = LayoutCompilationEngine.CompilePreview(elements); + + // Assert + preview.RenderedCss.Should().Contain(".layout-grid"); + preview.RenderedCss.Should().Contain("#back-btn"); + } + + [TestMethod] + public void LayoutCompilationEngine_CompilePreview_ValidationResultIsValid() + { + // Arrange + IReadOnlyList elements = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "OK", + Label: "OK", + Glyph: null, + SpeakPhrase: "ok", + Reverse: null, + CssId: "ok-btn", + GridRow: 1, + GridColumn: 1) + }; + + // Act + PreviewLayout preview = LayoutCompilationEngine.CompilePreview(elements); + + // Assert + preview.ValidationResult.IsValid.Should().BeTrue(); + preview.ValidationResult.Issues.Should().BeEmpty(); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + private static RawLayout CreateTestRawLayout(int version = 1) + { + return new RawLayout( + Id: Guid.NewGuid(), + UserId: "test-user", + Name: "Test Layout", + Elements: new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Up", + Label: "Up", + Glyph: null, + SpeakPhrase: "up", + Reverse: "Down", + CssId: "up-btn", + GridRow: 1, + GridColumn: 1) + }, + Version: version, + CreatedAt: DateTimeOffset.UtcNow, + UpdatedAt: DateTimeOffset.UtcNow, + ValidationResult: null); + } +} From 6d5167043d0b25d13498229816f0b76f31814eee Mon Sep 17 00:00:00 2001 From: jodavis-claude Date: Tue, 12 May 2026 06:53:43 -0700 Subject: [PATCH 2/7] fix: guard IsNotCalledForPath expectations when paths are identical [ADR-171] Pre-existing test failure on Linux: %LocalAppData% is not a valid environment variable there, so ExpandEnvironmentVariables returns the unexpanded string, making InputSettingsPath == ResolvedSettingsPath. The second Expect_OpenRead_IsNotCalledForPath call then overwrote the first Expect_OpenRead_ForPath setup with Times.Never, causing the mock to reject the valid call the production code makes. --- .../Services/ProgrammaticSettings/PersistSettingsTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs b/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs index 98fc4ba0..e0c50c2d 100644 --- a/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs @@ -40,9 +40,12 @@ public async Task PersistSettings_Set_SavesSettingsToFileAsync() MockFileSystem.AddFile(ResolvedSettingsPath, "ExistingSetting=123"); MockFileSystem.Expect_OpenRead_ForPath(ResolvedSettingsPath); - MockFileSystem.Expect_OpenRead_IsNotCalledForPath(InputSettingsPath); MockFileSystem.Expect_OpenWrite_ForPath(ResolvedSettingsPath); - MockFileSystem.Expect_OpenWrite_IsNotCalledForPath(InputSettingsPath); + if (InputSettingsPath != ResolvedSettingsPath) + { + MockFileSystem.Expect_OpenRead_IsNotCalledForPath(InputSettingsPath); + MockFileSystem.Expect_OpenWrite_IsNotCalledForPath(InputSettingsPath); + } // Act sut.Set("NewSetting", "abc"); @@ -405,4 +408,3 @@ public async Task PersistSettings_Set_ReadsExistingIniSectionFormatAsync() } } - From 7e69b696f5ea140a7788f917314a10dd21f9edbc Mon Sep 17 00:00:00 2001 From: jodavis-claude Date: Tue, 12 May 2026 07:14:35 -0700 Subject: [PATCH 3/7] review: address all reviewer and Copilot feedback [ADR-171] - CLAUDE.md: split 1100-1199 into 1100-1179 (CompiledLayoutService) and 1180-1199 (LayoutCompilerService) - _doc_BackendDevelopment.md: add LayoutCompilerService to services table (port 5180); update pipeline step 3 to HttpLayoutCompilerClient; remove StubLayoutCompilerClient; clarify HTTP container vs Lambda - launchSettings.json: rename misleading 'Lambda Test Tool' profile to 'LayoutCompilerService (HTTP)' - LayoutCompilationEngine.cs: filter AdditionalCss lines containing { or } (CSS injection); validate CssId against [A-Za-z0-9-_]+ regex - CompileEndpoints.cs: return generic error messages from BadRequest instead of ex.Message; diagnostics stay in CompilationFailed logs - LayoutProcessingService/Program.cs: validate BaseUrl is a non-empty absolute URI at startup; fail fast with clear config error - Remove StubLayoutCompilerClient.cs (replaced by HttpLayoutCompilerClient) - Tests: add coverage for AdditionalCss brace-filtering and CssId validation (valid and invalid cases) --- CLAUDE.md | 3 +- .../Endpoints/CompileEndpoints.cs | 4 +- .../LayoutCompilationEngine.cs | 27 +++ .../Properties/launchSettings.json | 5 +- .../Program.cs | 14 +- src/_doc_BackendDevelopment.md | 10 +- .../Engine/LayoutCompilationEngineTests.cs | 183 ++++++++++++++++++ 7 files changed, 232 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5889e767..584920ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,8 @@ Event IDs are organized in ranges by subsystem: | 800–899 | TiVoConnection | | 900–999 | UdpService | | 1000–1099 | BroadlinkCommandService | -| 1100–1199 | CompiledLayoutService (backend) | +| 1100–1179 | CompiledLayoutService (backend) | +| 1180–1199 | LayoutCompilerService (backend) | | 1200–1299 | RawLayoutService (backend) | | 1300–1699 | (reserved — App subsystems: ProgrammaticSettings, ScopedBackgroundProcess, ConversationState, SamplesRecorder, TestEndpointService, CognitoTokenService) | | 1700–1799 | LayoutProcessingService (backend) | diff --git a/src/AdaptiveRemote.Backend.LayoutCompilerService/Endpoints/CompileEndpoints.cs b/src/AdaptiveRemote.Backend.LayoutCompilerService/Endpoints/CompileEndpoints.cs index c383c78a..fe25c4c4 100644 --- a/src/AdaptiveRemote.Backend.LayoutCompilerService/Endpoints/CompileEndpoints.cs +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/Endpoints/CompileEndpoints.cs @@ -55,7 +55,7 @@ private static async Task CompileLayout( catch (Exception ex) { logger.CompilationFailed(raw.Id, ex.Message, ex); - return Results.BadRequest($"Compilation failed: {ex.Message}"); + return Results.BadRequest("Compilation failed."); } logger.CompilationSucceeded(raw.Id, compiled.Elements.Count); @@ -100,7 +100,7 @@ private static async Task CompilePreview( catch (Exception ex) { logger.CompilationFailed(Guid.Empty, $"Preview compilation failed: {ex.Message}", ex); - return Results.BadRequest($"Preview compilation failed: {ex.Message}"); + return Results.BadRequest("Preview compilation failed."); } logger.PreviewCompilationSucceeded(elements.Count); diff --git a/src/AdaptiveRemote.Backend.LayoutCompilerService/LayoutCompilationEngine.cs b/src/AdaptiveRemote.Backend.LayoutCompilerService/LayoutCompilationEngine.cs index f1f9b65e..4a657bef 100644 --- a/src/AdaptiveRemote.Backend.LayoutCompilerService/LayoutCompilationEngine.cs +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/LayoutCompilationEngine.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.Text; +using System.Text.RegularExpressions; using AdaptiveRemote.Contracts; namespace AdaptiveRemote.Backend.LayoutCompilerService; @@ -76,10 +77,29 @@ internal static string BuildCssDefinitions(IReadOnlyList el return sb.ToString(); } + /// + /// Validates that contains only characters that are safe to + /// interpolate directly into a CSS ID selector (#id { ... }). + /// Allowed: ASCII letters, digits, hyphens, and underscores. + /// + private static bool IsValidCssId(string? cssId) => + !string.IsNullOrEmpty(cssId) && CssIdPattern.IsMatch(cssId); + + // Only letters, digits, hyphens, and underscores are permitted — no whitespace, + // braces, commas, or other characters that could break or escape a selector. + private static readonly Regex CssIdPattern = new(@"^[A-Za-z0-9\-_]+$", RegexOptions.Compiled); + private static void AppendElementCssRules(StringBuilder sb, IReadOnlyList elements) { foreach (RawLayoutElementDto element in elements) { + if (!IsValidCssId(element.CssId)) + { + throw new InvalidOperationException( + $"Element CssId '{element.CssId}' contains invalid characters. " + + "Only ASCII letters, digits, hyphens, and underscores are permitted."); + } + sb.AppendLine(); sb.AppendLine($"#{element.CssId} {{"); sb.AppendLine($" grid-row: {element.GridRow} / span {element.GridRowSpan};"); @@ -88,8 +108,15 @@ private static void AppendElementCssRules(StringBuilder sb, IReadOnlyList(builder.Configuration.GetSection("LayoutCompilerService")); +if (string.IsNullOrEmpty(layoutCompilerSettings.BaseUrl) || + !Uri.TryCreate(layoutCompilerSettings.BaseUrl, UriKind.Absolute, out Uri? compilerBaseUri)) +{ + throw new InvalidOperationException( + "LayoutCompilerService:BaseUrl must be set to a valid absolute URI in configuration."); +} + builder.Services.AddHttpClient(client => { - if (!string.IsNullOrEmpty(layoutCompilerSettings.BaseUrl)) - { - client.BaseAddress = new Uri(layoutCompilerSettings.BaseUrl); - } + client.BaseAddress = compilerBaseUri; }); -// Register stub implementations (to be replaced in later tasks) +// Register remaining stub implementations builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/_doc_BackendDevelopment.md b/src/_doc_BackendDevelopment.md index d76d2785..0aaf1be5 100644 --- a/src/_doc_BackendDevelopment.md +++ b/src/_doc_BackendDevelopment.md @@ -10,6 +10,7 @@ Task 5 ([ADR-187](https://jodasoft.atlassian.net/browse/ADR-187)). | `AdaptiveRemote.Backend.CompiledLayoutService` | 54433 (HTTPS) / 54434 (HTTP) | Compiled layout storage and retrieval | | `AdaptiveRemote.Backend.RawLayoutService` | 54435 (HTTPS) / 54436 (HTTP) | Raw layout CRUD; enqueues SQS trigger on save | | `AdaptiveRemote.Backend.LayoutProcessingService` | 54437 (HTTPS) / 54438 (HTTP) | SQS polling; orchestrates compile → validate → store → notify pipeline | +| `AdaptiveRemote.Backend.LayoutCompilerService` | 5180 (HTTP) | Compiles raw layouts to CSS+element DTOs; runs as a plain HTTP container in Docker Compose (Lambda deployment is aspirational) | ### LayoutProcessingService @@ -21,15 +22,18 @@ then drives: fetch raw layout → compile → validate → store compiled layout 1. Dequeue SQS message containing `rawLayoutId` 2. Fetch `RawLayout` from `RawLayoutService` via `IRawLayoutRepository` -3. Compile via `ILayoutCompilerClient` (stub: `StubLayoutCompilerClient`) +3. Compile via `HttpLayoutCompilerClient` → `LayoutCompilerService` (POST /compile) 4. Validate via `ILayoutValidationClient` (stub: `StubLayoutValidationClient`) 5a. On validation failure: write result back via `IRawLayoutStatusWriter`; delete message 5b. On success: store compiled layout via `ICompiledLayoutRepository`; publish notification via `INotificationPublisher`; delete message 5c. On error: do NOT delete message; SQS retry → DLQ (max receive count = 3; DLQ retention = 14 days) -**Stub implementations (current task):** +**Compiler client:** + +`ILayoutCompilerClient` is implemented by `HttpLayoutCompilerClient`, which calls `LayoutCompilerService` (POST /compile). The previous `StubLayoutCompilerClient` has been removed. + +**Stub implementations (remaining):** -- `StubLayoutCompilerClient` — derives a `CompiledLayout` from `RawLayout` elements; no real CSS - `StubLayoutValidationClient` — always returns `IsValid=true`; set `Validation:ForceInvalid=true` to exercise the failure path - `StubNotificationPublisher` — no-op diff --git a/test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/Engine/LayoutCompilationEngineTests.cs b/test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/Engine/LayoutCompilationEngineTests.cs index bfaac0e8..3c64e4e0 100644 --- a/test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/Engine/LayoutCompilationEngineTests.cs +++ b/test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/Engine/LayoutCompilationEngineTests.cs @@ -103,6 +103,35 @@ public void LayoutCompilationEngine_BuildCssDefinitions_ElementWithAdditionalCss css.Should().Contain("color: white;"); } + [TestMethod] + public void LayoutCompilationEngine_BuildCssDefinitions_AdditionalCssWithBraces_BraceLinesAreSkipped() + { + // Arrange — a line containing '{' or '}' would allow breaking out of the rule block. + IReadOnlyList elements = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Power", + Label: "Power", + Glyph: null, + SpeakPhrase: "power", + Reverse: null, + CssId: "power-btn", + GridRow: 1, + GridColumn: 1, + AdditionalCss: "color: red;\n} body { display:none\nfont-size: 1rem;") + }; + + // Act + string css = LayoutCompilationEngine.BuildCssDefinitions(elements); + + // Assert — safe properties are included; the injection line is dropped entirely. + css.Should().Contain("color: red;"); + css.Should().Contain("font-size: 1rem;"); + css.Should().NotContain("body"); + css.Should().NotContain("display:none"); + } + [TestMethod] public void LayoutCompilationEngine_BuildCssDefinitions_MultipleElements_GridExtentsBasedOnMaxValues() { @@ -139,6 +168,41 @@ public void LayoutCompilationEngine_BuildCssDefinitions_MultipleElements_GridExt css.Should().Contain("grid-template-rows: repeat(3, auto);"); } + [TestMethod] + public void LayoutCompilationEngine_BuildCssDefinitions_Group_GroupCssAppearsBeforeChildrenCss() + { + // Arrange + IReadOnlyList elements = new[] + { + new RawLayoutGroupDefinitionDto( + CssId: "nav-group", + Children: new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Up", + Label: "Up", + Glyph: null, + SpeakPhrase: "up", + Reverse: null, + CssId: "up-btn", + GridRow: 1, + GridColumn: 1) + }, + GridRow: 1, + GridColumn: 1) + }; + + // Act + string css = LayoutCompilationEngine.BuildCssDefinitions(elements); + + // Assert — group rule must appear before child rule in the output. + int groupRuleIndex = css.IndexOf("#nav-group {", StringComparison.Ordinal); + int childRuleIndex = css.IndexOf("#up-btn {", StringComparison.Ordinal); + groupRuleIndex.Should().BeGreaterThanOrEqualTo(0, "group rule should be present in CSS"); + childRuleIndex.Should().BeGreaterThan(groupRuleIndex, "group CSS rule must precede its children's CSS rules"); + } + // ── Element conversion (payload stripping) ───────────────────────────────── [TestMethod] @@ -338,6 +402,125 @@ public void LayoutCompilationEngine_CompilePreview_ValidationResultIsValid() preview.ValidationResult.Issues.Should().BeEmpty(); } + // ── AdditionalCss injection filtering ───────────────────────────────────── + + [TestMethod] + public void LayoutCompilationEngine_BuildCssDefinitions_AdditionalCssLineWithOpenBrace_IsSkipped() + { + // Arrange + IReadOnlyList elements = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Evil", + Label: "Evil", + Glyph: null, + SpeakPhrase: "evil", + Reverse: null, + CssId: "evil-btn", + GridRow: 1, + GridColumn: 1, + AdditionalCss: "color: red;\n.injected { color: blue; }\nfont-size: 1rem;") + }; + + // Act + string css = LayoutCompilationEngine.BuildCssDefinitions(elements); + + // Assert — the injected rule block line is dropped; surrounding safe lines survive. + css.Should().NotContain(".injected"); + css.Should().Contain("color: red;"); + css.Should().Contain("font-size: 1rem;"); + } + + [TestMethod] + public void LayoutCompilationEngine_BuildCssDefinitions_AdditionalCssLineWithCloseBrace_IsSkipped() + { + // Arrange + IReadOnlyList elements = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Evil2", + Label: "Evil2", + Glyph: null, + SpeakPhrase: "evil2", + Reverse: null, + CssId: "evil2-btn", + GridRow: 1, + GridColumn: 1, + AdditionalCss: "} .outside { color: blue;") + }; + + // Act + string css = LayoutCompilationEngine.BuildCssDefinitions(elements); + + // Assert — the escape attempt is dropped entirely. + css.Should().NotContain(".outside"); + } + + // ── CssId validation ─────────────────────────────────────────────────────── + + [TestMethod] + [DataRow("btn with space")] + [DataRow("btn{inject}")] + [DataRow("btn,other")] + [DataRow("btn}close")] + [DataRow("")] + public void LayoutCompilationEngine_BuildCssDefinitions_InvalidCssId_Throws(string invalidCssId) + { + // Arrange + IReadOnlyList elements = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Bad", + Label: "Bad", + Glyph: null, + SpeakPhrase: "bad", + Reverse: null, + CssId: invalidCssId, + GridRow: 1, + GridColumn: 1) + }; + + // Act + Action act = () => LayoutCompilationEngine.BuildCssDefinitions(elements); + + // Assert + act.Should().Throw() + .WithMessage("*CssId*invalid characters*"); + } + + [TestMethod] + [DataRow("btn-1")] + [DataRow("my_button")] + [DataRow("NavGroup")] + [DataRow("a")] + [DataRow("Z9-_x")] + public void LayoutCompilationEngine_BuildCssDefinitions_ValidCssId_DoesNotThrow(string validCssId) + { + // Arrange + IReadOnlyList elements = new[] + { + new RawCommandDefinitionDto( + Type: CommandType.TiVo, + Name: "Good", + Label: "Good", + Glyph: null, + SpeakPhrase: "good", + Reverse: null, + CssId: validCssId, + GridRow: 1, + GridColumn: 1) + }; + + // Act + Action act = () => LayoutCompilationEngine.BuildCssDefinitions(elements); + + // Assert + act.Should().NotThrow(); + } + // ── Helpers ──────────────────────────────────────────────────────────────── private static RawLayout CreateTestRawLayout(int version = 1) From c4fefbc56e4a1d5d4ba27a1d9c588f120928dfd4 Mon Sep 17 00:00:00 2001 From: jodavis-claude Date: Tue, 12 May 2026 07:14:45 -0700 Subject: [PATCH 4/7] chore: remove StubLayoutCompilerClient (replaced by HttpLayoutCompilerClient) [ADR-171] --- .../Services/StubLayoutCompilerClient.cs | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs deleted file mode 100644 index 31d2902c..00000000 --- a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Collections.ObjectModel; -using AdaptiveRemote.Contracts; - -namespace AdaptiveRemote.Backend.LayoutProcessingService.Services; - -/// -/// Stub implementation of ILayoutCompilerClient. -/// Returns a hardcoded CompiledLayout derived from the input RawLayout elements -/// (names and labels pass through; no real CSS generation). -/// To be replaced with a real Lambda-backed HTTP client in ADR-171. -/// -public class StubLayoutCompilerClient : ILayoutCompilerClient -{ - public Task CompileAsync(RawLayout raw, CancellationToken ct) - { - IReadOnlyList compiledElements = ConvertElements(raw.Elements); - - // This is a special check to simulate a validation failure - // for testing purposes - bool invalid = raw.Name == "Invalid Pipeline Test Layout"; - - CompiledLayout compiled = new( - Id: Guid.NewGuid(), - RawLayoutId: raw.Id, - UserId: raw.UserId, - IsActive: false, - Version: raw.Version, - Elements: compiledElements, - CssDefinitions: invalid ? "INVALID" : string.Empty, // Stub: no real CSS generation until ADR-171 - CompiledAt: DateTimeOffset.UtcNow - ); - - return Task.FromResult(compiled); - } - - public Task CompilePreviewAsync(IReadOnlyList elements, CancellationToken ct) - { - PreviewLayout preview = new( - RawLayoutId: Guid.Empty, - Version: 0, - RenderedHtml: "
", - RenderedCss: string.Empty, - CompiledAt: DateTimeOffset.UtcNow, - ValidationResult: new ValidationResult(true, Array.Empty()) - ); - - return Task.FromResult(preview); - } - - private static ReadOnlyCollection ConvertElements(IReadOnlyList rawElements) - { - List result = new(rawElements.Count); - - foreach (RawLayoutElementDto element in rawElements) - { - LayoutElementDto compiled = element switch - { - RawCommandDefinitionDto cmd => new CommandDefinitionDto( - Type: cmd.Type, - Name: cmd.Name, - Label: cmd.Label, - Glyph: cmd.Glyph, - SpeakPhrase: cmd.SpeakPhrase, - Reverse: cmd.Reverse, - CssId: cmd.CssId), - RawLayoutGroupDefinitionDto group => new LayoutGroupDefinitionDto( - CssId: group.CssId, - Children: ConvertElements(group.Children)), - _ => throw new InvalidOperationException($"Unknown element type: {element.GetType().Name}") - }; - - result.Add(compiled); - } - - return result.AsReadOnly(); - } -} From c6a8b15f47dc621be88e68fad9985015274b70ee Mon Sep 17 00:00:00 2001 From: jodavis-claude Date: Tue, 12 May 2026 07:17:32 -0700 Subject: [PATCH 5/7] review: remove dead CssDefinitions=="INVALID" check from StubLayoutValidationClient [ADR-171] This was a testing hook tied to the deleted StubLayoutCompilerClient. Now that HttpLayoutCompilerClient is in use, the real compiler never returns "INVALID" as CssDefinitions, making that branch unreachable. The Validation:ForceInvalid configuration path is sufficient for exercising the failure path in tests. --- .../Services/StubLayoutValidationClient.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs index 82fcb6ec..569d1f59 100644 --- a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs @@ -21,9 +21,7 @@ public StubLayoutValidationClient(IConfiguration configuration) public Task ValidateAsync(CompiledLayout compiled, CancellationToken ct) { - // This check allows tests to force an invalid result by using the - // StubLayoutCompilerClient with a special RawLayout name. - if (compiled.CssDefinitions == "INVALID") + if (_forceInvalid) { ValidationResult failure = new( IsValid: false, @@ -32,7 +30,6 @@ public Task ValidateAsync(CompiledLayout compiled, Cancellatio return Task.FromResult(failure); } - // Stub: always valid until real validation Lambda is wired in ADR-172 ValidationResult result = new( IsValid: true, Issues: Array.Empty() From 07c43d3343ab3a4aa4e972c63a1eeabb422e5523 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 22:44:22 +0000 Subject: [PATCH 6/7] fix: restore StubLayoutCompilerClient and validation chain; use custom env var in PersistSettingsTests [ADR-171] Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/56772d94-dde0-40f3-928e-d71a74d6b324 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Program.cs | 19 +++-- .../Services/StubLayoutCompilerClient.cs | 79 +++++++++++++++++++ .../Services/StubLayoutValidationClient.cs | 19 ++++- .../PersistSettingsTests.cs | 22 ++++-- 4 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs index ccc3e9a1..da8a6e38 100644 --- a/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs @@ -129,17 +129,20 @@ void ConfigureRawLayoutClient(HttpClient client) builder.Services.Configure(builder.Configuration.GetSection("LayoutCompilerService")); -if (string.IsNullOrEmpty(layoutCompilerSettings.BaseUrl) || - !Uri.TryCreate(layoutCompilerSettings.BaseUrl, UriKind.Absolute, out Uri? compilerBaseUri)) +if (!string.IsNullOrEmpty(layoutCompilerSettings.BaseUrl) && + Uri.TryCreate(layoutCompilerSettings.BaseUrl, UriKind.Absolute, out Uri? compilerBaseUri)) { - throw new InvalidOperationException( - "LayoutCompilerService:BaseUrl must be set to a valid absolute URI in configuration."); + builder.Services.AddHttpClient(client => + { + client.BaseAddress = compilerBaseUri; + }); } - -builder.Services.AddHttpClient(client => +else { - client.BaseAddress = compilerBaseUri; -}); + // No LayoutCompilerService URL configured: fall back to the stub implementation. + // This is expected in local test environments where the Lambda is not running. + builder.Services.AddSingleton(); +} // Register remaining stub implementations builder.Services.AddSingleton(); diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs new file mode 100644 index 00000000..46406fa3 --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs @@ -0,0 +1,79 @@ +using System.Collections.ObjectModel; +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Backend.LayoutProcessingService.Services; + +/// +/// Stub implementation of ILayoutCompilerClient. +/// Returns a hardcoded CompiledLayout derived from the input RawLayout elements +/// (names and labels pass through; no real CSS generation). +/// Used when LayoutCompilerService:BaseUrl is not configured (e.g. in integration test +/// environments where the real Lambda is not available). +/// To be replaced by the real Lambda-backed HttpLayoutCompilerClient in production. +/// +public class StubLayoutCompilerClient : ILayoutCompilerClient +{ + public Task CompileAsync(RawLayout raw, CancellationToken ct) + { + IReadOnlyList compiledElements = ConvertElements(raw.Elements); + + // This is a special check to simulate a validation failure + // for testing purposes + bool invalid = raw.Name == "Invalid Pipeline Test Layout"; + + CompiledLayout compiled = new( + Id: Guid.NewGuid(), + RawLayoutId: raw.Id, + UserId: raw.UserId, + IsActive: false, + Version: raw.Version, + Elements: compiledElements, + CssDefinitions: invalid ? "INVALID" : string.Empty, // Stub: no real CSS generation until ADR-171 + CompiledAt: DateTimeOffset.UtcNow + ); + + return Task.FromResult(compiled); + } + + public Task CompilePreviewAsync(IReadOnlyList elements, CancellationToken ct) + { + PreviewLayout preview = new( + RawLayoutId: Guid.Empty, + Version: 0, + RenderedHtml: "
", + RenderedCss: string.Empty, + CompiledAt: DateTimeOffset.UtcNow, + ValidationResult: new ValidationResult(true, Array.Empty()) + ); + + return Task.FromResult(preview); + } + + private static ReadOnlyCollection ConvertElements(IReadOnlyList rawElements) + { + List result = new(rawElements.Count); + + foreach (RawLayoutElementDto element in rawElements) + { + LayoutElementDto compiled = element switch + { + RawCommandDefinitionDto cmd => new CommandDefinitionDto( + Type: cmd.Type, + Name: cmd.Name, + Label: cmd.Label, + Glyph: cmd.Glyph, + SpeakPhrase: cmd.SpeakPhrase, + Reverse: cmd.Reverse, + CssId: cmd.CssId), + RawLayoutGroupDefinitionDto group => new LayoutGroupDefinitionDto( + CssId: group.CssId, + Children: ConvertElements(group.Children)), + _ => throw new InvalidOperationException($"Unknown element type: {element.GetType().Name}") + }; + + result.Add(compiled); + } + + return result.AsReadOnly(); + } +} diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs index 569d1f59..82121ace 100644 --- a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs @@ -6,9 +6,10 @@ namespace AdaptiveRemote.Backend.LayoutProcessingService.Services; /// /// Stub implementation of ILayoutValidationClient. /// Returns a valid ValidationResult for all inputs by default. -/// When Validation:ForceInvalid is set to true (e.g. in integration tests), returns an -/// invalid result with a single stub issue, enabling end-to-end testing of the failure path. -/// To be replaced with a real Lambda-backed HTTP client in ADR-172. +/// When CssDefinitions equals "INVALID" (produced by StubLayoutCompilerClient for layouts +/// with a special test name), or when Validation:ForceInvalid is set to true in configuration, +/// returns an invalid result with a single stub issue, enabling end-to-end testing of the +/// failure path. To be replaced with a real Lambda-backed HTTP client in ADR-172. /// public class StubLayoutValidationClient : ILayoutValidationClient { @@ -21,6 +22,17 @@ public StubLayoutValidationClient(IConfiguration configuration) public Task ValidateAsync(CompiledLayout compiled, CancellationToken ct) { + // This check allows tests to force an invalid result by using the + // StubLayoutCompilerClient with a special RawLayout name. + if (compiled.CssDefinitions == "INVALID") + { + ValidationResult failure = new( + IsValid: false, + Issues: [new ValidationIssue("STUB_INVALID", "Stub validation forced invalid for testing", null)] + ); + return Task.FromResult(failure); + } + if (_forceInvalid) { ValidationResult failure = new( @@ -30,6 +42,7 @@ public Task ValidateAsync(CompiledLayout compiled, Cancellatio return Task.FromResult(failure); } + // Stub: always valid until real validation Lambda is wired in ADR-172 ValidationResult result = new( IsValid: true, Issues: Array.Empty() diff --git a/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs b/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs index e0c50c2d..3228477b 100644 --- a/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs @@ -5,8 +5,19 @@ namespace AdaptiveRemote.Services.ProgrammaticSettings; [TestClass] public class PersistSettingsTests { - private static readonly string InputSettingsPath = Path.Combine("%LocalAppData%", "path", "to", "settings.ini"); - private static readonly string ResolvedSettingsPath = Environment.ExpandEnvironmentVariables(InputSettingsPath); + // Use a custom-defined environment variable so that InputSettingsPath and ResolvedSettingsPath + // are always distinct on every platform (Linux, Windows, macOS), allowing the mock to correctly + // verify that the service resolves the env var before accessing the file system. + private const string TestSettingsVarName = "ADAPTIVE_REMOTE_SETTINGS_DIR_TEST"; + + [ClassInitialize] + public static void ClassSetup(TestContext context) + { + Environment.SetEnvironmentVariable(TestSettingsVarName, Path.Combine(Path.GetTempPath(), "ar-test-settings")); + } + + private static string InputSettingsPath => Path.Combine($"%{TestSettingsVarName}%", "path", "to", "settings.ini"); + private static string ResolvedSettingsPath => Environment.ExpandEnvironmentVariables(InputSettingsPath); private readonly MockLogger MockLogger = new(); private readonly MockFileSystem MockFileSystem = new(); @@ -40,12 +51,9 @@ public async Task PersistSettings_Set_SavesSettingsToFileAsync() MockFileSystem.AddFile(ResolvedSettingsPath, "ExistingSetting=123"); MockFileSystem.Expect_OpenRead_ForPath(ResolvedSettingsPath); + MockFileSystem.Expect_OpenRead_IsNotCalledForPath(InputSettingsPath); MockFileSystem.Expect_OpenWrite_ForPath(ResolvedSettingsPath); - if (InputSettingsPath != ResolvedSettingsPath) - { - MockFileSystem.Expect_OpenRead_IsNotCalledForPath(InputSettingsPath); - MockFileSystem.Expect_OpenWrite_IsNotCalledForPath(InputSettingsPath); - } + MockFileSystem.Expect_OpenWrite_IsNotCalledForPath(InputSettingsPath); // Act sut.Set("NewSetting", "abc"); From 44d16d2b3ccbd27c390401b22daa15ddc94db364 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 22:47:12 +0000 Subject: [PATCH 7/7] fix: add ClassCleanup for env var, fix ADR reference in stub comment [ADR-171] Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/56772d94-dde0-40f3-928e-d71a74d6b324 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Services/StubLayoutCompilerClient.cs | 2 +- .../Services/ProgrammaticSettings/PersistSettingsTests.cs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs index 46406fa3..ecf8d69c 100644 --- a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs @@ -28,7 +28,7 @@ public Task CompileAsync(RawLayout raw, CancellationToken ct) IsActive: false, Version: raw.Version, Elements: compiledElements, - CssDefinitions: invalid ? "INVALID" : string.Empty, // Stub: no real CSS generation until ADR-171 + CssDefinitions: invalid ? "INVALID" : string.Empty, // Stub: no real CSS generation; see ADR-172 for the real implementation CompiledAt: DateTimeOffset.UtcNow ); diff --git a/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs b/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs index 3228477b..4c98940a 100644 --- a/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs @@ -16,6 +16,12 @@ public static void ClassSetup(TestContext context) Environment.SetEnvironmentVariable(TestSettingsVarName, Path.Combine(Path.GetTempPath(), "ar-test-settings")); } + [ClassCleanup] + public static void ClassTeardown() + { + Environment.SetEnvironmentVariable(TestSettingsVarName, null); + } + private static string InputSettingsPath => Path.Combine($"%{TestSettingsVarName}%", "path", "to", "settings.ini"); private static string ResolvedSettingsPath => Environment.ExpandEnvironmentVariables(InputSettingsPath);