using FrameProcessor.Configuration; using FrameProcessor.Domain; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace FrameProcessor.Tests; public class FramesRegistryTests { [Fact] public void Construction_StrictOnStartup_ThrowsOnInvalidFrame() { var monitor = new TestOptionsMonitor(new FramesOptions { Frames = { Invalid() }, }); var ex = Assert.Throws( () => new FramesRegistry(monitor, new FramesOptionsValidator(), NullLogger.Instance)); Assert.NotEmpty(ex.Failures); } [Fact] public void Construction_StrictOnStartup_FailsIfOneFrameIsBad() { var monitor = new TestOptionsMonitor(new FramesOptions { Frames = { LivingRoom(), Invalid() }, }); Assert.Throws( () => new FramesRegistry(monitor, new FramesOptionsValidator(), NullLogger.Instance)); } [Fact] public void TryGetByName_FindsParsedFrame() { var registry = BuildWith(LivingRoom()); Assert.True(registry.TryGetByName(FrameName.Parse("living-room"), out var frame)); Assert.Equal("living-room", frame.Name.Value); Assert.Equal("aabbccddeeff", frame.Mac.ToString()); Assert.Equal(1600, frame.Resolution.Width); Assert.Equal(1200, frame.Resolution.Height); Assert.Equal(Orientation.Landscape, frame.Orientation); Assert.Equal(DitherAlgorithm.FloydSteinberg, frame.Dithering); Assert.Equal(2, frame.Palette.Count); } [Fact] public void TryGetByMac_LooksUpRegardlessOfInputFormat() { var registry = BuildWith(LivingRoom()); Assert.True(MacAddress.TryParse("AA-BB-CC-DD-EE-FF", out var mac)); Assert.True(registry.TryGetByMac(mac, out var frame)); Assert.Equal("living-room", frame.Name.Value); } [Fact] public void TryGetByName_UnknownReturnsFalse() { var registry = BuildWith(LivingRoom()); Assert.False(registry.TryGetByName(FrameName.Parse("kitchen"), out _)); } [Fact] public void TryGetByMac_UnknownReturnsFalse() { var registry = BuildWith(LivingRoom()); Assert.True(MacAddress.TryParse("11:22:33:44:55:66", out var mac)); Assert.False(registry.TryGetByMac(mac, out _)); } [Fact] public void Reload_DropsInvalidFrameAndKeepsValidOnes() { var monitor = new TestOptionsMonitor(new FramesOptions { Frames = { LivingRoom() }, }); var registry = new FramesRegistry(monitor, new FramesOptionsValidator(), NullLogger.Instance); monitor.Emit(new FramesOptions { Frames = { LivingRoom(), Invalid(), Kitchen() }, }); Assert.True(registry.TryGetByName(FrameName.Parse("living-room"), out _)); Assert.True(registry.TryGetByName(FrameName.Parse("kitchen"), out _)); Assert.Equal(2, registry.All.Count); } [Fact] public void Reload_DoesNotThrowOnAllInvalid() { var monitor = new TestOptionsMonitor(new FramesOptions { Frames = { LivingRoom() }, }); var registry = new FramesRegistry(monitor, new FramesOptionsValidator(), NullLogger.Instance); monitor.Emit(new FramesOptions { Frames = { Invalid() } }); Assert.Empty(registry.All); } [Fact] public void Reload_LogsWarningForSkippedFrame() { var logger = new ListLogger(); var monitor = new TestOptionsMonitor(new FramesOptions { Frames = { LivingRoom() }, }); var registry = new FramesRegistry(monitor, new FramesOptionsValidator(), logger); monitor.Emit(new FramesOptions { Frames = { LivingRoom(), Invalid() } }); Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("Skipping invalid frame")); } [Fact] public void Reload_SkipsDuplicateNameAcrossSurvivingFrames() { var logger = new ListLogger(); var monitor = new TestOptionsMonitor(new FramesOptions { Frames = { LivingRoom() }, }); var registry = new FramesRegistry(monitor, new FramesOptionsValidator(), logger); var firstWithName = LivingRoom(); firstWithName.Mac = "11:22:33:44:55:66"; var secondWithSameName = LivingRoom(); secondWithSameName.Mac = "77:88:99:AA:BB:CC"; monitor.Emit(new FramesOptions { Frames = { firstWithName, secondWithSameName } }); Assert.Single(registry.All); Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("duplicate name")); } [Fact] public void Reload_SkipsDuplicateMacAcrossSurvivingFrames() { var logger = new ListLogger(); var monitor = new TestOptionsMonitor(new FramesOptions { Frames = { LivingRoom() }, }); var registry = new FramesRegistry(monitor, new FramesOptionsValidator(), logger); var firstWithMac = LivingRoom(); var secondWithSameMac = LivingRoom(); secondWithSameMac.Name = "kitchen"; secondWithSameMac.Mac = "aabbccddeeff"; monitor.Emit(new FramesOptions { Frames = { firstWithMac, secondWithSameMac } }); Assert.Single(registry.All); Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("duplicate MAC")); } [Fact] public void Dispose_UnsubscribesFromMonitor() { var monitor = new TestOptionsMonitor(new FramesOptions { Frames = { LivingRoom() }, }); var registry = new FramesRegistry(monitor, new FramesOptionsValidator(), NullLogger.Instance); registry.Dispose(); monitor.Emit(new FramesOptions { Frames = { LivingRoom(), Kitchen() } }); Assert.Single(registry.All); Assert.True(registry.TryGetByName(FrameName.Parse("living-room"), out _)); } private static FramesRegistry BuildWith(params FrameOptions[] frames) { var monitor = new TestOptionsMonitor(new FramesOptions { Frames = frames.ToList(), }); return new FramesRegistry(monitor, new FramesOptionsValidator(), NullLogger.Instance); } private static FrameOptions LivingRoom() => 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 { new() { Name = "black", Color = "#1F2226", DeviceColor = "#000000" }, new() { Name = "white", Color = "#B9C7C9", DeviceColor = "#FFFFFF" }, }, }; private static FrameOptions Kitchen() => new() { Name = "kitchen", Mac = "11:22:33:44:55:66", Resolution = new ResolutionOptions { Width = 800, Height = 600 }, Orientation = "portrait", Dithering = "atkinson", Palette = new List { new() { Name = "black", Color = "#000000", DeviceColor = "#000000" }, new() { Name = "white", Color = "#FFFFFF", DeviceColor = "#FFFFFF" }, }, }; private static FrameOptions Invalid() => new() { Name = "bad name", Mac = "not-a-mac", Resolution = null, Orientation = "diagonal", Dithering = "ordered", Palette = new List(), }; private sealed class TestOptionsMonitor : IOptionsMonitor { private T _current; private Action? _listeners; public TestOptionsMonitor(T initial) { _current = initial; } public T CurrentValue => _current; public T Get(string? name) => _current; public IDisposable? OnChange(Action listener) { _listeners += listener; return new Unsubscriber(() => _listeners -= listener); } public void Emit(T newValue) { _current = newValue; _listeners?.Invoke(newValue, null); } private sealed class Unsubscriber : IDisposable { private readonly Action _onDispose; public Unsubscriber(Action onDispose) { _onDispose = onDispose; } public void Dispose() => _onDispose(); } } private sealed record LogEntry(LogLevel Level, string Message); private sealed class ListLogger : ILogger { public List Entries { get; } = new(); public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Entries.Add(new LogEntry(logLevel, formatter(state, exception))); } } }