1.5 PaletteEntry value type
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.
|
|||||||
- `record(int Width, int Height)`.
|
- `record(int Width, int Height)`.
|
||||||
- `Resolution ForOrientation(Orientation o)` — swaps when portrait.
|
- `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`.
|
- `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).
|
- JSON converter parses hex strings into `Color` at deserialize time (don't store hex strings on the type).
|
||||||
|
|
||||||
|
|||||||
118
src/FrameProcessor/Domain/PaletteEntry.cs
Normal file
118
src/FrameProcessor/Domain/PaletteEntry.cs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
|
||||||
|
namespace FrameProcessor.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="Color"/> values;
|
||||||
|
/// the entry does not retain the original strings.
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(PaletteEntryJsonConverter))]
|
||||||
|
public sealed record PaletteEntry(string Name, Color DisplayColor, Color DeviceColor);
|
||||||
|
|
||||||
|
internal sealed class PaletteEntryJsonConverter : JsonConverter<PaletteEntry>
|
||||||
|
{
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
111
tests/FrameProcessor.Tests/PaletteEntryTests.cs
Normal file
111
tests/FrameProcessor.Tests/PaletteEntryTests.cs
Normal file
@@ -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<PaletteEntry>(json);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Equal("black", entry!.Name);
|
||||||
|
Assert.Equal(new Rgba32(0x1F, 0x22, 0x26, 0xFF), entry.DisplayColor.ToPixel<Rgba32>());
|
||||||
|
Assert.Equal(new Rgba32(0x00, 0x00, 0x00, 0xFF), entry.DeviceColor.ToPixel<Rgba32>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JsonDeserialize_HexWithoutHashIsAccepted()
|
||||||
|
{
|
||||||
|
const string json = """{ "name": "red", "color": "62201E", "deviceColor": "FF0000" }""";
|
||||||
|
|
||||||
|
var entry = JsonSerializer.Deserialize<PaletteEntry>(json);
|
||||||
|
|
||||||
|
Assert.NotNull(entry);
|
||||||
|
Assert.Equal(new Rgba32(0x62, 0x20, 0x1E, 0xFF), entry!.DisplayColor.ToPixel<Rgba32>());
|
||||||
|
Assert.Equal(new Rgba32(0xFF, 0x00, 0x00, 0xFF), entry.DeviceColor.ToPixel<Rgba32>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<PaletteEntry>(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<JsonException>(() => JsonSerializer.Deserialize<PaletteEntry>(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<JsonException>(() => JsonSerializer.Deserialize<PaletteEntry>(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JsonDeserialize_EmptyName_Throws()
|
||||||
|
{
|
||||||
|
const string json = """{ "name": "", "color": "#000000", "deviceColor": "#000000" }""";
|
||||||
|
|
||||||
|
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PaletteEntry>(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JsonDeserialize_UnknownFields_AreIgnored()
|
||||||
|
{
|
||||||
|
const string json = """{ "name": "black", "color": "#000000", "deviceColor": "#000000", "extra": 42 }""";
|
||||||
|
|
||||||
|
var entry = JsonSerializer.Deserialize<PaletteEntry>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user