From fa0081b923f235503b779b70e480e65d4c69e66b Mon Sep 17 00:00:00 2001 From: Fritiof Hedman Date: Sun, 7 Jun 2026 14:09:15 +0200 Subject: [PATCH] 1.5 PaletteEntry value type Co-Authored-By: Claude Opus 4.7 (1M context) --- IMPLEMENTATION.md | 2 +- src/FrameProcessor/Domain/PaletteEntry.cs | 118 ++++++++++++++++++ .../FrameProcessor.Tests/PaletteEntryTests.cs | 111 ++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/FrameProcessor/Domain/PaletteEntry.cs create mode 100644 tests/FrameProcessor.Tests/PaletteEntryTests.cs diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index bdd36be..ba55fbc 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -55,7 +55,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. - `record(int Width, int Height)`. - `Resolution ForOrientation(Orientation o)` — swaps when portrait. -### [ ] 1.5 `PaletteEntry` +### [x] 1.5 `PaletteEntry` - `record(string Name, Color DisplayColor, Color DeviceColor)` using `SixLabors.ImageSharp.Color`. - JSON converter parses hex strings into `Color` at deserialize time (don't store hex strings on the type). diff --git a/src/FrameProcessor/Domain/PaletteEntry.cs b/src/FrameProcessor/Domain/PaletteEntry.cs new file mode 100644 index 0000000..3091118 --- /dev/null +++ b/src/FrameProcessor/Domain/PaletteEntry.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SixLabors.ImageSharp; + +namespace FrameProcessor.Domain; + +/// +/// One palette entry mapping a display color (what the eye sees on the panel) to +/// a device color (the RGB value firmware expects in the input PNG). +/// Hex strings from configuration are parsed once into values; +/// the entry does not retain the original strings. +/// +[JsonConverter(typeof(PaletteEntryJsonConverter))] +public sealed record PaletteEntry(string Name, Color DisplayColor, Color DeviceColor); + +internal sealed class PaletteEntryJsonConverter : JsonConverter +{ + public override PaletteEntry Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Palette entry must be a JSON object."); + } + + string? name = null; + Color? displayColor = null; + Color? deviceColor = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + if (name is null) + { + throw new JsonException("Palette entry is missing required field 'name'."); + } + + if (displayColor is null) + { + throw new JsonException("Palette entry is missing required field 'color'."); + } + + if (deviceColor is null) + { + throw new JsonException("Palette entry is missing required field 'deviceColor'."); + } + + return new PaletteEntry(name, displayColor.Value, deviceColor.Value); + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException(); + } + + var propertyName = reader.GetString(); + if (!reader.Read()) + { + throw new JsonException("Unexpected end of palette entry."); + } + + switch (propertyName) + { + case "name": + var n = reader.GetString(); + if (string.IsNullOrWhiteSpace(n)) + { + throw new JsonException("Palette entry 'name' must be a non-empty string."); + } + + name = n; + break; + case "color": + displayColor = ParseHex(reader.GetString(), "color"); + break; + case "deviceColor": + deviceColor = ParseHex(reader.GetString(), "deviceColor"); + break; + default: + reader.Skip(); + break; + } + } + + throw new JsonException("Unexpected end of palette entry."); + } + + public override void Write(Utf8JsonWriter writer, PaletteEntry value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("name", value.Name); + writer.WriteString("color", ToRgbHex(value.DisplayColor)); + writer.WriteString("deviceColor", ToRgbHex(value.DeviceColor)); + writer.WriteEndObject(); + } + + private static Color ParseHex(string? s, string field) + { + if (string.IsNullOrWhiteSpace(s)) + { + throw new JsonException($"Palette entry '{field}' must be a non-empty hex string."); + } + + if (!Color.TryParseHex(s, out var color)) + { + throw new JsonException($"'{s}' is not a valid hex color for '{field}'. Expected '#RRGGBB'."); + } + + return color; + } + + private static string ToRgbHex(Color color) + { + // ImageSharp's Color.ToHex() returns 8 characters (RRGGBBAA); SPEC writes 6-digit '#RRGGBB'. + var hex = color.ToHex(); + return "#" + hex[..6]; + } +} diff --git a/tests/FrameProcessor.Tests/PaletteEntryTests.cs b/tests/FrameProcessor.Tests/PaletteEntryTests.cs new file mode 100644 index 0000000..7a32b31 --- /dev/null +++ b/tests/FrameProcessor.Tests/PaletteEntryTests.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using FrameProcessor.Domain; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace FrameProcessor.Tests; + +public class PaletteEntryTests +{ + [Fact] + public void JsonDeserialize_ParsesHexIntoColor() + { + const string json = """{ "name": "black", "color": "#1F2226", "deviceColor": "#000000" }"""; + + var entry = JsonSerializer.Deserialize(json); + + Assert.NotNull(entry); + Assert.Equal("black", entry!.Name); + Assert.Equal(new Rgba32(0x1F, 0x22, 0x26, 0xFF), entry.DisplayColor.ToPixel()); + Assert.Equal(new Rgba32(0x00, 0x00, 0x00, 0xFF), entry.DeviceColor.ToPixel()); + } + + [Fact] + public void JsonDeserialize_HexWithoutHashIsAccepted() + { + const string json = """{ "name": "red", "color": "62201E", "deviceColor": "FF0000" }"""; + + var entry = JsonSerializer.Deserialize(json); + + Assert.NotNull(entry); + Assert.Equal(new Rgba32(0x62, 0x20, 0x1E, 0xFF), entry!.DisplayColor.ToPixel()); + Assert.Equal(new Rgba32(0xFF, 0x00, 0x00, 0xFF), entry.DeviceColor.ToPixel()); + } + + [Fact] + public void JsonSerialize_EmitsHashPrefixedSixDigitHex() + { + var entry = new PaletteEntry( + "yellow", + Color.FromPixel(new Rgba32(0xC1, 0xBB, 0x1E, 0xFF)), + Color.FromPixel(new Rgba32(0xFF, 0xFF, 0x00, 0xFF))); + + var json = JsonSerializer.Serialize(entry); + + Assert.Contains("\"name\":\"yellow\"", json); + Assert.Contains("\"color\":\"#C1BB1E\"", json); + Assert.Contains("\"deviceColor\":\"#FFFF00\"", json); + } + + [Fact] + public void JsonRoundTrip_PreservesValues() + { + var original = new PaletteEntry( + "blue", + Color.FromPixel(new Rgba32(0x23, 0x3F, 0x8E, 0xFF)), + Color.FromPixel(new Rgba32(0x00, 0x00, 0xFF, 0xFF))); + + var json = JsonSerializer.Serialize(original); + var roundTripped = JsonSerializer.Deserialize(json); + + Assert.Equal(original, roundTripped); + } + + [Theory] + [InlineData("""{ "color": "#000000", "deviceColor": "#000000" }""")] + [InlineData("""{ "name": "x", "deviceColor": "#000000" }""")] + [InlineData("""{ "name": "x", "color": "#000000" }""")] + public void JsonDeserialize_MissingField_Throws(string json) + { + Assert.Throws(() => JsonSerializer.Deserialize(json)); + } + + [Theory] + [InlineData("""{ "name": "x", "color": "not-a-color", "deviceColor": "#000000" }""")] + [InlineData("""{ "name": "x", "color": "#000000", "deviceColor": "zzz" }""")] + [InlineData("""{ "name": "x", "color": "", "deviceColor": "#000000" }""")] + public void JsonDeserialize_InvalidHex_Throws(string json) + { + Assert.Throws(() => JsonSerializer.Deserialize(json)); + } + + [Fact] + public void JsonDeserialize_EmptyName_Throws() + { + const string json = """{ "name": "", "color": "#000000", "deviceColor": "#000000" }"""; + + Assert.Throws(() => JsonSerializer.Deserialize(json)); + } + + [Fact] + public void JsonDeserialize_UnknownFields_AreIgnored() + { + const string json = """{ "name": "black", "color": "#000000", "deviceColor": "#000000", "extra": 42 }"""; + + var entry = JsonSerializer.Deserialize(json); + + Assert.NotNull(entry); + Assert.Equal("black", entry!.Name); + } + + [Fact] + public void Equality_BasedOnNameAndColors() + { + var a = new PaletteEntry("black", Color.Black, Color.Black); + var b = new PaletteEntry("black", Color.Black, Color.Black); + var c = new PaletteEntry("white", Color.Black, Color.Black); + + Assert.Equal(a, b); + Assert.NotEqual(a, c); + } +}