2.2 FramesOptions (frames.json binding + validator)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:21:24 +02:00
parent 20e6aafaa1
commit d0fa755534
4 changed files with 461 additions and 1 deletions

View File

@@ -0,0 +1,186 @@
using FrameProcessor.Domain;
using Microsoft.Extensions.Options;
using SixLabors.ImageSharp;
namespace FrameProcessor.Configuration;
/// <summary>
/// Bound from <c>frames.json</c> (registered as an additional config source with
/// <c>reloadOnChange: true</c>). Holds the raw, un-typed frame entries; parsing into
/// <see cref="FrameName"/>, <see cref="MacAddress"/>, etc. happens downstream after
/// <see cref="FramesOptionsValidator"/> confirms the values are well-formed.
/// See SPEC.md §6.2.
/// </summary>
public sealed class FramesOptions
{
public List<FrameOptions> 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<PaletteEntryOptions> 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; }
}
/// <summary>
/// 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.
/// </summary>
public sealed class FramesOptionsValidator : IValidateOptions<FramesOptions>
{
public ValidateOptionsResult Validate(string? name, FramesOptions options)
{
var errors = new List<string>();
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<string> 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<string> 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<FrameOptions> frames, List<string> errors)
{
var seenNames = new HashSet<string>(StringComparer.Ordinal);
var seenMacs = new HashSet<string>(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,
};
}