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/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/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..fe25c4c4 --- /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."); + } + + 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."); + } + + 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..4a657bef --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/LayoutCompilationEngine.cs @@ -0,0 +1,243 @@ +using System.Collections.ObjectModel; +using System.Text; +using System.Text.RegularExpressions; +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(); + } + + /// + /// 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};"); + sb.AppendLine($" grid-column: {element.GridColumn} / span {element.GridColumnSpan};"); + + if (!string.IsNullOrWhiteSpace(element.AdditionalCss)) + { + // Inline per-element overrides inside the same rule block. + // Lines containing '{' or '}' are skipped: they are not valid in CSS property + // declarations and indicate an injection attempt (e.g. breaking out of the rule block). + foreach (string line in element.AdditionalCss.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (line.Contains('{', StringComparison.Ordinal) || line.Contains('}', StringComparison.Ordinal)) + { + continue; + } + + 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..b9f652ec --- /dev/null +++ b/src/AdaptiveRemote.Backend.LayoutCompilerService/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "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" + }, + "LayoutCompilerService (HTTP)": { + "commandName": "Project", + "launchBrowser": false, + "outputCapture": "None", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "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..da8a6e38 100644 --- a/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Program.cs @@ -122,8 +122,29 @@ void ConfigureRawLayoutClient(HttpClient client) } }); -// Register stub implementations (to be replaced in later tasks) -builder.Services.AddSingleton(); +// Configure HTTP client for LayoutCompilerService +LayoutCompilerServiceSettings layoutCompilerSettings = builder.Configuration + .GetSection("LayoutCompilerService") + .Get() ?? new LayoutCompilerServiceSettings(); + +builder.Services.Configure(builder.Configuration.GetSection("LayoutCompilerService")); + +if (!string.IsNullOrEmpty(layoutCompilerSettings.BaseUrl) && + Uri.TryCreate(layoutCompilerSettings.BaseUrl, UriKind.Absolute, out Uri? compilerBaseUri)) +{ + builder.Services.AddHttpClient(client => + { + client.BaseAddress = compilerBaseUri; + }); +} +else +{ + // 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(); 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/Services/StubLayoutCompilerClient.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs index 31d2902c..ecf8d69c 100644 --- a/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs +++ b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutCompilerClient.cs @@ -7,7 +7,9 @@ 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. +/// 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 { @@ -26,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/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs b/src/AdaptiveRemote.Backend.LayoutProcessingService/Services/StubLayoutValidationClient.cs index 82fcb6ec..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 { @@ -32,6 +33,15 @@ public Task ValidateAsync(CompiledLayout compiled, Cancellatio return Task.FromResult(failure); } + if (_forceInvalid) + { + ValidationResult failure = new( + IsValid: false, + Issues: [new ValidationIssue("STUB_INVALID", "Stub validation forced invalid for testing", null)] + ); + return Task.FromResult(failure); + } + // Stub: always valid until real validation Lambda is wired in ADR-172 ValidationResult result = new( IsValid: true, 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/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.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs b/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs index 98fc4ba0..4c98940a 100644 --- a/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs @@ -5,8 +5,25 @@ 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")); + } + + [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); private readonly MockLogger MockLogger = new(); private readonly MockFileSystem MockFileSystem = new(); @@ -405,4 +422,3 @@ public async Task PersistSettings_Set_ReadsExistingIniSectionFormatAsync() } } - 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..3c64e4e0 --- /dev/null +++ b/test/AdaptiveRemote.Backend.LayoutCompilerService.Tests/Engine/LayoutCompilationEngineTests.cs @@ -0,0 +1,550 @@ +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_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() + { + // 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);"); + } + + [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] + 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(); + } + + // ── 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) + { + 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); + } +}