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);
+ }
+}