Files
frame-processor/tests/FrameProcessor.Tests/FramesRegistryTests.cs
Fritiof Hedman 0dc0da8de1 2.3 Startup vs reload asymmetry (FramesRegistry)
FramesRegistry validates strictly on construction (fail-fast at startup) and
leniently on hot-reload (skip invalid frames with a warning, keep valid ones
serving). Exposes TryGetByName/TryGetByMac over the current valid set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 14:27:20 +02:00

290 lines
9.3 KiB
C#

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<FramesOptions>(new FramesOptions
{
Frames = { Invalid() },
});
var ex = Assert.Throws<OptionsValidationException>(
() => new FramesRegistry(monitor, new FramesOptionsValidator(), NullLogger<FramesRegistry>.Instance));
Assert.NotEmpty(ex.Failures);
}
[Fact]
public void Construction_StrictOnStartup_FailsIfOneFrameIsBad()
{
var monitor = new TestOptionsMonitor<FramesOptions>(new FramesOptions
{
Frames = { LivingRoom(), Invalid() },
});
Assert.Throws<OptionsValidationException>(
() => new FramesRegistry(monitor, new FramesOptionsValidator(), NullLogger<FramesRegistry>.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<FramesOptions>(new FramesOptions
{
Frames = { LivingRoom() },
});
var registry = new FramesRegistry(monitor, new FramesOptionsValidator(), NullLogger<FramesRegistry>.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<FramesOptions>(new FramesOptions
{
Frames = { LivingRoom() },
});
var registry = new FramesRegistry(monitor, new FramesOptionsValidator(), NullLogger<FramesRegistry>.Instance);
monitor.Emit(new FramesOptions { Frames = { Invalid() } });
Assert.Empty(registry.All);
}
[Fact]
public void Reload_LogsWarningForSkippedFrame()
{
var logger = new ListLogger<FramesRegistry>();
var monitor = new TestOptionsMonitor<FramesOptions>(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<FramesRegistry>();
var monitor = new TestOptionsMonitor<FramesOptions>(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<FramesRegistry>();
var monitor = new TestOptionsMonitor<FramesOptions>(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<FramesOptions>(new FramesOptions
{
Frames = { LivingRoom() },
});
var registry = new FramesRegistry(monitor, new FramesOptionsValidator(), NullLogger<FramesRegistry>.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<FramesOptions>(new FramesOptions
{
Frames = frames.ToList(),
});
return new FramesRegistry(monitor, new FramesOptionsValidator(), NullLogger<FramesRegistry>.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<PaletteEntryOptions>
{
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<PaletteEntryOptions>
{
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<PaletteEntryOptions>(),
};
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
{
private T _current;
private Action<T, string?>? _listeners;
public TestOptionsMonitor(T initial) { _current = initial; }
public T CurrentValue => _current;
public T Get(string? name) => _current;
public IDisposable? OnChange(Action<T, string?> 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<T> : ILogger<T>
{
public List<LogEntry> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add(new LogEntry(logLevel, formatter(state, exception)));
}
}
}