2.2 FramesOptions (frames.json binding + validator)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -76,7 +76,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.
|
|||||||
- Bound from `appsettings.json` via `builder.Services.Configure<T>(...)`.
|
- Bound from `appsettings.json` via `builder.Services.Configure<T>(...)`.
|
||||||
- Validate on startup (`ValidateOnStart` + `IValidateOptions<T>` or DataAnnotations).
|
- Validate on startup (`ValidateOnStart` + `IValidateOptions<T>` or DataAnnotations).
|
||||||
|
|
||||||
### [ ] 2.2 `FramesOptions` (from `frames.json`)
|
### [x] 2.2 `FramesOptions` (from `frames.json`)
|
||||||
- Top-level `{ Frames: Frame[] }` POCO.
|
- Top-level `{ Frames: Frame[] }` POCO.
|
||||||
- Register `frames.json` as an additional config source with `reloadOnChange: true`.
|
- Register `frames.json` as an additional config source with `reloadOnChange: true`.
|
||||||
- Bind via `IOptionsMonitor<FramesOptions>`.
|
- Bind via `IOptionsMonitor<FramesOptions>`.
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
using FrameProcessor.Configuration;
|
using FrameProcessor.Configuration;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Configuration.AddJsonFile("frames.json", optional: false, reloadOnChange: true);
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
builder.Services.AddOptions<MqttOptions>()
|
builder.Services.AddOptions<MqttOptions>()
|
||||||
@@ -24,6 +27,11 @@ builder.Services.AddOptions<ApiKeyOptions>()
|
|||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<IValidateOptions<FramesOptions>, FramesOptionsValidator>();
|
||||||
|
builder.Services.AddOptions<FramesOptions>()
|
||||||
|
.Bind(builder.Configuration)
|
||||||
|
.ValidateOnStart();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|||||||
266
tests/FrameProcessor.Tests/FramesOptionsValidatorTests.cs
Normal file
266
tests/FrameProcessor.Tests/FramesOptionsValidatorTests.cs
Normal file
@@ -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<PaletteEntryOptions>
|
||||||
|
{
|
||||||
|
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<PaletteEntryOptions>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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<PaletteEntryOptions>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user