From d0fa755534656a0e91e77f19bcb8398c46eafbb5 Mon Sep 17 00:00:00 2001 From: Fritiof Hedman Date: Sun, 7 Jun 2026 14:21:24 +0200 Subject: [PATCH] 2.2 FramesOptions (frames.json binding + validator) Co-Authored-By: Claude Opus 4.7 (1M context) --- IMPLEMENTATION.md | 2 +- .../Configuration/FramesOptions.cs | 186 ++++++++++++ src/FrameProcessor/Program.cs | 8 + .../FramesOptionsValidatorTests.cs | 266 ++++++++++++++++++ 4 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 src/FrameProcessor/Configuration/FramesOptions.cs create mode 100644 tests/FrameProcessor.Tests/FramesOptionsValidatorTests.cs diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 353dd48..1f00aa8 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -76,7 +76,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. - Bound from `appsettings.json` via `builder.Services.Configure(...)`. - Validate on startup (`ValidateOnStart` + `IValidateOptions` or DataAnnotations). -### [ ] 2.2 `FramesOptions` (from `frames.json`) +### [x] 2.2 `FramesOptions` (from `frames.json`) - Top-level `{ Frames: Frame[] }` POCO. - Register `frames.json` as an additional config source with `reloadOnChange: true`. - Bind via `IOptionsMonitor`. diff --git a/src/FrameProcessor/Configuration/FramesOptions.cs b/src/FrameProcessor/Configuration/FramesOptions.cs new file mode 100644 index 0000000..8d48173 --- /dev/null +++ b/src/FrameProcessor/Configuration/FramesOptions.cs @@ -0,0 +1,186 @@ +using FrameProcessor.Domain; +using Microsoft.Extensions.Options; +using SixLabors.ImageSharp; + +namespace FrameProcessor.Configuration; + +/// +/// Bound from frames.json (registered as an additional config source with +/// reloadOnChange: true). Holds the raw, un-typed frame entries; parsing into +/// , , etc. happens downstream after +/// confirms the values are well-formed. +/// See SPEC.md §6.2. +/// +public sealed class FramesOptions +{ + public List Frames { get; set; } = new(); +} + +public sealed class FrameOptions +{ + public string? Name { get; set; } + + public string? Mac { get; set; } + + public ResolutionOptions? Resolution { get; set; } + + public string? Orientation { get; set; } + + public string? Dithering { get; set; } + + public List Palette { get; set; } = new(); +} + +public sealed class ResolutionOptions +{ + public int Width { get; set; } + + public int Height { get; set; } +} + +public sealed class PaletteEntryOptions +{ + public string? Name { get; set; } + + public string? Color { get; set; } + + public string? DeviceColor { get; set; } +} + +/// +/// Enforces the field-level rules from SPEC.md §6.2: name is URL-safe, MAC parseable, +/// resolution positive, orientation and dithering keys recognized, palette has at +/// least two entries with parseable hex colors, and names + MACs are unique across +/// frames. +/// +public sealed class FramesOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, FramesOptions options) + { + var errors = new List(); + + for (var i = 0; i < options.Frames.Count; i++) + { + ValidateFrame(options.Frames[i], $"Frames[{i}]", errors); + } + + ValidateUniqueness(options.Frames, errors); + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } + + private static void ValidateFrame(FrameOptions frame, string prefix, List errors) + { + if (string.IsNullOrWhiteSpace(frame.Name)) + { + errors.Add($"{prefix}.Name is required."); + } + else if (!FrameName.TryParse(frame.Name, out _)) + { + errors.Add($"{prefix}.Name '{frame.Name}' is not a valid frame name (RFC 3986 unreserved characters only)."); + } + + if (string.IsNullOrWhiteSpace(frame.Mac)) + { + errors.Add($"{prefix}.Mac is required."); + } + else if (!MacAddress.TryParse(frame.Mac, out _)) + { + errors.Add($"{prefix}.Mac '{frame.Mac}' is not a valid MAC address."); + } + + if (frame.Resolution is null) + { + errors.Add($"{prefix}.Resolution is required."); + } + else + { + if (frame.Resolution.Width <= 0) + { + errors.Add($"{prefix}.Resolution.Width must be positive (got {frame.Resolution.Width})."); + } + + if (frame.Resolution.Height <= 0) + { + errors.Add($"{prefix}.Resolution.Height must be positive (got {frame.Resolution.Height})."); + } + } + + if (!IsKnownOrientation(frame.Orientation)) + { + errors.Add($"{prefix}.Orientation '{frame.Orientation}' is not valid. Expected 'landscape' or 'portrait'."); + } + + if (!IsKnownDithering(frame.Dithering)) + { + errors.Add($"{prefix}.Dithering '{frame.Dithering}' is not valid. Expected 'floyd-steinberg', 'atkinson', 'stucki', or 'jarvis'."); + } + + if (frame.Palette.Count < 2) + { + errors.Add($"{prefix}.Palette must contain at least 2 entries (found {frame.Palette.Count})."); + } + + for (var j = 0; j < frame.Palette.Count; j++) + { + ValidatePaletteEntry(frame.Palette[j], $"{prefix}.Palette[{j}]", errors); + } + } + + private static void ValidatePaletteEntry(PaletteEntryOptions entry, string prefix, List errors) + { + if (string.IsNullOrWhiteSpace(entry.Name)) + { + errors.Add($"{prefix}.Name is required."); + } + + if (string.IsNullOrWhiteSpace(entry.Color) || !Color.TryParseHex(entry.Color, out _)) + { + errors.Add($"{prefix}.Color '{entry.Color}' is not a valid hex color."); + } + + if (string.IsNullOrWhiteSpace(entry.DeviceColor) || !Color.TryParseHex(entry.DeviceColor, out _)) + { + errors.Add($"{prefix}.DeviceColor '{entry.DeviceColor}' is not a valid hex color."); + } + } + + private static void ValidateUniqueness(List frames, List errors) + { + var seenNames = new HashSet(StringComparer.Ordinal); + var seenMacs = new HashSet(StringComparer.Ordinal); + + for (var i = 0; i < frames.Count; i++) + { + var frame = frames[i]; + + if (!string.IsNullOrWhiteSpace(frame.Name) && !seenNames.Add(frame.Name)) + { + errors.Add($"Frames[{i}].Name '{frame.Name}' is a duplicate."); + } + + if (!string.IsNullOrWhiteSpace(frame.Mac) && MacAddress.TryParse(frame.Mac, out var mac)) + { + var canonical = mac.ToString(); + if (!seenMacs.Add(canonical)) + { + errors.Add($"Frames[{i}].Mac '{frame.Mac}' (canonical '{canonical}') is a duplicate."); + } + } + } + } + + private static bool IsKnownOrientation(string? value) => value switch + { + "landscape" or "portrait" => true, + _ => false, + }; + + private static bool IsKnownDithering(string? value) => value switch + { + "floyd-steinberg" or "atkinson" or "stucki" or "jarvis" => true, + _ => false, + }; +} diff --git a/src/FrameProcessor/Program.cs b/src/FrameProcessor/Program.cs index 4022b0d..d20bc1b 100644 --- a/src/FrameProcessor/Program.cs +++ b/src/FrameProcessor/Program.cs @@ -1,7 +1,10 @@ using FrameProcessor.Configuration; +using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("frames.json", optional: false, reloadOnChange: true); + builder.Services.AddControllers(); builder.Services.AddOptions() @@ -24,6 +27,11 @@ builder.Services.AddOptions() .ValidateDataAnnotations() .ValidateOnStart(); +builder.Services.AddSingleton, FramesOptionsValidator>(); +builder.Services.AddOptions() + .Bind(builder.Configuration) + .ValidateOnStart(); + var app = builder.Build(); app.MapControllers(); diff --git a/tests/FrameProcessor.Tests/FramesOptionsValidatorTests.cs b/tests/FrameProcessor.Tests/FramesOptionsValidatorTests.cs new file mode 100644 index 0000000..47d4577 --- /dev/null +++ b/tests/FrameProcessor.Tests/FramesOptionsValidatorTests.cs @@ -0,0 +1,266 @@ +using FrameProcessor.Configuration; + +namespace FrameProcessor.Tests; + +public class FramesOptionsValidatorTests +{ + private readonly FramesOptionsValidator _validator = new(); + + [Fact] + public void NoFrames_IsValid() + { + var result = _validator.Validate(null, new FramesOptions()); + + Assert.True(result.Succeeded); + } + + [Fact] + public void ValidFrame_Succeeds() + { + var options = new FramesOptions { Frames = { ValidFrame() } }; + + var result = _validator.Validate(null, options); + + Assert.True(result.Succeeded, JoinFailures(result)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Name_MissingOrBlank_Fails(string? badName) + { + var frame = ValidFrame(); + frame.Name = badName; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("Name is required")); + } + + [Theory] + [InlineData("living room")] + [InlineData("living/room")] + [InlineData("li:vingroom")] + public void Name_NotUrlSafe_Fails(string badName) + { + var frame = ValidFrame(); + frame.Name = badName; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("is not a valid frame name")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Mac_Missing_Fails(string? badMac) + { + var frame = ValidFrame(); + frame.Mac = badMac; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("Mac is required")); + } + + [Theory] + [InlineData("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ")] + [InlineData("AABBCC")] + [InlineData("not-a-mac")] + public void Mac_Unparseable_Fails(string badMac) + { + var frame = ValidFrame(); + frame.Mac = badMac; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("is not a valid MAC address")); + } + + [Fact] + public void Resolution_Missing_Fails() + { + var frame = ValidFrame(); + frame.Resolution = null; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("Resolution is required")); + } + + [Theory] + [InlineData(0, 1200)] + [InlineData(1600, 0)] + [InlineData(-1, 1200)] + [InlineData(1600, -1)] + public void Resolution_NonPositive_Fails(int width, int height) + { + var frame = ValidFrame(); + frame.Resolution = new ResolutionOptions { Width = width, Height = height }; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("must be positive")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("LANDSCAPE")] + [InlineData("sideways")] + public void Orientation_Invalid_Fails(string? badOrientation) + { + var frame = ValidFrame(); + frame.Orientation = badOrientation; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("Orientation")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("FloydSteinberg")] + [InlineData("ordered")] + public void Dithering_Invalid_Fails(string? badDithering) + { + var frame = ValidFrame(); + frame.Dithering = badDithering; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("Dithering")); + } + + [Fact] + public void Palette_FewerThanTwoEntries_Fails() + { + var frame = ValidFrame(); + frame.Palette = new List + { + new() { Name = "black", Color = "#000000", DeviceColor = "#000000" }, + }; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("at least 2 entries")); + } + + [Fact] + public void Palette_EntryWithBadHex_Fails() + { + var frame = ValidFrame(); + frame.Palette[0].Color = "not-a-color"; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("Color 'not-a-color' is not a valid hex color")); + } + + [Fact] + public void Palette_EntryWithBadDeviceHex_Fails() + { + var frame = ValidFrame(); + frame.Palette[1].DeviceColor = "zzzzzz"; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("DeviceColor 'zzzzzz' is not a valid hex color")); + } + + [Fact] + public void Palette_EntryMissingName_Fails() + { + var frame = ValidFrame(); + frame.Palette[0].Name = ""; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("Palette[0].Name is required")); + } + + [Fact] + public void DuplicateName_Fails() + { + var a = ValidFrame(); + var b = ValidFrame(); + b.Mac = "11:22:33:44:55:66"; + + var result = _validator.Validate(null, new FramesOptions { Frames = { a, b } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("Name 'living-room' is a duplicate")); + } + + [Fact] + public void DuplicateMac_DetectedAfterCanonicalization() + { + var a = ValidFrame(); + a.Mac = "AA:BB:CC:DD:EE:FF"; + var b = ValidFrame(); + b.Name = "kitchen"; + b.Mac = "aabbccddeeff"; + + var result = _validator.Validate(null, new FramesOptions { Frames = { a, b } }); + + Assert.False(result.Succeeded); + Assert.Contains(result.Failures!, f => f.Contains("is a duplicate")); + } + + [Fact] + public void AllErrors_AreReportedTogether() + { + var frame = new FrameOptions + { + Name = "bad name", + Mac = "not-a-mac", + Resolution = null, + Orientation = "diagonal", + Dithering = "ordered", + Palette = new List(), + }; + + var result = _validator.Validate(null, new FramesOptions { Frames = { frame } }); + + Assert.False(result.Succeeded); + var failures = result.Failures!.ToList(); + Assert.Contains(failures, f => f.Contains("Name")); + Assert.Contains(failures, f => f.Contains("Mac")); + Assert.Contains(failures, f => f.Contains("Resolution is required")); + Assert.Contains(failures, f => f.Contains("Orientation")); + Assert.Contains(failures, f => f.Contains("Dithering")); + Assert.Contains(failures, f => f.Contains("at least 2 entries")); + } + + private static FrameOptions ValidFrame() => new() + { + Name = "living-room", + Mac = "AA:BB:CC:DD:EE:FF", + Resolution = new ResolutionOptions { Width = 1600, Height = 1200 }, + Orientation = "landscape", + Dithering = "floyd-steinberg", + Palette = new List + { + new() { Name = "black", Color = "#1F2226", DeviceColor = "#000000" }, + new() { Name = "white", Color = "#B9C7C9", DeviceColor = "#FFFFFF" }, + }, + }; + + private static string JoinFailures(Microsoft.Extensions.Options.ValidateOptionsResult result) + => result.Failures is null ? string.Empty : string.Join("; ", result.Failures); +}