using System.Text.Json; using Artemis.Core.LayerBrushes; using Artemis.Core.LayerEffects; using Artemis.Core.Services.Core; using Artemis.Core.Tests.Infrastructure.Plugins; using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders; using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes; using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects; using Artemis.Storage.Entities.Profile; using RGB.NET.Core; using RGB.NET.Layout; using SkiaSharp; namespace Artemis.Core.Tests.Infrastructure.Rendering; /// /// Renders profiles onto fake RGB.NET devices through Artemis' real render pipeline (, /// sampling and RGB.NET LED updates) without starting any services or loading plugins. /// Time is fully controlled by the harness, frames are only rendered when requested. /// public sealed class RenderHarness : IRenderer, IDisposable { /// /// The render scale Artemis uses by default /// public const float DefaultRenderScale = 0.5f; private readonly TestPlugin _plugin = new(); private readonly TestLayerBrushProvider _brushProvider; private readonly TestLayerEffectProvider _effectProvider; private readonly SurfaceManager _surfaceManager; private readonly List _devices = []; private readonly List _renderExceptions = []; private readonly Dictionary _deviceColors = []; private readonly Dictionary _elapsed = []; private readonly int _previousRenderScaleMultiplier; private Action? _draw; private SKBitmap? _lastCanvas; private readonly List _profiles = []; private bool _disposed; /// The scale of the canvas relative to the surface, Artemis defaults to 0.5 public RenderHarness(float renderScale = DefaultRenderScale) { // RenderService keeps the static render scale multiplier, which snaps LED rectangles to whole canvas pixels, in sync // with the render scale setting. Set it before any devices are created and restore it afterwards _previousRenderScaleMultiplier = RenderScale.RenderScaleMultiplier; RenderScale.SetRenderScaleMultiplier((int) (1 / renderScale)); _brushProvider = _plugin.AddFeature(new TestLayerBrushProvider()); _effectProvider = _plugin.AddFeature(new TestLayerEffectProvider()); _surfaceManager = new SurfaceManager(this, null, 30, renderScale); // RGB.NET swallows exceptions thrown during a surface update, collect them so they can fail the test _surfaceManager.Surface.Exception += args => _renderExceptions.Add(args.Exception); } /// /// Gets the devices added to the harness /// public IReadOnlyList Devices => _devices; /// /// Adds a fake device based on an RGB.NET layout file, see for the layouts shipped with Artemis. /// /// The path to the layout XML file /// The X position of the device on the surface /// The Y position of the device on the surface public ArtemisDevice AddDevice(string layoutPath, float x = 0, float y = 0) { DeviceLayout layout = DeviceLayout.Load(layoutPath) ?? throw new ArgumentException($"Failed to load layout {layoutPath}", nameof(layoutPath)); // One provider per device keeps devices independent of each other TestDeviceProvider deviceProvider = _plugin.AddFeature(new TestDeviceProvider()); IRGBDevice rgbDevice = deviceProvider.CreateDevice(layout, CaptureLedColor); ArtemisDevice device = new(rgbDevice, deviceProvider) {X = x, Y = y}; _devices.Add(device); _surfaceManager.AddDevices([device]); return device; } /// /// Removes the device from the surface, like disabling a device in Artemis does. /// public void RemoveDevice(ArtemisDevice device) { _surfaceManager.RemoveDevices([device]); _devices.Remove(device); } /// /// Creates a new, empty profile containing only a root folder. /// public Profile CreateProfile(string name = "Test profile") { ProfileCategory category = new("Tests", 0); ProfileConfiguration configuration = new(category, name, "TestTube"); Profile profile = new(configuration, configuration.Entity.Profile); configuration.Profile = profile; _profiles.Add(profile); return profile; } /// /// Adds a layer to the provided parent that uses the provided test brush. /// /// Note: like any layer created in code, its timeline starts at the end. Call /// on the profile (like the editor does) or use when a test depends on the timeline /// starting at the beginning. /// /// /// The folder (or profile root folder) to add the layer to /// The LEDs the layer should cover /// The name of the layer /// /// The position among the parent's children (0-based), defaults to the end. The first child renders on top. /// /// The type of test brush to use, must be registered by /// The layer and its brush, the brush exposes the brush properties public (Layer Layer, TBrush Brush) AddLayer(Folder parent, IEnumerable leds, string name = "Test layer", int? order = null) where TBrush : BaseLayerBrush { // The Layer constructor does not add the layer to the parent Layer layer = new(parent, name); parent.AddChild(layer, order); layer.AddLeds(leds); return (layer, ChangeBrush(layer)); } /// /// Gives the layer a new instance of the provided test brush. /// /// The type of test brush to use, must be registered by public TBrush ChangeBrush(Layer layer) where TBrush : BaseLayerBrush { LayerBrushDescriptor descriptor = _brushProvider.LayerBrushDescriptors.Single(d => d.LayerBrushType == typeof(TBrush)); layer.ChangeLayerBrush(descriptor.CreateInstance(layer, null)); return (TBrush) layer.LayerBrush!; } /// /// Adds an empty folder to the provided parent. /// /// The folder (or profile root folder) to add the folder to /// The name of the folder /// /// The position among the parent's children (0-based), defaults to the end. The first child renders on top. /// public Folder AddFolder(Folder parent, string name = "Test folder", int? order = null) { // Like the Layer constructor, the Folder constructor does not add the folder to the parent Folder folder = new(parent, name); parent.AddChild(folder, order); return folder; } /// /// Adds a test effect to the provided layer or folder. /// /// The type of test effect to use, must be registered by public TEffect AddEffect(RenderProfileElement element) where TEffect : BaseLayerEffect { LayerEffectDescriptor descriptor = _effectProvider.LayerEffectDescriptors.Single(d => d.LayerEffectType == typeof(TEffect)); TEffect effect = (TEffect) descriptor.CreateInstance(element, null); element.AddLayerEffect(effect); return effect; } /// /// Saves the profile and loads it back into a new profile instance, the way Artemis stores and activates profiles. /// /// The reloaded profile, its time starts at zero public Profile SaveAndReload(Profile profile) { return Load(Save(profile)); } /// /// Saves the profile the way Artemis stores it. /// /// A copy of the stored entity, detached from the profile public ProfileEntity Save(Profile profile) { // Mirrors ProfileService.SaveProfile profile.Save(); foreach (RenderProfileElement element in profile.GetAllRenderElements()) element.Save(); // The database stores the profile entity as JSON return JsonSerializer.Deserialize(JsonSerializer.Serialize(profile.ProfileEntity))!; } /// /// Loads a stored profile onto the devices of this harness, the way Artemis activates profiles. /// public Profile Load(ProfileEntity entity, string name = "Test profile") { // Mirrors ProfileService.ActivateProfile ProfileCategory category = new("Tests", 0); ProfileConfiguration configuration = new(category, name, "TestTube"); configuration.Entity.Profile = entity; Profile profile = new(configuration, entity); profile.PopulateLeds(_devices); configuration.Profile = profile; _profiles.Add(profile); return profile; } /// /// Advances the profile by and renders a single frame. /// public RenderFrame Render(Profile profile, TimeSpan delta) { // Layers skip updating their properties when their timeline did not move, Artemis itself never renders a zero delta if (delta <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(delta), delta, "Frames must advance time, layers don't update their properties on a zero delta"); TimeSpan time = GetElapsed(profile) + delta; RenderFrame frame = RenderSurface(time, canvas => { profile.Update(delta.TotalSeconds); profile.Render(canvas, SKPointI.Empty, null); }); if (profile.Exceptions.Count > 0) throw new AggregateException("The profile threw exceptions while rendering", profile.Exceptions); _elapsed[profile] = time; return frame; } /// /// Renders a frame by drawing directly onto the canvas instead of rendering a profile, for testing the render pipeline /// itself. Like a profile, the drawing uses surface coordinates, the canvas is already scaled to the render scale. /// public RenderFrame Render(Action draw) { return RenderSurface(TimeSpan.Zero, draw); } private RenderFrame RenderSurface(TimeSpan time, Action draw) { _draw = draw; _renderExceptions.Clear(); try { // Synchronously runs SurfaceManager's update which calls back into Render and PostRender below _surfaceManager.Surface.Update(true); } finally { _draw = null; } if (_renderExceptions.Count > 0) throw new AggregateException("The surface threw exceptions while rendering", _renderExceptions); // LEDs that were not sent a color this frame keep showing their previous color, like real hardware Dictionary ledColors = _devices .SelectMany(d => d.Leds) .ToDictionary(l => l, l => _deviceColors.TryGetValue(l.RgbLed, out SKColor color) ? color : SKColors.Transparent); SKBitmap canvas = _lastCanvas ?? throw new InvalidOperationException("The surface did not render a frame"); _lastCanvas = null; return new RenderFrame(time, canvas, ledColors); } /// /// Renders the profile at each of the provided timestamps, advancing time between them. /// /// Timestamps are the total time the harness rendered the profile for, each must be later than the time the /// profile is currently at. /// /// public RenderTimeline RenderTimeline(Profile profile, params TimeSpan[] timestamps) { List frames = []; foreach (TimeSpan timestamp in timestamps.Order()) frames.Add(Render(profile, timestamp - GetElapsed(profile))); return new RenderTimeline(frames); } /// /// Gets the total time the harness rendered the profile for /// public TimeSpan GetElapsed(Profile profile) { return _elapsed.GetValueOrDefault(profile); } /// public void Dispose() { if (_disposed) return; _disposed = true; // Profiles subscribe to static events of Core's stores, disposing them unsubscribes them again. Otherwise every // profile of every test stays in memory and receives the store events of later tests foreach (Profile profile in _profiles) { if (!profile.Disposed) profile.Dispose(); profile.Configuration.Dispose(); } _surfaceManager.Dispose(); _plugin.Dispose(); RenderScale.SetRenderScaleMultiplier(_previousRenderScaleMultiplier); } void IRenderer.Render(SKCanvas canvas, double delta) { // Ignore the surface's wall-clock delta, the harness controls time _draw?.Invoke(canvas); } void IRenderer.PostRender(SKTexture texture) { // Ownership of the bitmap moves to the RenderFrame using SKImage snapshot = texture.Surface.Snapshot(); _lastCanvas = SKBitmap.FromImage(snapshot); } private void CaptureLedColor(Led led, Color color) { // Called by the test device when the surface flushes LEDs, these are the colors the hardware would receive _deviceColors[led] = new SKColor(color.GetR(), color.GetG(), color.GetB(), color.GetA()); } }