2.2 FramesOptions (frames.json binding + validator)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
186
src/FrameProcessor/Configuration/FramesOptions.cs
Normal file
186
src/FrameProcessor/Configuration/FramesOptions.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user