Tests - Add Artemis.Core and profile editor test suites (#907)

This commit is contained in:
RobertBeekman authored and GitHub committed 2026-10-20 09:22:10 +02:00
1 parent 6e7fae16d2
commit 504845c7d2
168 files changed
+22914 -7

No files matched your search

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Platforms>x64</Platforms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Using Include="Shouldly" />
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Artemis.Core\Artemis.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Infrastructure/Plugins/DeviceProviders/Layouts/**" CopyToOutputDirectory="PreserveNewest" Link="TestLayouts/%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DryIoc.Microsoft.DependencyInjection" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
<PackageReference Include="Shouldly" />
<PackageReference Include="SkiaSharp" />
<!-- SkiaSharp only ships native libraries for Windows and macOS, this adds the Linux ones so the tests run on Linux -->
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
</Project>
@@ -0,0 +1,241 @@
using Artemis.Core.ColorScience;
using Artemis.Core.Tests.Infrastructure;
using SkiaSharp;
namespace Artemis.Core.Tests.ColorScience;
public class ColorQuantizerTests
{
// One color for each type, right on the target lightness and well within the saturation limits of that type
private static readonly SKColor Vibrant = SKColor.FromHsl(0, 80, 50);
private static readonly SKColor LightVibrant = SKColor.FromHsl(120, 80, 74);
private static readonly SKColor DarkVibrant = SKColor.FromHsl(240, 80, 26);
private static readonly SKColor Muted = SKColor.FromHsl(60, 20, 50);
private static readonly SKColor LightMuted = SKColor.FromHsl(180, 20, 74);
private static readonly SKColor DarkMuted = SKColor.FromHsl(300, 20, 26);
private static SKColor[] AllTypes => [Vibrant, LightVibrant, DarkVibrant, Muted, LightMuted, DarkMuted];
#region Quantize
[Fact]
public void Quantize_FindsTheDistinctColors()
{
SKColor[] colors = [..Enumerable.Repeat(SKColors.Red, 8), ..Enumerable.Repeat(SKColors.Blue, 8)];
SKColor[] result = ColorQuantizer.Quantize(colors, 2);
result.ShouldBe([SKColors.Red, SKColors.Blue], ignoreOrder: true);
}
[Fact]
public void Quantize_ToASingleColorAveragesTheColors()
{
SKColor[] colors = [..Enumerable.Repeat(SKColors.Red, 8), ..Enumerable.Repeat(SKColors.Blue, 8)];
SKColor[] result = ColorQuantizer.Quantize(colors, 1);
result.Length.ShouldBe(1);
result[0].ShouldBeColor(new SKColor(127, 0, 127), 1);
}
[Fact]
public void Quantize_GroupsSimilarColors()
{
SKColor[] colors =
[
new(250, 0, 0), new(255, 5, 0), new(245, 0, 5), new(255, 0, 0),
new(0, 0, 250), new(5, 0, 255), new(0, 5, 245), new(0, 0, 255)
];
SKColor[] result = ColorQuantizer.Quantize(colors, 2).OrderByDescending(c => c.Red).ToArray();
result[0].ShouldBeColor(new SKColor(251, 1, 1), 2);
result[1].ShouldBeColor(new SKColor(1, 1, 251), 2);
}
[Theory]
[InlineData(0)]
[InlineData(3)]
[InlineData(6)]
public void Quantize_OnlyAcceptsPowersOfTwo(int amount)
{
SKColor[] colors = Enumerable.Repeat(SKColors.Red, 16).ToArray();
Should.Throw<ArgumentException>(() => ColorQuantizer.Quantize(colors, amount)).ParamName.ShouldBe("amount");
}
[Fact]
public void QuantizeSplit_DoublesTheColorsWithEverySplit()
{
SKColor[] colors = Enumerable.Range(0, 64).Select(i => new SKColor((byte) (i * 4), 0, 0)).ToArray();
ColorQuantizer.QuantizeSplit(colors, 0).Length.ShouldBe(1);
ColorQuantizer.QuantizeSplit(colors, 1).Length.ShouldBe(2);
ColorQuantizer.QuantizeSplit(colors, 3).Length.ShouldBe(8);
}
[Fact]
public void QuantizeSplit_NeedsAtLeastAsManyColorsAsItReturns()
{
SKColor[] colors = Enumerable.Repeat(SKColors.Red, 7).ToArray();
Should.Throw<ArgumentException>(() => ColorQuantizer.QuantizeSplit(colors, 3));
}
#endregion
#region Color variations
[Fact]
public void FindColorVariation_FindsEachTypeOfColor()
{
ColorQuantizer.FindColorVariation(AllTypes, ColorType.Vibrant).ShouldBe(Vibrant);
ColorQuantizer.FindColorVariation(AllTypes, ColorType.LightVibrant).ShouldBe(LightVibrant);
ColorQuantizer.FindColorVariation(AllTypes, ColorType.DarkVibrant).ShouldBe(DarkVibrant);
ColorQuantizer.FindColorVariation(AllTypes, ColorType.Muted).ShouldBe(Muted);
ColorQuantizer.FindColorVariation(AllTypes, ColorType.LightMuted).ShouldBe(LightMuted);
ColorQuantizer.FindColorVariation(AllTypes, ColorType.DarkMuted).ShouldBe(DarkMuted);
}
[Fact]
public void FindColorVariation_PrefersTheColorClosestToTheTarget()
{
SKColor closer = SKColor.FromHsl(0, 90, 50);
SKColor further = SKColor.FromHsl(0, 50, 60);
ColorQuantizer.FindColorVariation([further, closer], ColorType.Vibrant).ShouldBe(closer);
}
[Fact]
public void FindColorVariation_IsEmptyWhenNoColorFits()
{
ColorQuantizer.FindColorVariation([Muted, LightMuted, DarkMuted], ColorType.Vibrant).ShouldBe(SKColor.Empty);
ColorQuantizer.FindColorVariation([], ColorType.Vibrant).ShouldBe(SKColor.Empty);
}
[Fact]
public void FindColorVariation_IgnoringTheLimitsAlwaysFindsAColor()
{
ColorQuantizer.FindColorVariation([Muted, DarkMuted], ColorType.Vibrant, true).ShouldBe(Muted);
}
[Fact]
public void FindColorVariation_FullySaturatedColorsAreNeverVibrant()
{
// Pinned as-is: a color only counts as vibrant when its saturation is below the maximum of 100%, so pure colors like
// red are skipped. The same goes for grays, a color only counts as muted when it has more than 0% saturation
ColorQuantizer.FindColorVariation([SKColors.Red], ColorType.Vibrant).ShouldBe(SKColor.Empty);
ColorQuantizer.FindColorVariation([SKColors.Gray], ColorType.Muted).ShouldBe(SKColor.Empty);
ColorQuantizer.FindColorVariation([SKColors.Red], ColorType.Vibrant, true).ShouldBe(SKColors.Red);
ColorQuantizer.FindColorVariation([SKColors.Gray], ColorType.Muted, true).ShouldBe(SKColors.Gray);
}
[Fact]
public void FindAllColorVariations_FillsEveryType()
{
ColorSwatch swatch = ColorQuantizer.FindAllColorVariations(AllTypes);
swatch.ShouldBe(new ColorSwatch
{
Vibrant = Vibrant,
LightVibrant = LightVibrant,
DarkVibrant = DarkVibrant,
Muted = Muted,
LightMuted = LightMuted,
DarkMuted = DarkMuted
});
}
[Fact]
public void FindAllColorVariations_LeavesTypesWithoutAFittingColorEmpty()
{
ColorSwatch swatch = ColorQuantizer.FindAllColorVariations([Vibrant, DarkMuted]);
swatch.Vibrant.ShouldBe(Vibrant);
swatch.DarkMuted.ShouldBe(DarkMuted);
swatch.LightVibrant.ShouldBe(SKColor.Empty);
swatch.DarkVibrant.ShouldBe(SKColor.Empty);
swatch.Muted.ShouldBe(SKColor.Empty);
swatch.LightMuted.ShouldBe(SKColor.Empty);
}
[Fact]
public void FindAllColorVariations_IgnoringTheLimitsFillsEveryType()
{
ColorSwatch swatch = ColorQuantizer.FindAllColorVariations([Vibrant], true);
swatch.ShouldBe(new ColorSwatch
{
Vibrant = Vibrant,
LightVibrant = Vibrant,
DarkVibrant = Vibrant,
Muted = Vibrant,
LightMuted = Vibrant,
DarkMuted = Vibrant
});
}
[Fact]
public void ColorSwatch_ToStringIsTheTypeName()
{
// The data model viewer shows this instead of every color
new ColorSwatch {Vibrant = Vibrant}.ToString().ShouldBe(typeof(ColorSwatch).FullName);
}
#endregion
#region Gradients from images
[Fact]
public void GetGradientFromImage_BuildsAnEvenlySpacedGradientOfTheImagesColorTypes()
{
using SKBitmap bitmap = CreateImage(AllTypes);
ColorGradient gradient = ColorQuantizer.GetGradientFromImage(bitmap);
gradient.Count.ShouldBe(6);
gradient.Select(s => s.Position).ShouldBe([0f, 0.2f, 0.4f, 0.6f, 0.8f, 1f], 0.0001f);
gradient.Select(s => s.Color).ShouldBe(AllTypes, ignoreOrder: true);
}
[Fact]
public void GetGradientFromImage_SortsTheColorsStartingFromBlack()
{
using SKBitmap bitmap = CreateImage(AllTypes);
ColorGradient gradient = ColorQuantizer.GetGradientFromImage(bitmap);
SKColor[] sorted = AllTypes;
ColorSorter.Sort(sorted, SKColors.Black);
gradient.Colors.ShouldBe(sorted);
}
[Fact]
public void GetGradientFromImage_UsesEmptyColorsForMissingTypes()
{
using SKBitmap bitmap = CreateImage([Vibrant, DarkVibrant]);
ColorGradient gradient = ColorQuantizer.GetGradientFromImage(bitmap);
gradient.Count.ShouldBe(6);
gradient.Count(s => s.Color == SKColor.Empty).ShouldBe(4);
gradient.Colors.ShouldContain(Vibrant);
gradient.Colors.ShouldContain(DarkVibrant);
}
/// <summary>
/// Creates a 16x16 image, the smallest image the gradient can be taken from, filled with bands of the given colors
/// </summary>
private static SKBitmap CreateImage(SKColor[] colors)
{
SKBitmap bitmap = new(16, 16);
for (int y = 0; y < 16; y++)
for (int x = 0; x < 16; x++)
bitmap.SetPixel(x, y, colors[(y * 16 + x) * colors.Length / 256]);
return bitmap;
}
#endregion
}
@@ -0,0 +1,142 @@
using Artemis.Core.ColorScience;
using SkiaSharp;
namespace Artemis.Core.Tests.ColorScience;
public class ColorSorterTests
{
[Fact]
public void Sort_StartsClosestToTheStartColorAndWalksToTheNearestColor()
{
SKColor[] colors = [SKColors.White, new(128, 128, 128), SKColors.Black, new(64, 64, 64)];
ColorSorter.Sort(colors, SKColors.Black);
colors.ShouldBe([SKColors.Black, new SKColor(64, 64, 64), new SKColor(128, 128, 128), SKColors.White]);
}
[Fact]
public void Sort_FromWhiteStartsAtTheLightEnd()
{
SKColor[] colors = [SKColors.Black, new(128, 128, 128), SKColors.White, new(64, 64, 64)];
ColorSorter.Sort(colors, SKColors.White);
colors.ShouldBe([SKColors.White, new SKColor(128, 128, 128), new SKColor(64, 64, 64), SKColors.Black]);
}
[Fact]
public void Sort_StartsFromBlackByDefault()
{
SKColor[] colors = [SKColors.White, SKColors.Black];
ColorSorter.Sort(colors);
colors.ShouldBe([SKColors.Black, SKColors.White]);
}
[Fact]
public void Sort_FollowsTheNearestColorRatherThanTheStartColor()
{
// Grays with a lightness of about 60, 35 and 75, sorted from a gray of about 50. The darker gray is closer to the
// start, but after the 60 gray the 75 gray is closer, so the sorter picks that one first
SKColor gray60 = new(145, 145, 145);
SKColor gray35 = new(82, 82, 82);
SKColor gray75 = new(184, 184, 184);
SKColor[] colors = [gray35, gray75, gray60];
ColorSorter.Sort(colors, new SKColor(119, 119, 119));
colors.ShouldBe([gray60, gray75, gray35]);
}
[Fact]
public void Sort_KeepsDuplicates()
{
SKColor[] colors = [SKColors.White, SKColors.Black, SKColors.White, SKColors.Black];
ColorSorter.Sort(colors, SKColors.Black);
colors.ShouldBe([SKColors.Black, SKColors.Black, SKColors.White, SKColors.White]);
}
[Fact]
public void Sort_HandlesManyColors()
{
// Over 1024 colors the sorter rents a buffer instead of using the stack
Random random = new(42);
SKColor[] colors = Enumerable.Range(0, 2000).Select(_ => (byte) random.Next(256)).Select(v => new SKColor(v, v, v)).ToArray();
ColorSorter.Sort(colors, SKColors.Black);
colors.Select(c => c.Red).ShouldBeInOrder(SortDirection.Ascending);
colors.Length.ShouldBe(2000);
}
[Fact]
public void Sort_OfNothingDoesNothing()
{
SKColor[] colors = [];
ColorSorter.Sort(colors, SKColors.Black);
colors.ShouldBeEmpty();
}
[Fact]
public void GetColorDifference_IsZeroForTheSameColor()
{
ColorSorter.GetColorDifference(SKColors.Orange, SKColors.Orange).ShouldBe(0f);
}
[Fact]
public void GetColorDifference_BetweenBlackAndWhiteIsTheFullLightnessRange()
{
ColorSorter.GetColorDifference(SKColors.Black, SKColors.White).ShouldBe(100f, 0.1f);
}
[Fact]
public void GetColorDifference_IsSmallerForSimilarColors()
{
float similar = ColorSorter.GetColorDifference(SKColors.Red, new SKColor(230, 20, 20));
float different = ColorSorter.GetColorDifference(SKColors.Red, SKColors.Blue);
similar.ShouldBeLessThan(different);
similar.ShouldBeGreaterThan(0f);
}
[Fact]
public void GetColorDifference_DependsOnWhichColorComesFirst()
{
// The formula Artemis uses (CIE94) weighs the difference by how colorful the first color is, so swapping the colors
// gives a different answer
float redToGray = ColorSorter.GetColorDifference(SKColors.Red, SKColors.Gray);
float grayToRed = ColorSorter.GetColorDifference(SKColors.Gray, SKColors.Red);
redToGray.ShouldBeLessThan(grayToRed);
}
[Fact]
public void LabColor_HasTheExpectedLightness()
{
LabColor white = new(SKColors.White);
LabColor black = new(SKColors.Black);
LabColor gray = new(new SKColor(119, 119, 119));
white.L.ShouldBe(100f, 0.1f);
white.A.ShouldBe(0f, 0.1f);
white.B.ShouldBe(0f, 0.1f);
black.L.ShouldBe(0f, 0.1f);
gray.L.ShouldBe(50f, 0.5f);
}
[Fact]
public void LabColor_PlacesColorsOnTheirSide()
{
// In Lab, A runs from green (negative) to red (positive) and B from blue (negative) to yellow (positive)
new LabColor(SKColors.Red).A.ShouldBeGreaterThan(50f);
new LabColor(SKColors.Lime).A.ShouldBeLessThan(-50f);
new LabColor(SKColors.Blue).B.ShouldBeLessThan(-50f);
new LabColor(SKColors.Yellow).B.ShouldBeGreaterThan(50f);
}
}
@@ -0,0 +1,690 @@
using Artemis.Core.Internal;
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Plugins.Modules;
using Artemis.Core.Tests.Infrastructure.Plugins.Nodes;
using Artemis.Core.Tests.Infrastructure.Rendering;
using Artemis.Core.Tests.Infrastructure.VisualScripting;
using Artemis.Storage.Entities.Profile.Conditions;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Conditions;
/// <summary>
/// Event conditions on a layer on the A key, with a main segment of one second and no start or end segment
/// </summary>
public class EventConditionTests : RenderTestBase
{
private readonly TestModule _module;
private readonly DataModelTestNodeProvider _nodes;
public EventConditionTests()
{
TestPlugin plugin = Track(new TestPlugin());
_module = plugin.AddFeature(new TestModule());
_nodes = plugin.AddFeature(new DataModelTestNodeProvider());
}
private TestDataModel DataModel => _module.DataModel;
#region Play
[Fact]
public void Layer_StaysOffUntilTheEventTriggers()
{
(Layer layer, _) = AddLayer();
using RenderFrame frame = Render(layer.Profile);
layer.DisplayConditionMet.ShouldBeFalse();
ShouldBeOff(frame);
}
[Fact]
public void Play_PlaysTheTimelineOnceWhenTheEventTriggers()
{
(Layer layer, EventCondition condition) = AddLayer();
DataModel.Event.Trigger();
using RenderFrame triggered = Render(layer.Profile);
bool metWhenTriggered = condition.IsMet;
using RenderFrame playing = Render(layer.Profile, 0.5);
bool metWhilePlaying = condition.IsMet;
using RenderFrame finished = Render(layer.Profile, 0.5);
// The condition is only met in the frame the event triggered, the timeline then plays to the end
metWhenTriggered.ShouldBeTrue();
metWhilePlaying.ShouldBeFalse();
ShouldBeOn(triggered);
ShouldBeOn(playing);
ShouldBeOff(finished);
layer.Timeline.IsFinished.ShouldBeTrue();
}
[Fact]
public void Play_TriggeringTwiceBetweenFrames_PlaysOnce()
{
(Layer layer, _) = AddLayer(overlapMode: EventOverlapMode.Restart);
DataModel.Event.Trigger();
DataModel.Event.Trigger();
using (Render(layer.Profile, 0.5))
{
}
using RenderFrame frame = Render(layer.Profile, 0.6);
ShouldBeOff(frame);
}
[Fact]
public void Play_EventTriggeredBeforeTheConditionExisted_StillPlaysTheLayer()
{
DataModel.Event.Trigger();
(Layer layer, _) = AddLayer();
using RenderFrame frame = Render(layer.Profile);
// Pinned as-is: the condition starts out having seen no triggers at all, so a trigger from before it was created
// (for example long before the profile was activated) still counts as new and plays the layer once. Looks like
// the condition should only react to triggers that happen after it started watching.
ShouldBeOn(frame);
}
[Fact]
public void Play_Restart_StartsOverWhenTheEventTriggersAgain()
{
(Layer layer, _) = AddLayer(overlapMode: EventOverlapMode.Restart);
DataModel.Event.Trigger();
using (Render(layer.Profile, 0.6))
{
}
DataModel.Event.Trigger();
using (Render(layer.Profile))
{
}
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(0.1));
using RenderFrame frame = Render(layer.Profile, 0.5);
ShouldBeOn(frame);
}
[Fact]
public void Play_Ignore_KeepsPlayingWhenTheEventTriggersAgain()
{
(Layer layer, _) = AddLayer(overlapMode: EventOverlapMode.Ignore);
DataModel.Event.Trigger();
using (Render(layer.Profile, 0.6))
{
}
DataModel.Event.Trigger();
using (Render(layer.Profile))
{
}
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(0.7));
using RenderFrame frame = Render(layer.Profile, 0.4);
ShouldBeOff(frame);
}
[Fact]
public void Play_Ignore_PlaysAgainOnceTheTimelineFinished()
{
(Layer layer, _) = AddLayer(overlapMode: EventOverlapMode.Ignore);
DataModel.Event.Trigger();
using (Render(layer.Profile, 1.5))
{
}
DataModel.Event.Trigger();
using RenderFrame frame = Render(layer.Profile);
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(0.1));
ShouldBeOn(frame);
}
[Fact]
public void Play_Copy_PlaysACopyOnTopWhenTheEventTriggersAgain()
{
// The color goes from red to blue over the second, so the color tells how far along a timeline is
(Layer layer, _) = AddLayer(overlapMode: EventOverlapMode.Copy, fadeToBlue: true);
// Copies are created from what was last saved
Harness.Save(layer.Profile);
DataModel.Event.Trigger();
using RenderFrame first = Render(layer.Profile, 0.8);
DataModel.Event.Trigger();
using RenderFrame copied = Render(layer.Profile);
TimeSpan positionWhenCopied = layer.Timeline.Position;
using RenderFrame originalFinished = Render(layer.Profile, 0.2);
using RenderFrame copyFinished = Render(layer.Profile, 0.8);
first.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(ColorAt(0.8));
// The original is at 0.9 seconds, the copy is drawn on top at 0.1 seconds
positionWhenCopied.ShouldBe(TimeSpan.FromSeconds(0.9));
copied.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(ColorAt(0.1));
originalFinished.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(ColorAt(0.3));
ShouldBeOff(copyFinished);
}
[Fact]
public void Play_Copy_KeepsTheLayerEnabledWhileACopyPlays()
{
(Layer layer, _) = AddLayer(overlapMode: EventOverlapMode.Copy);
Harness.Save(layer.Profile);
DataModel.Event.Trigger();
using (Render(layer.Profile, 0.8))
{
}
DataModel.Event.Trigger();
using (Render(layer.Profile))
{
}
using (Render(layer.Profile, 0.5))
{
}
layer.Timeline.IsFinished.ShouldBeTrue();
layer.Enabled.ShouldBeTrue();
// Once the copy finished it is removed on the next frame and the layer is disabled the frame after
using (Render(layer.Profile, 0.5))
{
}
using (Render(layer.Profile))
{
}
using (Render(layer.Profile))
{
}
layer.Enabled.ShouldBeFalse();
}
[Fact]
public void Play_Copy_OnAFolder_KeepsPlaying()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)]);
UseEventCondition(folder, "Event", overlapMode: EventOverlapMode.Copy);
DataModel.Event.Trigger();
using (Render(profile, 0.6))
{
}
DataModel.Event.Trigger();
using (Render(profile))
{
}
// Only layers can be copied, a folder neither copies nor restarts
folder.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(0.7));
}
#endregion
#region Toggle
[Fact]
public void Toggle_KeepsRepeatingUntilTheEventTriggersAgain()
{
(Layer layer, EventCondition condition) = AddLayer(EventTriggerMode.Toggle);
DataModel.Event.Trigger();
using RenderFrame on = Render(layer.Profile);
using RenderFrame stillOn = Render(layer.Profile, 1.5);
condition.IsMet.ShouldBeTrue();
ShouldBeOn(on);
ShouldBeOn(stillOn);
// Wrapped around the main segment rather than finishing
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(0.5));
}
[Fact]
public void Toggle_Finish_FinishesTheCurrentRunWhenToggledOff()
{
(Layer layer, EventCondition condition) = AddLayer(EventTriggerMode.Toggle, toggleOffMode: EventToggleOffMode.Finish);
DataModel.Event.Trigger();
using (Render(layer.Profile, 0.5))
{
}
DataModel.Event.Trigger();
using RenderFrame toggledOff = Render(layer.Profile);
using RenderFrame finished = Render(layer.Profile, 0.5);
condition.IsMet.ShouldBeFalse();
ShouldBeOn(toggledOff);
ShouldBeOff(finished);
}
[Fact]
public void Toggle_SkipToEnd_StopsRightAwayWhenToggledOff()
{
(Layer layer, _) = AddLayer(EventTriggerMode.Toggle, toggleOffMode: EventToggleOffMode.SkipToEnd);
DataModel.Event.Trigger();
using (Render(layer.Profile, 0.5))
{
}
DataModel.Event.Trigger();
using RenderFrame toggledOff = Render(layer.Profile);
ShouldBeOff(toggledOff);
layer.Timeline.IsFinished.ShouldBeTrue();
}
[Fact]
public void Toggle_TriggeringTwiceBetweenFrames_TogglesOnce()
{
(Layer layer, EventCondition condition) = AddLayer(EventTriggerMode.Toggle);
DataModel.Event.Trigger();
DataModel.Event.Trigger();
using RenderFrame frame = Render(layer.Profile);
condition.IsMet.ShouldBeTrue();
ShouldBeOn(frame);
}
[Fact]
public void Toggle_TogglesBackOnAfterBeingToggledOff()
{
(Layer layer, EventCondition condition) = AddLayer(EventTriggerMode.Toggle);
DataModel.Event.Trigger();
using (Render(layer.Profile))
{
}
DataModel.Event.Trigger();
using (Render(layer.Profile, 1.5))
{
}
DataModel.Event.Trigger();
using RenderFrame frame = Render(layer.Profile);
condition.IsMet.ShouldBeTrue();
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(0.1));
ShouldBeOn(frame);
}
#endregion
#region Event arguments
[Fact]
public void StartNode_OffersTheEventArguments()
{
(_, EventCondition condition) = AddLayer(eventPath: "EventWithArgs");
INode startNode = condition.GetStartNode();
startNode.ShouldBeOfType<EventConditionEventStartNode>();
IPin value = startNode.Pins.ShouldHaveSingleItem();
value.Name.ShouldBe("Value");
value.Type.ShouldBe(typeof(Numeric));
condition.Script.Nodes.ShouldContain(startNode);
}
[Fact]
public void Script_DecidesWhetherATriggerCounts()
{
(Layer layer, EventCondition condition) = AddLayer(eventPath: "EventWithArgs");
TestGreaterThanNode greaterThan = RequireValueAbove(condition, 3);
DataModel.EventWithArgs.Trigger(new TestEventArgs {Value = 1});
using RenderFrame ignored = Render(layer.Profile);
DataModel.EventWithArgs.Trigger(new TestEventArgs {Value = 5});
using RenderFrame counted = Render(layer.Profile);
ShouldBeOff(ignored);
ShouldBeOn(counted);
greaterThan.LastInput.ShouldBe(5);
}
[Fact]
public void Script_IsNotRunWithoutANewTrigger()
{
(Layer layer, EventCondition condition) = AddLayer(eventPath: "EventWithArgs");
TestGreaterThanNode greaterThan = RequireValueAbove(condition, 3);
DataModel.EventWithArgs.Trigger(new TestEventArgs {Value = 1});
using (Render(layer.Profile))
{
}
// Changing the arguments of the last trigger doesn't make it count
DataModel.EventWithArgs.LastEventArguments!.Value = 5;
using RenderFrame frame = Render(layer.Profile);
ShouldBeOff(frame);
greaterThan.LastInput.ShouldBe(1);
}
[Fact]
public void WithoutAScript_EveryTriggerCounts()
{
(Layer layer, _) = AddLayer(eventPath: "EventWithArgs");
DataModel.EventWithArgs.Trigger(new TestEventArgs {Value = 1});
using RenderFrame frame = Render(layer.Profile);
ShouldBeOn(frame);
}
[Fact]
public void WithoutAnEvent_TheLayerNeverPlays()
{
(Layer layer, EventCondition condition) = AddLayer();
condition.EventPath = null;
DataModel.Event.Trigger();
using RenderFrame frame = Render(layer.Profile);
condition.IsMet.ShouldBeFalse();
ShouldBeOff(frame);
}
#endregion
#region Values instead of events
[Fact]
public void PathToAValue_PlaysWhenTheValueChanges()
{
(Layer layer, EventCondition condition) = AddLayer(eventPath: "Integer");
using (Render(layer.Profile, 1.5))
{
}
using RenderFrame unchanged = Render(layer.Profile);
DataModel.Integer = 5;
using RenderFrame changed = Render(layer.Profile);
condition.GetStartNode().ShouldBeOfType<EventConditionValueChangedStartNode>();
ShouldBeOff(unchanged);
ShouldBeOn(changed);
}
[Fact]
public void PathToAValue_PlaysOnTheFirstFrame()
{
(Layer layer, _) = AddLayer(eventPath: "Integer");
using RenderFrame frame = Render(layer.Profile);
// Pinned as-is: the last seen value starts out empty, so the first frame always sees the value as changed (0 is
// not empty) and plays the layer even though nothing changed.
ShouldBeOn(frame);
}
[Fact]
public void PathToAValue_OffersTheNewAndOldValue()
{
(_, EventCondition condition) = AddLayer(eventPath: "Integer");
EventConditionValueChangedStartNode startNode = condition.GetStartNode().ShouldBeOfType<EventConditionValueChangedStartNode>();
startNode.NewValue.Type.ShouldBe(typeof(Numeric));
startNode.OldValue.Type.ShouldBe(typeof(Numeric));
condition.Script.Nodes.ShouldContain(startNode);
condition.Script.Nodes.OfType<EventConditionEventStartNode>().ShouldBeEmpty();
}
[Fact]
public void PathToAValue_LetsTheScriptLookAtTheNewValue()
{
(Layer layer, EventCondition condition) = AddLayer(eventPath: "Integer");
EventConditionValueChangedStartNode startNode = (EventConditionValueChangedStartNode) condition.GetStartNode();
TestGreaterThanNode greaterThan = _nodes.AddNode<TestGreaterThanNode>(condition.Script);
greaterThan.Threshold = 3;
startNode.NewValue.ConnectTo(greaterThan.Input);
condition.Script.Nodes.OfType<ExitNode<bool>>().Single().Input.ConnectTo(greaterThan.Output);
using (Render(layer.Profile, 1.5))
{
}
DataModel.Integer = 1;
using RenderFrame ignored = Render(layer.Profile);
DataModel.Integer = 5;
using RenderFrame counted = Render(layer.Profile);
ShouldBeOff(ignored);
ShouldBeOn(counted);
}
#endregion
#region Saving and loading
[Fact]
public void Condition_SurvivesSavingAndLoading()
{
(Layer layer, _) = AddLayer(EventTriggerMode.Toggle, EventOverlapMode.Ignore, EventToggleOffMode.SkipToEnd, eventPath: "EventWithArgs");
Profile reloaded = Harness.SaveAndReload(layer.Profile);
EventCondition condition = reloaded.GetAllLayers().Single().DisplayCondition.ShouldBeOfType<EventCondition>();
condition.TriggerMode.ShouldBe(EventTriggerMode.Toggle);
condition.OverlapMode.ShouldBe(EventOverlapMode.Ignore);
condition.ToggleOffMode.ShouldBe(EventToggleOffMode.SkipToEnd);
DataModelPath eventPath = condition.EventPath.ShouldNotBeNull();
eventPath.Path.ShouldBe("EventWithArgs");
eventPath.Target.ShouldBeSameAs(DataModel);
condition.GetStartNode().Pins.ShouldHaveSingleItem().Name.ShouldBe("Value");
}
[Fact]
public void ScriptLookingAtTheEventArguments_SurvivesSavingAndLoading()
{
(Layer layer, EventCondition condition) = AddLayer(eventPath: "EventWithArgs");
RequireValueAbove(condition, 3);
Profile reloaded = Harness.SaveAndReload(layer.Profile);
EventCondition reloadedCondition = (EventCondition) reloaded.GetAllLayers().Single().DisplayCondition;
DataModel.EventWithArgs.Trigger(new TestEventArgs {Value = 1});
using RenderFrame ignored = Render(reloaded);
DataModel.EventWithArgs.Trigger(new TestEventArgs {Value = 5});
using RenderFrame counted = Render(reloaded);
reloadedCondition.Script.Nodes.OfType<TestGreaterThanNode>().Single().Threshold.ShouldBe(3);
ShouldBeOff(ignored);
ShouldBeOn(counted);
}
[Fact]
public void ScriptOfAValuePath_SurvivesSavingAndLoading()
{
(Layer layer, _) = AddLayer(eventPath: "Integer");
Profile reloaded = Harness.SaveAndReload(layer.Profile);
EventCondition condition = (EventCondition) reloaded.GetAllLayers().Single().DisplayCondition;
condition.GetStartNode().ShouldBeOfType<EventConditionValueChangedStartNode>();
condition.Script.Nodes.ShouldContain(condition.GetStartNode());
}
[Fact]
public void EmptyScript_IsNotSaved()
{
(_, EventCondition condition) = AddLayer();
condition.Save();
EventConditionEntity entity = condition.Entity.ShouldBeOfType<EventConditionEntity>();
entity.Script.ShouldBeNull();
entity.EventPath.ShouldNotBeNull().Path.ShouldBe("Event");
}
[Fact]
public void ScriptWithOtherNodes_IsSaved()
{
(_, EventCondition condition) = AddLayer();
_nodes.AddNode<TestGreaterThanNode>(condition.Script);
condition.Save();
((EventConditionEntity) condition.Entity).Script.ShouldNotBeNull();
}
[Fact]
public void ConditionWithoutAnEvent_SurvivesSavingAndLoading()
{
(Layer layer, EventCondition condition) = AddLayer();
condition.EventPath = null;
Profile reloaded = Harness.SaveAndReload(layer.Profile);
EventCondition reloadedCondition = reloaded.GetAllLayers().Single().DisplayCondition.ShouldBeOfType<EventCondition>();
reloadedCondition.EventPath.ShouldBeNull();
reloadedCondition.GetStartNode().ShouldBeOfType<EventConditionEventStartNode>();
}
#endregion
#region Other
[Fact]
public void OverridingTheTimeline_MovesItToThePosition()
{
(Layer layer, EventCondition condition) = AddLayer();
condition.OverrideTimeline(TimeSpan.FromSeconds(1.5));
layer.Timeline.IsOverridden.ShouldBeTrue();
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(1.5));
}
[Fact]
public void OverridingTheTimeline_InToggleMode_WrapsAroundTheMainSegment()
{
(Layer layer, EventCondition condition) = AddLayer(EventTriggerMode.Toggle);
condition.OverrideTimeline(TimeSpan.FromSeconds(1.5));
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(0.5));
}
[Fact]
public void Condition_DependsOnTheModuleOfTheEventAndTheNodesInItsScript()
{
(_, EventCondition condition) = AddLayer(eventPath: "EventWithArgs");
RequireValueAbove(condition, 3);
condition.GetFeatureDependencies().ShouldContain(_module);
condition.GetFeatureDependencies().ShouldContain(_nodes);
}
[Fact]
public void Condition_UsesItsScriptAsNodeScript()
{
(Layer layer, EventCondition condition) = AddLayer();
condition.NodeScript.ShouldBeSameAs(condition.Script);
condition.ProfileElement.ShouldBeSameAs(layer);
condition.Script.Name.ShouldBe("Activate Layer");
}
[Fact]
public void DisposingTheCondition_DisposesTheEventPath()
{
(_, EventCondition condition) = AddLayer();
DataModelPath eventPath = condition.EventPath!;
condition.Dispose();
eventPath.IsValid.ShouldBeFalse();
DataModel.ActivePaths.ShouldNotContain(eventPath);
}
[Fact]
public void ChangingASetting_RaisesPropertyChanged()
{
(_, EventCondition condition) = AddLayer();
List<string?> changed = [];
condition.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
condition.TriggerMode = EventTriggerMode.Toggle;
condition.OverlapMode = EventOverlapMode.Copy;
condition.ToggleOffMode = EventToggleOffMode.SkipToEnd;
changed.ShouldBe([nameof(EventCondition.TriggerMode), nameof(EventCondition.OverlapMode), nameof(EventCondition.ToggleOffMode)]);
}
#endregion
private (Layer Layer, EventCondition Condition) AddLayer(EventTriggerMode triggerMode = EventTriggerMode.Play,
EventOverlapMode overlapMode = EventOverlapMode.Restart,
EventToggleOffMode toggleOffMode = EventToggleOffMode.Finish,
string eventPath = "Event",
bool fadeToBlue = false)
{
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(Harness.CreateProfile().GetRootFolder(), [Led(LedId.Keyboard_A)]);
if (fadeToBlue)
{
SKColorLayerProperty color = brush.Properties.Color;
color.KeyframesEnabled = true;
color.AddKeyframe(new LayerPropertyKeyframe<SKColor>(SKColors.Red, TimeSpan.Zero, Easings.Functions.Linear, color));
color.AddKeyframe(new LayerPropertyKeyframe<SKColor>(SKColors.Blue, TimeSpan.FromSeconds(1), Easings.Functions.Linear, color));
}
EventCondition condition = UseEventCondition(layer, eventPath, triggerMode, overlapMode, toggleOffMode);
return (layer, condition);
}
private EventCondition UseEventCondition(RenderProfileElement element, string eventPath,
EventTriggerMode triggerMode = EventTriggerMode.Play,
EventOverlapMode overlapMode = EventOverlapMode.Restart,
EventToggleOffMode toggleOffMode = EventToggleOffMode.Finish)
{
element.Timeline.StartSegmentLength = TimeSpan.Zero;
element.Timeline.MainSegmentLength = TimeSpan.FromSeconds(1);
element.Timeline.EndSegmentLength = TimeSpan.Zero;
EventCondition condition = new(element)
{
EventPath = new DataModelPath(DataModel, eventPath),
TriggerMode = triggerMode,
OverlapMode = overlapMode,
ToggleOffMode = toggleOffMode
};
// Like the editor does after picking an event
condition.UpdateEventNode(true);
element.DisplayCondition = condition;
return condition;
}
private TestGreaterThanNode RequireValueAbove(EventCondition condition, float threshold)
{
TestGreaterThanNode greaterThan = _nodes.AddNode<TestGreaterThanNode>(condition.Script);
greaterThan.Threshold = threshold;
condition.GetStartNode().Pins.Single(p => p.Name == "Value").ConnectTo(greaterThan.Input);
condition.Script.Nodes.OfType<ExitNode<bool>>().Single().Input.ConnectTo(greaterThan.Output);
return greaterThan;
}
private static SKColor ColorAt(double seconds)
{
return new SKColor((byte) Math.Round(255 * (1 - seconds)), 0, (byte) Math.Round(255 * seconds));
}
private void ShouldBeOn(RenderFrame frame)
{
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
private void ShouldBeOff(RenderFrame frame)
{
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
}
}
@@ -0,0 +1,521 @@
using Artemis.Core.Internal;
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Plugins.Modules;
using Artemis.Core.Tests.Infrastructure.Plugins.Nodes;
using Artemis.Core.Tests.Infrastructure.Rendering;
using Artemis.Core.Tests.Infrastructure.VisualScripting;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.DataBindings;
public class DataBindingTests : RenderTestBase
{
private readonly TestModule _module;
private readonly TestNodeProvider _nodes;
private readonly DataModelTestNodeProvider _dataModelNodes;
public DataBindingTests()
{
TestPlugin plugin = Track(new TestPlugin());
_module = plugin.AddFeature(new TestModule());
_nodes = plugin.AddFeature(new TestNodeProvider());
_dataModelNodes = plugin.AddFeature(new DataModelTestNodeProvider());
}
#region Driving layer properties
[Fact]
public void LayerProperty_OffersItsValueToDataBindings()
{
Layer layer = AddLayer();
DataBinding<float> dataBinding = layer.Transform.Opacity.DataBinding;
layer.Transform.Opacity.DataBindingsSupported.ShouldBeTrue();
layer.Transform.Opacity.HasDataBinding.ShouldBeFalse();
layer.Transform.Opacity.BaseDataBinding.ShouldBeSameAs(dataBinding);
dataBinding.LayerProperty.ShouldBeSameAs(layer.Transform.Opacity);
dataBinding.BaseLayerProperty.ShouldBeSameAs(layer.Transform.Opacity);
IDataBindingProperty property = dataBinding.Properties.ShouldHaveSingleItem();
property.DisplayName.ShouldBe("Value");
property.ValueType.ShouldBe(typeof(float));
}
[Fact]
public void LayerProperty_WithSeveralValues_OffersEachOfThem()
{
Layer layer = AddLayer();
layer.Transform.Position.DataBinding.Properties.Select(p => p.DisplayName).ShouldBe(["X", "Y"]);
}
[Fact]
public void ExitNode_HasAnInputForEachValue()
{
Layer layer = AddLayer();
DataBindingExitNode<SKPoint> exitNode = ExitNode(layer.Transform.Position.DataBinding);
exitNode.IsExitNode.ShouldBeTrue();
// Numbers come in as Numeric, like everywhere else in node scripts
exitNode.Pins.Select(p => p.Name).ShouldBe(["X", "Y"]);
exitNode.Pins.ShouldAllBe(p => p.Type == typeof(Numeric));
}
[Fact]
public void EnabledDataBinding_SetsTheValueFromItsScript()
{
Layer layer = AddLayer();
BindNumber(layer.Transform.Opacity, 50);
using RenderFrame frame = Render(layer.Profile);
layer.Transform.Opacity.HasDataBinding.ShouldBeTrue();
layer.Transform.Opacity.CurrentValue.ShouldBe(50);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
}
[Fact]
public void DisabledDataBinding_LeavesTheValueAlone()
{
Layer layer = AddLayer();
BindNumber(layer.Transform.Opacity, 50);
layer.Transform.Opacity.DataBinding.IsEnabled = false;
using RenderFrame frame = Render(layer.Profile);
layer.Transform.Opacity.CurrentValue.ShouldBe(100);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
[Fact]
public void EnabledDataBinding_WithNothingConnected_LeavesTheValueAlone()
{
Layer layer = AddLayer();
layer.Transform.Opacity.DataBinding.IsEnabled = true;
using RenderFrame frame = Render(layer.Profile);
layer.Transform.Opacity.CurrentValue.ShouldBe(100);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
[Fact]
public void DisconnectingTheScript_GivesBackTheBaseValue()
{
Layer layer = AddLayer();
TestNumericNode node = BindNumber(layer.Transform.Opacity, 50);
using (Render(layer.Profile))
{
}
node.Output.DisconnectAll();
using RenderFrame frame = Render(layer.Profile);
layer.Transform.Opacity.CurrentValue.ShouldBe(100);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
[Fact]
public void DataBinding_FollowsItsScriptEveryFrame()
{
Layer layer = AddLayer();
TestNumericNode node = BindNumber(layer.Transform.Opacity, 50);
using RenderFrame first = Render(layer.Profile);
node.Value = 0;
using RenderFrame second = Render(layer.Profile);
first.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
second.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
node.Evaluations.ShouldBe(2);
}
[Fact]
public void DataBinding_CanDriveOneOfSeveralValues()
{
Layer layer = AddLayer();
layer.Transform.Position.BaseValue = new SKPoint(1, 2);
DataBinding<SKPoint> dataBinding = layer.Transform.Position.DataBinding;
TestNumericNode node = _nodes.AddNode<TestNumericNode>(dataBinding.Script);
node.Value = 7;
node.Output.ConnectTo(ExitNode(dataBinding).Pins.Single(p => p.Name == "X"));
dataBinding.IsEnabled = true;
using (Render(layer.Profile))
{
}
layer.Transform.Position.CurrentValue.ShouldBe(new SKPoint(7, 2));
}
[Fact]
public void DataBinding_CanDriveAColor()
{
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(Harness.CreateProfile().GetRootFolder(), [Led(LedId.Keyboard_A)]);
DataBinding<SKColor> dataBinding = brush.Properties.Color.DataBinding;
TestColorNode node = _dataModelNodes.AddNode<TestColorNode>(dataBinding.Script);
node.Value = SKColors.Lime;
node.Output.ConnectTo(ExitNode(dataBinding).Pins.Single());
dataBinding.IsEnabled = true;
using RenderFrame frame = Render(layer.Profile);
ExitNode(dataBinding).Pins.Single().Type.ShouldBe(typeof(SKColor));
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Lime);
}
[Fact]
public void DataBinding_CanFollowTheDataModel()
{
Layer layer = AddLayer();
DataBinding<float> dataBinding = layer.Transform.Opacity.DataBinding;
TestDataModelNumberNode node = _dataModelNodes.AddNode<TestDataModelNumberNode>(dataBinding.Script);
using (DataModelPath path = new(_module.DataModel, "Float"))
node.UsePath(path);
node.Output.ConnectTo(ExitNode(dataBinding).Pins.Single());
dataBinding.IsEnabled = true;
_module.DataModel.Float = 50;
using RenderFrame half = Render(layer.Profile);
_module.DataModel.Float = 100;
using RenderFrame full = Render(layer.Profile);
half.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
full.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
dataBinding.GetFeatureDependencies().ShouldContain(_module);
dataBinding.GetFeatureDependencies().ShouldContain(_dataModelNodes);
}
#endregion
#region Keyframes
[Fact]
public void EnabledDataBinding_WinsFromKeyframes()
{
Layer layer = AddLayer();
AddOpacityKeyframes(layer);
BindNumber(layer.Transform.Opacity, 25);
using RenderFrame frame = Render(layer.Profile, 0.5);
layer.Transform.Opacity.CurrentValue.ShouldBe(25);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(63, 0, 0));
}
[Fact]
public void DisabledDataBinding_LetsKeyframesThrough()
{
Layer layer = AddLayer();
AddOpacityKeyframes(layer);
BindNumber(layer.Transform.Opacity, 25);
layer.Transform.Opacity.DataBinding.IsEnabled = false;
using RenderFrame frame = Render(layer.Profile, 0.5);
layer.Transform.Opacity.CurrentValue.ShouldBe(50);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
}
[Fact]
public void EnabledDataBinding_WithNothingConnected_LetsKeyframesThrough()
{
Layer layer = AddLayer();
AddOpacityKeyframes(layer);
layer.Transform.Opacity.DataBinding.IsEnabled = true;
using (Render(layer.Profile, 0.5))
{
}
layer.Transform.Opacity.CurrentValue.ShouldBe(50);
}
[Fact]
public void DataBinding_LeavesTheValuesItDoesNotDriveToTheKeyframes()
{
Layer layer = AddLayer();
SKPointLayerProperty position = layer.Transform.Position;
position.KeyframesEnabled = true;
position.AddKeyframe(new LayerPropertyKeyframe<SKPoint>(new SKPoint(0, 0), TimeSpan.Zero, Easings.Functions.Linear, position));
position.AddKeyframe(new LayerPropertyKeyframe<SKPoint>(new SKPoint(10, 10), TimeSpan.FromSeconds(1), Easings.Functions.Linear, position));
TestNumericNode node = _nodes.AddNode<TestNumericNode>(position.DataBinding.Script);
node.Value = 7;
node.Output.ConnectTo(ExitNode(position.DataBinding).Pins.Single(p => p.Name == "Y"));
position.DataBinding.IsEnabled = true;
using (Render(layer.Profile, 0.5))
{
}
position.CurrentValue.ShouldBe(new SKPoint(5, 7));
}
#endregion
#region Saving and loading
[Fact]
public void DataBinding_SurvivesSavingAndLoading()
{
Layer layer = AddLayer();
BindNumber(layer.Transform.Opacity, 50);
Profile reloaded = Harness.SaveAndReload(layer.Profile);
Layer reloadedLayer = reloaded.GetAllLayers().Single();
using RenderFrame frame = Render(reloaded);
DataBinding<float> dataBinding = reloadedLayer.Transform.Opacity.DataBinding;
dataBinding.IsEnabled.ShouldBeTrue();
dataBinding.Script.Nodes.OfType<TestNumericNode>().Single().Value.ShouldBe(50);
ExitNode(dataBinding).Pins.Single().ConnectedTo.ShouldNotBeEmpty();
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
}
[Fact]
public void DataBindingFollowingTheDataModel_SurvivesSavingAndLoading()
{
Layer layer = AddLayer();
DataBinding<float> dataBinding = layer.Transform.Opacity.DataBinding;
TestDataModelNumberNode node = _dataModelNodes.AddNode<TestDataModelNumberNode>(dataBinding.Script);
using (DataModelPath path = new(_module.DataModel, "Float"))
node.UsePath(path);
node.Output.ConnectTo(ExitNode(dataBinding).Pins.Single());
dataBinding.IsEnabled = true;
Profile reloaded = Harness.SaveAndReload(layer.Profile);
_module.DataModel.Float = 50;
using RenderFrame frame = Render(reloaded);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
}
[Fact]
public void DisabledDataBinding_StaysDisabledAfterSavingAndLoading()
{
Layer layer = AddLayer();
BindNumber(layer.Transform.Opacity, 50);
layer.Transform.Opacity.DataBinding.IsEnabled = false;
Profile reloaded = Harness.SaveAndReload(layer.Profile);
Layer reloadedLayer = reloaded.GetAllLayers().Single();
using RenderFrame frame = Render(reloaded);
reloadedLayer.Transform.Opacity.DataBinding.IsEnabled.ShouldBeFalse();
reloadedLayer.Transform.Opacity.DataBinding.Script.Nodes.OfType<TestNumericNode>().ShouldHaveSingleItem();
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
[Fact]
public void EmptyScript_IsNotSaved()
{
Layer layer = AddLayer();
DataBinding<float> dataBinding = layer.Transform.Opacity.DataBinding;
dataBinding.IsEnabled = true;
dataBinding.Save();
dataBinding.Entity.IsEnabled.ShouldBeTrue();
dataBinding.Entity.NodeScript.ShouldBeNull();
}
[Fact]
public void ScriptWithUnconnectedNodes_IsSaved()
{
Layer layer = AddLayer();
DataBinding<float> dataBinding = layer.Transform.Opacity.DataBinding;
_nodes.AddNode<TestNumericNode>(dataBinding.Script);
dataBinding.Save();
dataBinding.Entity.NodeScript.ShouldNotBeNull();
}
[Fact]
public void LoadingTheNodeScript_ReplacesTheScript()
{
Layer layer = AddLayer();
DataBinding<float> dataBinding = layer.Transform.Opacity.DataBinding;
_nodes.AddNode<TestNumericNode>(dataBinding.Script);
dataBinding.Save();
INodeScript before = dataBinding.Script;
dataBinding.LoadNodeScript();
dataBinding.Script.ShouldNotBeSameAs(before);
dataBinding.Script.Nodes.OfType<TestNumericNode>().ShouldHaveSingleItem();
}
#endregion
#region The data binding itself
[Fact]
public void EnablingAndDisabling_RaisesEvents()
{
Layer layer = AddLayer();
DataBinding<float> dataBinding = layer.Transform.Opacity.DataBinding;
List<string> raised = [];
dataBinding.DataBindingEnabled += (_, e) => raised.Add("enabled " + (e.DataBinding == dataBinding));
dataBinding.DataBindingDisabled += (_, e) => raised.Add("disabled " + (e.DataBinding == dataBinding));
dataBinding.IsEnabled = true;
dataBinding.IsEnabled = false;
raised.ShouldBe(["enabled True", "disabled True"]);
}
[Fact]
public void RegisteringAProperty_AddsAnInputToTheExitNode()
{
Layer layer = AddLayer();
DataBinding<float> dataBinding = layer.Transform.Opacity.DataBinding;
int registered = 0;
dataBinding.DataBindingPropertyRegistered += (_, _) => registered++;
DataBindingProperty<string> property = dataBinding.RegisterDataBindingProperty(() => "text", _ => { }, "Text");
registered.ShouldBe(1);
dataBinding.Properties.ShouldContain(property);
ExitNode(dataBinding).Pins.Select(p => p.Name).ShouldBe(["Value", "Text"]);
ExitNode(dataBinding).Pins.Last().Type.ShouldBe(typeof(string));
}
[Fact]
public void RegisteringAPropertyTwice_Throws()
{
Layer layer = AddLayer();
Should.Throw<ArtemisCoreException>(() => layer.Transform.Opacity.DataBinding.RegisterDataBindingProperty(() => 1f, _ => { }, "Value"));
}
[Fact]
public void ClearingProperties_RemovesTheInputsOfTheExitNode()
{
Layer layer = AddLayer();
DataBinding<float> dataBinding = layer.Transform.Opacity.DataBinding;
int cleared = 0;
dataBinding.DataBindingPropertiesCleared += (_, _) => cleared++;
dataBinding.ClearDataBindingProperties();
cleared.ShouldBe(1);
dataBinding.Properties.ShouldBeEmpty();
layer.Transform.Opacity.DataBindingsSupported.ShouldBeFalse();
ExitNode(dataBinding).Pins.ShouldBeEmpty();
}
[Fact]
public void DisposedDataBinding_CannotBeUsed()
{
Layer layer = AddLayer();
DataBinding<float> dataBinding = layer.Transform.Opacity.DataBinding;
dataBinding.IsEnabled = true;
dataBinding.Dispose();
dataBinding.IsEnabled.ShouldBeFalse();
Should.Throw<ObjectDisposedException>(() => dataBinding.Update());
Should.Throw<ObjectDisposedException>(() => dataBinding.Apply());
Should.Throw<ObjectDisposedException>(() => dataBinding.Save());
Should.Throw<ObjectDisposedException>(() => dataBinding.Load());
Should.Throw<ObjectDisposedException>(() => dataBinding.ClearDataBindingProperties());
Should.Throw<ObjectDisposedException>(() => dataBinding.RegisterDataBindingProperty(() => 1f, _ => { }, "Other"));
}
[Fact]
public void Script_IsNamedAfterTheLayerProperty()
{
Layer layer = AddLayer();
layer.Transform.Opacity.DataBinding.Script.Name.ShouldBe(layer.Transform.Opacity.PropertyDescription.Name);
}
#endregion
#region Data binding properties
[Fact]
public void Property_ReadsAndWritesThroughItsGetterAndSetter()
{
float value = 1;
DataBindingProperty<float> property = new(() => value, v => value = v, "Value");
property.GetValue().ShouldBe(1f);
property.SetValue(2f);
value.ShouldBe(2);
property.DisplayName.ShouldBe("Value");
property.ValueType.ShouldBe(typeof(float));
}
[Fact]
public void Property_TurnsNumericsIntoItsOwnNumberType()
{
float floatValue = 0;
int intValue = 0;
double doubleValue = 0;
byte byteValue = 0;
new DataBindingProperty<float>(() => floatValue, v => floatValue = v, "Float").SetValue(new Numeric(1.5f));
new DataBindingProperty<int>(() => intValue, v => intValue = v, "Int").SetValue(new Numeric(2));
new DataBindingProperty<double>(() => doubleValue, v => doubleValue = v, "Double").SetValue(new Numeric(3.5f));
new DataBindingProperty<byte>(() => byteValue, v => byteValue = v, "Byte").SetValue(new Numeric(4));
floatValue.ShouldBe(1.5f);
intValue.ShouldBe(2);
doubleValue.ShouldBe(3.5);
byteValue.ShouldBe((byte) 4);
}
[Fact]
public void Property_RejectsValuesOfAnotherType()
{
DataBindingProperty<float> numberProperty = new(() => 0, _ => { }, "Value");
DataBindingProperty<string> textProperty = new(() => "", _ => { }, "Text");
Should.Throw<ArgumentException>(() => numberProperty.SetValue("text"));
Should.Throw<ArgumentException>(() => textProperty.SetValue(new Numeric(1)));
}
[Fact]
public void Property_RequiresAGetterSetterAndName()
{
Should.Throw<ArgumentNullException>(() => new DataBindingProperty<float>(null!, _ => { }, "Value"));
Should.Throw<ArgumentNullException>(() => new DataBindingProperty<float>(() => 0, null!, "Value"));
Should.Throw<ArgumentNullException>(() => new DataBindingProperty<float>(() => 0, _ => { }, null!));
}
#endregion
private Layer AddLayer()
{
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(Harness.CreateProfile().GetRootFolder(), [Led(LedId.Keyboard_A)]);
return layer;
}
private TestNumericNode BindNumber(FloatLayerProperty property, float value)
{
TestNumericNode node = _nodes.AddNode<TestNumericNode>(property.DataBinding.Script);
node.Value = value;
node.Output.ConnectTo(ExitNode(property.DataBinding).Pins.Single());
property.DataBinding.IsEnabled = true;
return node;
}
private static void AddOpacityKeyframes(Layer layer)
{
FloatLayerProperty opacity = layer.Transform.Opacity;
opacity.KeyframesEnabled = true;
opacity.AddKeyframe(new LayerPropertyKeyframe<float>(0, TimeSpan.Zero, Easings.Functions.Linear, opacity));
opacity.AddKeyframe(new LayerPropertyKeyframe<float>(100, TimeSpan.FromSeconds(1), Easings.Functions.Linear, opacity));
}
private static DataBindingExitNode<T> ExitNode<T>(DataBinding<T> dataBinding)
{
return dataBinding.Script.Nodes.OfType<DataBindingExitNode<T>>().Single();
}
}
@@ -0,0 +1,209 @@
using Artemis.Core.Tests.Infrastructure.Plugins.Modules;
namespace Artemis.Core.Tests.DataModels;
public class DataModelEventTests
{
#region Events without arguments
[Fact]
public void Event_StartsOutNeverTriggered()
{
DataModelEvent dataModelEvent = new();
dataModelEvent.TriggerCount.ShouldBe(0);
dataModelEvent.LastTrigger.ShouldBe(default);
dataModelEvent.LastTriggerArguments.ShouldBeNull();
dataModelEvent.LastEventArgumentsUntyped.ShouldBeNull();
dataModelEvent.EventArgumentsHistory.ShouldBeEmpty();
dataModelEvent.TrackHistory.ShouldBeFalse();
}
[Fact]
public void Event_DescribesItself()
{
DataModelEvent dataModelEvent = new();
dataModelEvent.ArgumentsType.ShouldBe(typeof(DataModelEventArgs));
dataModelEvent.TriggerPastParticiple.ShouldBe("triggered");
}
[Fact]
public void TriggeringAnEvent_CountsTheTriggerAndRemembersWhen()
{
DataModelEvent dataModelEvent = new();
int raised = 0;
dataModelEvent.EventTriggered += (_, _) => raised++;
DateTime before = DateTime.Now;
dataModelEvent.Trigger();
dataModelEvent.Trigger();
raised.ShouldBe(2);
dataModelEvent.TriggerCount.ShouldBe(2);
dataModelEvent.LastTrigger.ShouldBeInRange(before, DateTime.Now);
dataModelEvent.TimeSinceLastTrigger.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
DataModelEventArgs arguments = dataModelEvent.LastTriggerArguments.ShouldNotBeNull();
arguments.TriggerTime.ShouldBeInRange(before, DateTime.Now);
dataModelEvent.LastEventArgumentsUntyped.ShouldBeSameAs(arguments);
}
[Fact]
public void Event_OnlyKeepsHistoryWhenAskedTo()
{
DataModelEvent withoutHistory = new();
DataModelEvent withHistory = new(true);
withoutHistory.Trigger();
withHistory.Trigger();
withHistory.Trigger();
withoutHistory.EventArgumentsHistory.ShouldBeEmpty();
withHistory.TrackHistory.ShouldBeTrue();
withHistory.EventArgumentsHistory.Count.ShouldBe(2);
withHistory.EventArgumentsHistoryUntyped.Last().ShouldBeSameAs(withHistory.LastTriggerArguments);
}
[Fact]
public void EventHistory_KeepsTheLastTwentyTriggers()
{
DataModelEvent dataModelEvent = new(true);
for (int i = 0; i < 20; i++)
dataModelEvent.Trigger();
DataModelEventArgs oldest = dataModelEvent.EventArgumentsHistory.Peek();
dataModelEvent.Trigger();
dataModelEvent.EventArgumentsHistory.Count.ShouldBe(20);
dataModelEvent.EventArgumentsHistory.ShouldNotContain(oldest);
}
[Fact]
public void ChangingHistoryTracking_ClearsTheHistory()
{
DataModelEvent dataModelEvent = new(true);
dataModelEvent.Trigger();
dataModelEvent.TrackHistory = true;
dataModelEvent.EventArgumentsHistory.ShouldBeEmpty();
}
[Fact]
public void ResettingAnEvent_ClearsTheCountAndHistoryButNotTheLastTrigger()
{
DataModelEvent dataModelEvent = new(true);
dataModelEvent.Trigger();
DateTime lastTrigger = dataModelEvent.LastTrigger;
dataModelEvent.Reset();
dataModelEvent.Update();
dataModelEvent.TriggerCount.ShouldBe(0);
dataModelEvent.EventArgumentsHistory.ShouldBeEmpty();
dataModelEvent.LastTrigger.ShouldBe(lastTrigger);
dataModelEvent.LastTriggerArguments.ShouldNotBeNull();
}
#endregion
#region Events with arguments
[Fact]
public void EventWithArguments_StartsOutNeverTriggered()
{
DataModelEvent<TestEventArgs> dataModelEvent = new();
dataModelEvent.TriggerCount.ShouldBe(0);
dataModelEvent.LastTrigger.ShouldBe(default);
dataModelEvent.LastEventArguments.ShouldBeNull();
dataModelEvent.LastEventArgumentsUntyped.ShouldBeNull();
dataModelEvent.TrackHistory.ShouldBeFalse();
dataModelEvent.ArgumentsType.ShouldBe(typeof(TestEventArgs));
dataModelEvent.TriggerPastParticiple.ShouldBe("triggered");
}
[Fact]
public void TriggeringAnEventWithArguments_RemembersTheArguments()
{
DataModelEvent<TestEventArgs> dataModelEvent = new();
int raised = 0;
dataModelEvent.EventTriggered += (_, _) => raised++;
TestEventArgs arguments = new() {Value = 5};
DateTime before = DateTime.Now;
dataModelEvent.Trigger(arguments);
raised.ShouldBe(1);
dataModelEvent.TriggerCount.ShouldBe(1);
dataModelEvent.LastEventArguments.ShouldBeSameAs(arguments);
dataModelEvent.LastEventArgumentsUntyped.ShouldBeSameAs(arguments);
dataModelEvent.LastTrigger.ShouldBeInRange(before, DateTime.Now);
dataModelEvent.TimeSinceLastTrigger.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
arguments.TriggerTime.ShouldBeInRange(before, DateTime.Now);
}
[Fact]
public void EventWithArguments_RequiresArguments()
{
DataModelEvent<TestEventArgs> dataModelEvent = new();
Should.Throw<ArgumentNullException>(() => dataModelEvent.Trigger(null!));
dataModelEvent.TriggerCount.ShouldBe(0);
}
[Fact]
public void EventWithArguments_OnlyKeepsHistoryWhenAskedTo()
{
DataModelEvent<TestEventArgs> withoutHistory = new();
DataModelEvent<TestEventArgs> withHistory = new(true);
withoutHistory.Trigger(new TestEventArgs());
withHistory.Trigger(new TestEventArgs {Value = 1});
withHistory.Trigger(new TestEventArgs {Value = 2});
withoutHistory.EventArgumentsHistory.ShouldBeEmpty();
withHistory.EventArgumentsHistory.Select(a => a.Value).ShouldBe([1, 2]);
withHistory.EventArgumentsHistoryUntyped.Cast<TestEventArgs>().Select(a => a.Value).ShouldBe([1, 2]);
}
[Fact]
public void EventWithArgumentsHistory_KeepsTheLastTwentyTriggers()
{
DataModelEvent<TestEventArgs> dataModelEvent = new(true);
for (int i = 1; i <= 21; i++)
dataModelEvent.Trigger(new TestEventArgs {Value = i});
dataModelEvent.EventArgumentsHistory.Count.ShouldBe(20);
dataModelEvent.EventArgumentsHistory.Peek().Value.ShouldBe(2);
}
[Fact]
public void ChangingHistoryTrackingOfAnEventWithArguments_ClearsTheHistory()
{
DataModelEvent<TestEventArgs> dataModelEvent = new(true);
dataModelEvent.Trigger(new TestEventArgs());
dataModelEvent.TrackHistory = false;
dataModelEvent.TrackHistory.ShouldBeFalse();
dataModelEvent.EventArgumentsHistory.ShouldBeEmpty();
}
[Fact]
public void ResettingAnEventWithArguments_ClearsTheCountAndHistoryButNotTheLastArguments()
{
DataModelEvent<TestEventArgs> dataModelEvent = new(true);
TestEventArgs arguments = new() {Value = 3};
dataModelEvent.Trigger(arguments);
dataModelEvent.Reset();
dataModelEvent.Update();
dataModelEvent.TriggerCount.ShouldBe(0);
dataModelEvent.EventArgumentsHistory.ShouldBeEmpty();
dataModelEvent.LastEventArguments.ShouldBeSameAs(arguments);
}
#endregion
}
@@ -0,0 +1,629 @@
using Artemis.Core.Modules;
using Artemis.Core.Tests.Infrastructure.Plugins;
using Artemis.Core.Tests.Infrastructure.Plugins.Modules;
using Artemis.Storage.Entities.Profile;
using SkiaSharp;
namespace Artemis.Core.Tests.DataModels;
public class DataModelPathTests : IDisposable
{
private readonly TestPlugin _plugin = new();
private readonly TestModule _module;
public DataModelPathTests()
{
_module = _plugin.AddFeature(new TestModule());
}
private TestDataModel DataModel => _module.DataModel;
[Fact]
public void Path_ReadsTheCurrentValue()
{
_module.DataModel.Integer = 5;
using DataModelPath path = new(_module.DataModel, "Integer");
path.IsValid.ShouldBeTrue();
path.GetValue().ShouldBe(5);
_module.DataModel.Integer = 6;
path.GetValue().ShouldBe(6);
}
[Fact]
public void Path_IsRestoredFromItsEntityThroughTheDataModelStore()
{
_module.DataModel.Child.Integer = 7;
using DataModelPath path = new(_module.DataModel, "Child.Integer");
path.Save();
using DataModelPath restored = new(path.Entity);
restored.IsValid.ShouldBeTrue();
restored.Target.ShouldBe(_module.DataModel);
restored.GetValue().ShouldBe(7);
}
public void Dispose()
{
_plugin.Dispose();
}
#region Reading values
[Fact]
public void Path_ReadsEveryKindOfProperty()
{
DataModel.Float = 1.5f;
DataModel.Double = 2.5;
DataModel.Boolean = true;
DataModel.Text = "Hello";
DataModel.Color = SKColors.Lime;
DataModel.Enum = TestEnum.Third;
ValueAt("Float").ShouldBe(1.5f);
ValueAt("Double").ShouldBe(2.5);
ValueAt("Boolean").ShouldBe(true);
ValueAt("Text").ShouldBe("Hello");
ValueAt("Color").ShouldBe(SKColors.Lime);
ValueAt("Enum").ShouldBe(TestEnum.Third);
}
[Fact]
public void EmptyPath_PointsToTheDataModelItself()
{
using DataModelPath path = new(DataModel);
path.IsValid.ShouldBeTrue();
path.Path.ShouldBe("");
path.ToString().ShouldBe("this");
path.GetValue().ShouldBeSameAs(DataModel);
path.GetPropertyType().ShouldBe(typeof(TestDataModel));
path.GetPropertyInfo().ShouldBeNull();
path.Segments.ShouldHaveSingleItem().IsStartSegment.ShouldBeTrue();
}
[Fact]
public void Path_ReadsANestedDataModel()
{
DataModel.Child.Text = "Nested";
ValueAt("Child.Text").ShouldBe("Nested");
ValueAt("Child").ShouldBeSameAs(DataModel.Child);
}
[Fact]
public void Path_ReadsAPlainObject()
{
DataModel.Item = new TestItem {Integer = 3};
ValueAt("Item.Integer").ShouldBe(3);
}
[Fact]
public void Path_ThroughAnEmptyPlainObject_ReadsTheDefaultValue()
{
DataModel.Item = null!;
using DataModelPath integerPath = new(DataModel, "Item.Integer");
using DataModelPath textPath = new(DataModel, "Item.Text");
integerPath.IsValid.ShouldBeTrue();
integerPath.GetValue().ShouldBe(0);
textPath.IsValid.ShouldBeTrue();
textPath.GetValue().ShouldBeNull();
}
[Fact]
public void Path_ReadsAList()
{
DataModel.Integers = [1, 2, 3];
ValueAt("Integers").ShouldBe(new List<int> {1, 2, 3});
ValueAt("Integers.Count").ShouldBe(3);
}
[Fact]
public void Path_CannotPointToAListItem()
{
DataModel.Integers = [1, 2, 3];
using DataModelPath path = new(DataModel, "Integers.0");
path.IsValid.ShouldBeFalse();
path.GetValue().ShouldBeNull();
}
[Fact]
public void Path_CanPointToAnIgnoredProperty()
{
// The ignore attribute only hides the property from the editor, paths still work
DataModel.Ignored = 4;
ValueAt("Ignored").ShouldBe(4);
}
[Fact]
public void Path_ReadsTheArgumentsOfAnEvent()
{
DataModel.EventWithArgs.Trigger(new TestEventArgs {Value = 9});
ValueAt("EventWithArgs.LastEventArguments.Value").ShouldBe(9);
ValueAt("EventWithArgs.TriggerCount").ShouldBe(1);
}
[Fact]
public void Path_IsCaseSensitive()
{
using DataModelPath path = new(DataModel, "integer");
path.IsValid.ShouldBeFalse();
}
#endregion
#region Invalid paths
[Fact]
public void PathToAMissingProperty_IsInvalid()
{
using DataModelPath path = new(DataModel, "DoesNotExist");
path.IsValid.ShouldBeFalse();
path.GetValue().ShouldBeNull();
path.GetPropertyType().ShouldBeNull();
path.GetPropertyInfo().ShouldBeNull();
}
[Fact]
public void PathThroughAMissingProperty_StopsAtTheFirstInvalidSegment()
{
using DataModelPath path = new(DataModel, "DoesNotExist.Integer");
path.Segments.Select(s => s.Type).ShouldBe([DataModelPathSegmentType.Static, DataModelPathSegmentType.Invalid, DataModelPathSegmentType.Invalid]);
path.Segments.Last().GetValue().ShouldBeNull();
}
[Fact]
public void Path_RequiresATarget()
{
Should.Throw<ArgumentNullException>(() => new DataModelPath((Artemis.Core.Modules.DataModel) null!));
Should.Throw<ArgumentNullException>(() => new DataModelPath(null!, "Integer"));
Should.Throw<ArgumentNullException>(() => new DataModelPath(DataModel, null!));
Should.Throw<ArgumentNullException>(() => new DataModelPath((DataModelPath) null!));
}
#endregion
#region Segments
[Fact]
public void Segments_DescribeEachStepOfThePath()
{
DataModel.Child.Integer = 8;
using DataModelPath path = new(DataModel, "Child.Integer");
DataModelPathSegment[] segments = path.Segments.ToArray();
segments.Length.ShouldBe(3);
segments[0].IsStartSegment.ShouldBeTrue();
segments[0].Identifier.ShouldBe("target");
segments[0].Previous.ShouldBeNull();
segments[0].Next.ShouldBeSameAs(segments[1]);
segments[0].GetValue().ShouldBeSameAs(DataModel);
segments[1].Identifier.ShouldBe("Child");
segments[1].Path.ShouldBe("Child");
segments[1].GetValue().ShouldBeSameAs(DataModel.Child);
segments[2].Identifier.ShouldBe("Integer");
segments[2].Path.ShouldBe("Child.Integer");
segments[2].IsStartSegment.ShouldBeFalse();
segments[2].Previous.ShouldBeSameAs(segments[1]);
segments[2].Next.ShouldBeNull();
segments[2].Type.ShouldBe(DataModelPathSegmentType.Static);
segments[2].GetValue().ShouldBe(8);
segments[2].ToString().ShouldBe("[Static] Child.Integer");
}
[Fact]
public void StaticSegment_KnowsItsProperty()
{
using DataModelPath path = new(DataModel, "Child.Integer");
path.GetPropertyInfo().ShouldBe(typeof(TestChildDataModel).GetProperty(nameof(TestChildDataModel.Integer)));
path.GetPropertyType().ShouldBe(typeof(int));
}
[Fact]
public void StaticSegment_WithoutADescription_IsNamedAfterItsProperty()
{
using DataModelPath path = new(DataModel, "Child.Integer");
DataModelPropertyAttribute description = path.GetPropertyDescription().ShouldNotBeNull();
description.Name.ShouldBe("Integer");
description.ResetsDepth.ShouldBeFalse();
}
[Fact]
public void StaticSegment_UsesTheDescriptionOfItsProperty()
{
using DataModelPath path = new(DataModel, "Event.TriggerCount");
path.GetPropertyDescription().ShouldNotBeNull().Description.ShouldBe("The total amount of times this event has triggered since the module was activated");
}
[Fact]
public void StartSegment_UsesTheDescriptionOfTheDataModel()
{
using DataModelPath path = new(DataModel);
path.GetPropertyDescription().ShouldBeSameAs(DataModel.DataModelDescription);
}
#endregion
#region Dynamic children
[Fact]
public void Path_ReadsADynamicChild()
{
DynamicChild<int> child = DataModel.AddDynamicChild("Extra", 5, "Extra points");
using DataModelPath path = new(DataModel, "Extra");
path.IsValid.ShouldBeTrue();
path.Segments.Last().Type.ShouldBe(DataModelPathSegmentType.Dynamic);
path.GetValue().ShouldBe(5);
child.Value = 6;
path.GetValue().ShouldBe(6);
}
[Fact]
public void DynamicSegment_TakesItsTypeFromTheValueAndItsDescriptionFromTheChild()
{
DynamicChild<int> child = DataModel.AddDynamicChild("Extra", 5, "Extra points");
using DataModelPath path = new(DataModel, "Extra");
path.GetPropertyInfo().ShouldBeNull();
path.GetPropertyType().ShouldBe(typeof(int));
path.GetPropertyDescription().ShouldBeSameAs(child.Attribute);
}
[Fact]
public void Path_ReadsThroughADynamicDataModel()
{
TestChildDataModel childDataModel = new() {Integer = 4};
DataModel.AddDynamicChild("Extra", childDataModel, "Extra data");
using DataModelPath path = new(DataModel, "Extra.Integer");
path.IsValid.ShouldBeTrue();
path.Segments.Select(s => s.Type).ShouldBe([DataModelPathSegmentType.Static, DataModelPathSegmentType.Dynamic, DataModelPathSegmentType.Static]);
path.GetValue().ShouldBe(4);
}
[Fact]
public void Path_ReadsADynamicChildOfANestedDataModel()
{
DataModel.Child.AddDynamicChild("Extra", "Deep");
ValueAt("Child.Extra").ShouldBe("Deep");
}
[Fact]
public void PathToAMissingDynamicChild_BecomesValidOnceTheChildIsAdded()
{
using DataModelPath path = new(DataModel, "Extra");
int validated = 0;
path.PathValidated += (_, _) => validated++;
path.IsValid.ShouldBeFalse();
DataModel.AddDynamicChild("Extra", 5);
path.IsValid.ShouldBeTrue();
path.GetValue().ShouldBe(5);
validated.ShouldBe(1);
}
[Fact]
public void AddingAnotherDynamicChild_DoesNotAffectThePath()
{
using DataModelPath path = new(DataModel, "Extra");
int validated = 0;
path.PathValidated += (_, _) => validated++;
DataModel.AddDynamicChild("Other", 5);
path.IsValid.ShouldBeFalse();
validated.ShouldBe(0);
}
[Fact]
public void RemovingTheDynamicChild_LeavesThePathValid()
{
DataModel.AddDynamicChild("Extra", 5);
using DataModelPath path = new(DataModel, "Extra");
int invalidated = 0;
path.PathInvalidated += (_, _) => invalidated++;
DataModel.RemoveDynamicChildByKey("Extra");
// Pinned as-is: the segment only invalidates the path when the value of the removed child is the data model the
// segment looks in, rather than when the child it points to is removed. So the path stays valid and reads the
// default value instead of becoming invalid.
path.IsValid.ShouldBeTrue();
path.GetValue().ShouldBe(0);
invalidated.ShouldBe(0);
}
[Fact]
public void RemovingADynamicDataModel_LeavesThePathThroughItValid()
{
DataModel.AddDynamicChild("Extra", new TestChildDataModel {Integer = 4}, "Extra data");
using DataModelPath path = new(DataModel, "Extra.Integer");
DataModel.RemoveDynamicChildByKey("Extra");
// Pinned as-is, see RemovingTheDynamicChild_LeavesThePathValid
path.IsValid.ShouldBeTrue();
path.GetValue().ShouldBe(0);
}
[Fact]
public void ReplacingADynamicChild_MakesThePathReadTheNewChild()
{
DataModel.AddDynamicChild("Extra", 5);
using DataModelPath path = new(DataModel, "Extra");
DataModel.RemoveDynamicChildByKey("Extra");
DataModel.AddDynamicChild("Extra", 7);
path.IsValid.ShouldBeTrue();
path.GetValue().ShouldBe(7);
}
#endregion
#region The data model store
[Fact]
public void SavedPath_BecomesInvalidWhenItsDataModelIsRemovedFromTheStore()
{
using DataModelPath path = new(DataModel, "Integer");
path.Save();
int invalidated = 0;
path.PathInvalidated += (_, _) => invalidated++;
RemoveFromStore();
path.IsValid.ShouldBeFalse();
path.Target.ShouldBeNull();
path.GetValue().ShouldBeNull();
invalidated.ShouldBe(1);
DataModel.ActivePaths.ShouldNotContain(path);
}
[Fact]
public void SavedPath_BecomesInvalidWhenItsModuleIsDisabled()
{
using DataModelPath path = new(DataModel, "Integer");
path.Save();
_plugin.Dispose();
path.IsValid.ShouldBeFalse();
path.Target.ShouldBeNull();
}
[Fact]
public void SavedPath_BecomesValidAgainWhenItsDataModelReturns()
{
DataModel.Integer = 3;
using DataModelPath path = new(DataModel, "Integer");
path.Save();
int validated = 0;
path.PathValidated += (_, _) => validated++;
RemoveFromStore();
DataModelStore.Add(DataModel);
path.IsValid.ShouldBeTrue();
path.Target.ShouldBeSameAs(DataModel);
path.GetValue().ShouldBe(3);
validated.ShouldBe(1);
}
[Fact]
public void SavedPath_MovesToANewInstanceOfItsDataModel()
{
using DataModelPath path = new(DataModel, "Integer");
path.Save();
// Like a module being enabled again, which creates a new data model
RemoveFromStore();
TestDataModel newDataModel = new() {Module = _module, Integer = 11};
DataModelStore.Add(newDataModel);
path.Target.ShouldBeSameAs(newDataModel);
path.GetValue().ShouldBe(11);
}
[Fact]
public void NewPath_DoesNotFollowTheStoreUntilItIsSaved()
{
using DataModelPath path = new(DataModel, "Integer");
// Pinned as-is: the constructors call Save before the path is set up, while it is still invalid, so nothing is
// saved and the entity has no data model ID. The path only reacts to its data model leaving and returning to the
// store once Save is called. Looks like Save should be called after Initialize.
path.Entity.DataModelId.ShouldBeNull();
path.Entity.Path.ShouldBe("");
RemoveFromStore();
path.IsValid.ShouldBeTrue();
path.Target.ShouldBeSameAs(DataModel);
}
[Fact]
public void PathFromAnEntity_WaitsForItsDataModelToBeAdded()
{
RemoveFromStore();
DataModel.Integer = 12;
DataModelPathEntity entity = new() {Path = "Integer", DataModelId = _module.Id};
using DataModelPath path = new(entity);
path.IsValid.ShouldBeFalse();
path.Target.ShouldBeNull();
path.GetValue().ShouldBeNull();
path.GetFeatureDependencies().ShouldBeEmpty();
DataModelStore.Add(DataModel);
path.IsValid.ShouldBeTrue();
path.GetValue().ShouldBe(12);
}
[Fact]
public void PathFromAnEntity_IgnoresOtherDataModels()
{
DataModelPathEntity entity = new() {Path = "Integer", DataModelId = "Some.Other.Module-12345678"};
using DataModelPath path = new(entity);
RemoveFromStore();
DataModelStore.Add(DataModel);
path.IsValid.ShouldBeFalse();
path.Target.ShouldBeNull();
}
#endregion
#region Saving and loading
[Fact]
public void Saving_StoresThePathItsDataModelAndItsType()
{
using DataModelPath path = new(DataModel, "Child.Integer");
path.Save();
path.Entity.Path.ShouldBe("Child.Integer");
path.Entity.DataModelId.ShouldBe(_module.Id);
path.Entity.Type.ShouldBe(typeof(int).FullName);
path.DataModelId.ShouldBe(_module.Id);
}
[Fact]
public void SavingAnInvalidPath_KeepsWhatWasSavedBefore()
{
DataModelPathEntity entity = new() {Path = "DoesNotExist", DataModelId = _module.Id, Type = "System.Int32"};
using DataModelPath path = new(entity);
path.Save();
path.IsValid.ShouldBeFalse();
entity.Path.ShouldBe("DoesNotExist");
entity.DataModelId.ShouldBe(_module.Id);
entity.Type.ShouldBe("System.Int32");
}
[Fact]
public void InvalidPath_FallsBackToTheSavedType()
{
using DataModelPath path = new(new DataModelPathEntity {Path = "Integer", DataModelId = "Missing-12345678", Type = typeof(int).FullName});
path.IsValid.ShouldBeFalse();
path.GetPropertyType().ShouldBe(typeof(int));
}
[Fact]
public void InvalidPath_CannotFallBackToATypeOutsideTheCoreLibraries()
{
using DataModelPath valid = new(DataModel, "Enum");
valid.Save();
valid.Entity.Type.ShouldBe(typeof(TestEnum).FullName);
using DataModelPath path = new(new DataModelPathEntity {Path = "Enum", DataModelId = "Missing-12345678", Type = valid.Entity.Type});
// Pinned as-is: the type is saved by its full name without the assembly, which Type.GetType can only find in
// Artemis.Core and the .NET libraries, so types from plugins (and SkiaSharp, like SKColor) can't be found again.
path.GetPropertyType().ShouldBeNull();
}
[Fact]
public void CopiedPath_PointsToTheSameProperty()
{
DataModel.Integer = 13;
using DataModelPath original = new(DataModel, "Integer");
using DataModelPath copy = new(original);
copy.Target.ShouldBeSameAs(DataModel);
copy.Path.ShouldBe("Integer");
copy.GetValue().ShouldBe(13);
copy.Entity.ShouldNotBeSameAs(original.Entity);
DataModel.ActivePaths.Count.ShouldBe(2);
}
#endregion
#region Disposing
[Fact]
public void DisposedPath_IsInvalidAndCannotBeRead()
{
DataModelPath path = new(DataModel, "Integer");
int invalidated = 0;
path.PathInvalidated += (_, _) => invalidated++;
path.Dispose();
invalidated.ShouldBe(1);
path.IsValid.ShouldBeFalse();
Should.Throw<ObjectDisposedException>(() => path.GetValue());
Should.Throw<ObjectDisposedException>(() => path.GetPropertyType());
Should.Throw<ObjectDisposedException>(() => path.GetPropertyInfo());
Should.Throw<ObjectDisposedException>(() => path.GetPropertyDescription());
}
[Fact]
public void DisposedPath_NoLongerFollowsTheStore()
{
DataModelPath path = new(DataModel, "Integer");
path.Save();
path.Dispose();
int validated = 0;
path.PathValidated += (_, _) => validated++;
RemoveFromStore();
DataModelStore.Add(DataModel);
validated.ShouldBe(0);
path.IsValid.ShouldBeFalse();
}
#endregion
[Fact]
public void Path_DependsOnTheModuleOfItsDataModel()
{
using DataModelPath path = new(DataModel, "Integer");
path.GetFeatureDependencies().ShouldBe([_module]);
}
private object? ValueAt(string path)
{
using DataModelPath dataModelPath = new(DataModel, path);
dataModelPath.IsValid.ShouldBeTrue();
return dataModelPath.GetValue();
}
private void RemoveFromStore()
{
DataModelStore.Remove(DataModelStore.Get(_module.Id).ShouldNotBeNull());
}
}
@@ -0,0 +1,359 @@
using Artemis.Core.Modules;
using Artemis.Core.Tests.Infrastructure.Plugins;
using Artemis.Core.Tests.Infrastructure.Plugins.Modules;
namespace Artemis.Core.Tests.DataModels;
public class DataModelTests : IDisposable
{
private readonly TestPlugin _plugin = new();
private readonly TestModule _module;
public DataModelTests()
{
_module = _plugin.AddFeature(new TestModule());
}
private TestDataModel DataModel => _module.DataModel;
public void Dispose()
{
_plugin.Dispose();
}
#region Dynamic children
[Fact]
public void DynamicChild_CanBeFoundByItsKey()
{
DynamicChild<int> child = DataModel.AddDynamicChild("Extra", 5);
DataModel.DynamicChildren.ShouldContainKey("Extra");
DataModel.GetDynamicChild("Extra").ShouldBeSameAs(child);
DataModel.GetDynamicChild<int>("Extra").ShouldBeSameAs(child);
DataModel.TryGetDynamicChild("Extra", out DynamicChild? untyped).ShouldBeTrue();
untyped.ShouldBeSameAs(child);
DataModel.TryGetDynamicChild("Extra", out DynamicChild<int>? typed).ShouldBeTrue();
typed.ShouldBeSameAs(child);
}
[Fact]
public void DynamicChild_KeepsItsKeyTypeAndValue()
{
DynamicChild<int> child = DataModel.AddDynamicChild("Extra", 5);
child.Key.ShouldBe("Extra");
child.Type.ShouldBe(typeof(int));
child.Value.ShouldBe(5);
child.BaseValue.ShouldBe(5);
child.Value = 6;
child.BaseValue.ShouldBe(6);
}
[Fact]
public void DynamicChild_WithoutAName_IsNamedAfterItsKey()
{
DynamicChild<int> child = DataModel.AddDynamicChild("currentScore", 5);
child.Attribute.Name.ShouldBe("Current score");
child.Attribute.Description.ShouldBeNull();
}
[Fact]
public void DynamicChild_UsesTheProvidedNameAndDescription()
{
DynamicChild<int> child = DataModel.AddDynamicChild("Extra", 5, "Extra points", "Points scored in overtime");
child.Attribute.Name.ShouldBe("Extra points");
child.Attribute.Description.ShouldBe("Points scored in overtime");
}
[Fact]
public void DynamicChild_UsesTheProvidedAttribute()
{
DataModelPropertyAttribute attribute = new() {Name = "Extra points", Affix = "pts"};
DynamicChild<int> child = DataModel.AddDynamicChild("Extra", 5, attribute);
child.Attribute.ShouldBeSameAs(attribute);
}
[Fact]
public void DynamicChild_ThatIsADataModel_BelongsToTheSameModule()
{
TestChildDataModel childDataModel = new();
DynamicChild<TestChildDataModel> child = DataModel.AddDynamicChild("Extra", childDataModel, "Extra data");
childDataModel.Module.ShouldBeSameAs(_module);
childDataModel.DataModelDescription.ShouldBeSameAs(child.Attribute);
}
[Fact]
public void AddingADynamicChild_RaisesDynamicChildAdded()
{
List<DynamicDataModelChildEventArgs> added = [];
DataModel.DynamicChildAdded += (_, e) => added.Add(e);
DynamicChild<int> child = DataModel.AddDynamicChild("Extra", 5);
added.ShouldHaveSingleItem();
added[0].Key.ShouldBe("Extra");
added[0].DynamicChild.ShouldBeSameAs(child);
}
[Fact]
public void DynamicChild_KeyCannotContainADot()
{
Should.Throw<ArtemisCoreException>(() => DataModel.AddDynamicChild("Extra.Points", 5));
}
[Fact]
public void DynamicChild_KeyCannotBeUsedTwice()
{
DataModel.AddDynamicChild("Extra", 5);
Should.Throw<ArtemisCoreException>(() => DataModel.AddDynamicChild("Extra", 6));
}
[Fact]
public void DynamicChild_KeyCannotBeTheNameOfARegularProperty()
{
Should.Throw<ArtemisCoreException>(() => DataModel.AddDynamicChild("Integer", 5));
}
[Fact]
public void DynamicChild_CannotStartOutEmpty()
{
Should.Throw<ArgumentNullException>(() => DataModel.AddDynamicChild<string?>("Extra", null));
}
[Fact]
public void GettingAMissingDynamicChild_Throws()
{
Should.Throw<KeyNotFoundException>(() => DataModel.GetDynamicChild("Missing"));
Should.Throw<KeyNotFoundException>(() => DataModel.GetDynamicChild<int>("Missing"));
}
[Fact]
public void GettingADynamicChildAsTheWrongType_Throws()
{
DataModel.AddDynamicChild("Extra", 5);
Should.Throw<InvalidCastException>(() => DataModel.GetDynamicChild<string>("Extra"));
}
[Fact]
public void TryingToGetAMissingOrWronglyTypedDynamicChild_ReturnsFalse()
{
DataModel.AddDynamicChild("Extra", 5);
DataModel.TryGetDynamicChild("Missing", out DynamicChild? missing).ShouldBeFalse();
missing.ShouldBeNull();
DataModel.TryGetDynamicChild("Extra", out DynamicChild<string>? wrongType).ShouldBeFalse();
wrongType.ShouldBeNull();
}
[Fact]
public void RemovingADynamicChildByKey_RemovesItAndRaisesDynamicChildRemoved()
{
DynamicChild<int> child = DataModel.AddDynamicChild("Extra", 5);
List<DynamicDataModelChildEventArgs> removed = [];
DataModel.DynamicChildRemoved += (_, e) => removed.Add(e);
DataModel.RemoveDynamicChildByKey("Extra");
DataModel.DynamicChildren.ShouldBeEmpty();
removed.ShouldHaveSingleItem();
removed[0].Key.ShouldBe("Extra");
removed[0].DynamicChild.ShouldBeSameAs(child);
}
[Fact]
public void RemovingAMissingDynamicChildByKey_DoesNothing()
{
DataModel.AddDynamicChild("Extra", 5);
int removed = 0;
DataModel.DynamicChildRemoved += (_, _) => removed++;
DataModel.RemoveDynamicChildByKey("Missing");
DataModel.DynamicChildren.Count.ShouldBe(1);
removed.ShouldBe(0);
}
[Fact]
public void RemovingADynamicChild_DoesNotRemoveIt()
{
DynamicChild<int> child = DataModel.AddDynamicChild("Extra", 5);
int removed = 0;
DataModel.DynamicChildRemoved += (_, _) => removed++;
DataModel.RemoveDynamicChild(child);
// Pinned as-is: RemoveDynamicChild compares the value of each child (BaseValue) with the child that was passed in,
// rather than the child itself, so it only removes a child whose value is the child object. Looks like it should
// compare the children themselves; RemoveDynamicChildByKey works.
DataModel.DynamicChildren.ShouldContainKey("Extra");
removed.ShouldBe(0);
}
[Fact]
public void ClearingDynamicChildren_RemovesAllOfThemOneByOne()
{
DataModel.AddDynamicChild("First", 1);
DataModel.AddDynamicChild("Second", "two");
List<string> removed = [];
DataModel.DynamicChildRemoved += (_, e) => removed.Add(e.Key);
DataModel.ClearDynamicChildren();
DataModel.DynamicChildren.ShouldBeEmpty();
removed.ShouldBe(["First", "Second"], ignoreOrder: true);
}
#endregion
#region Hidden properties
[Fact]
public void HiddenProperties_AreEmptyByDefault()
{
DataModel.GetHiddenProperties().ShouldBeEmpty();
}
[Fact]
public void HidingAProperty_AddsItToTheHiddenPropertiesOnce()
{
_module.HideProperty(dm => dm.Child.Integer);
_module.HideProperty(dm => dm.Child.Integer);
DataModel.GetHiddenProperties().ShouldHaveSingleItem().ShouldBe(typeof(TestChildDataModel).GetProperty(nameof(TestChildDataModel.Integer)));
}
[Fact]
public void ShowingAProperty_RemovesItFromTheHiddenProperties()
{
_module.HideProperty(dm => dm.Integer);
_module.HideProperty(dm => dm.Text);
_module.ShowProperty(dm => dm.Integer);
DataModel.GetHiddenProperties().ShouldHaveSingleItem().Name.ShouldBe(nameof(TestDataModel.Text));
}
#endregion
#region Property descriptions
[Fact]
public void PropertyWithoutADescription_HasNoPropertyDescription()
{
DataModel.GetPropertyDescription(typeof(TestDataModel).GetProperty(nameof(TestDataModel.Integer))!).ShouldBeNull();
}
[Fact]
public void DataModel_IsDescribedByItsModule()
{
DataModel.Module.ShouldBeSameAs(_module);
DataModel.DataModelDescription.ShouldNotBeNull();
}
#endregion
#region Paths in use
[Fact]
public void Property_IsNotInUseWithoutPaths()
{
_module.IsPropertyInUse("Integer", false).ShouldBeFalse();
_module.IsPropertyInUse("Integer", true).ShouldBeFalse();
}
[Fact]
public void Property_IsInUseWhileAPathPointsToIt()
{
DataModelPath path = new(DataModel, "Child.Integer");
_module.IsPropertyInUse("Child.Integer", false).ShouldBeTrue();
_module.IsPropertyInUse(dm => dm.Child.Integer, false).ShouldBeTrue();
_module.IsPropertyInUse("Child.Text", false).ShouldBeFalse();
path.Dispose();
_module.IsPropertyInUse("Child.Integer", false).ShouldBeFalse();
}
[Fact]
public void Property_IsInUseIgnoringCase()
{
using DataModelPath path = new(DataModel, "Child.Integer");
_module.IsPropertyInUse("child.integer", false).ShouldBeTrue();
}
[Fact]
public void ParentProperty_IsOnlyInUseWhenIncludingChildren()
{
using DataModelPath path = new(DataModel, "Child.Integer");
_module.IsPropertyInUse("Child", false).ShouldBeFalse();
_module.IsPropertyInUse("Child", true).ShouldBeTrue();
_module.IsPropertyInUse(dm => dm.Child, true).ShouldBeTrue();
}
[Fact]
public void IncludingChildren_AlsoMatchesPropertiesThatStartWithTheSameName()
{
using DataModelPath path = new(DataModel, "Integers");
// Pinned as-is: children are matched by checking whether a path starts with the given text, so "Integer" counts as
// in use because of a path to "Integers", a different property. Looks like it should check for "Integer."
_module.IsPropertyInUse("Integer", true).ShouldBeTrue();
_module.IsPropertyInUse("Integer", false).ShouldBeFalse();
}
[Fact]
public void Property_StaysInUseUntilTheLastPathToItIsGone()
{
DataModelPath first = new(DataModel, "Integer");
DataModelPath second = new(DataModel, "Integer");
first.Dispose();
_module.IsPropertyInUse("Integer", false).ShouldBeTrue();
second.Dispose();
_module.IsPropertyInUse("Integer", false).ShouldBeFalse();
}
[Fact]
public void ActivePaths_ListThePathsTargetingTheDataModel()
{
List<DataModelPath> added = [];
List<DataModelPath> removed = [];
DataModel.ActivePathAdded += (_, e) => added.Add(e.DataModelPath);
DataModel.ActivePathRemoved += (_, e) => removed.Add(e.DataModelPath);
DataModelPath path = new(DataModel, "Integer");
DataModel.ActivePaths.ShouldHaveSingleItem().ShouldBeSameAs(path);
added.ShouldHaveSingleItem().ShouldBeSameAs(path);
path.Dispose();
DataModel.ActivePaths.ShouldBeEmpty();
removed.ShouldHaveSingleItem().ShouldBeSameAs(path);
}
[Fact]
public void InvalidPath_IsStillAnActivePath()
{
using DataModelPath path = new(DataModel, "DoesNotExist");
DataModel.ActivePaths.ShouldContain(path);
_module.IsPropertyInUse("DoesNotExist", false).ShouldBeTrue();
}
#endregion
}
@@ -0,0 +1,20 @@
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure;
public static class ColorAssertions
{
/// <summary>
/// Asserts the color matches per channel within the given tolerance, rendering results vary slightly between platforms
/// due to anti-aliasing and the render scale.
/// </summary>
public static void ShouldBeColor(this SKColor actual, SKColor expected, int tolerance = 2, string? context = null)
{
bool matches = Math.Abs(actual.Red - expected.Red) <= tolerance &&
Math.Abs(actual.Green - expected.Green) <= tolerance &&
Math.Abs(actual.Blue - expected.Blue) <= tolerance &&
Math.Abs(actual.Alpha - expected.Alpha) <= tolerance;
matches.ShouldBeTrue($"Expected {expected} (±{tolerance}) but got {actual}{(context == null ? "" : $" ({context})")}");
}
}
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<Device xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Name>Test AZERTY keys</Name>
<Description>The letter keys around the movement keys of an AZERTY keyboard, where A sits above Q and Z above S</Description>
<Type>Keyboard</Type>
<Vendor>Artemis</Vendor>
<Model>Test AZERTY keys</Model>
<Width>80</Width>
<Height>60</Height>
<LedUnitWidth>1</LedUnitWidth>
<LedUnitHeight>1</LedUnitHeight>
<Leds>
<Led Id="Keyboard_A">
<X>0</X>
<Y>0</Y>
<Width>19</Width>
<Height>19</Height>
</Led>
<Led Id="Keyboard_Z">
<X>19</X>
<Y>0</Y>
<Width>19</Width>
<Height>19</Height>
</Led>
<Led Id="Keyboard_E">
<X>38</X>
<Y>0</Y>
<Width>19</Width>
<Height>19</Height>
</Led>
<Led Id="Keyboard_Q">
<X>5</X>
<Y>19</Y>
<Width>19</Width>
<Height>19</Height>
</Led>
<Led Id="Keyboard_S">
<X>24</X>
<Y>19</Y>
<Width>19</Width>
<Height>19</Height>
</Led>
<Led Id="Keyboard_D">
<X>43</X>
<Y>19</Y>
<Width>19</Width>
<Height>19</Height>
</Led>
<Led Id="Keyboard_W">
<X>10</X>
<Y>38</Y>
<Width>19</Width>
<Height>19</Height>
</Led>
</Leds>
</Device>
@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<Device xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Name>Test LED strip</Name>
<Description>A strip of 20 small 3x3 LEDs, 4 units apart, smaller than a canvas pixel at low render scales</Description>
<Type>LedStripe</Type>
<Vendor>Artemis</Vendor>
<Model>Test LED strip</Model>
<Width>82</Width>
<Height>7</Height>
<LedUnitWidth>1</LedUnitWidth>
<LedUnitHeight>1</LedUnitHeight>
<Leds>
<Led Id="LedStripe1">
<X>2</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe2">
<X>6</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe3">
<X>10</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe4">
<X>14</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe5">
<X>18</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe6">
<X>22</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe7">
<X>26</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe8">
<X>30</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe9">
<X>34</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe10">
<X>38</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe11">
<X>42</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe12">
<X>46</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe13">
<X>50</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe14">
<X>54</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe15">
<X>58</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe16">
<X>62</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe17">
<X>66</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe18">
<X>70</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe19">
<X>74</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
<Led Id="LedStripe20">
<X>78</X>
<Y>2</Y>
<Width>3</Width>
<Height>3</Height>
</Led>
</Leds>
</Device>
@@ -0,0 +1,14 @@
using RGB.NET.Core;
namespace Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
/// <summary>
/// An update queue that discards its data, the trigger is never started so nothing runs in the background
/// </summary>
internal sealed class NoOpUpdateQueue() : UpdateQueue(new DeviceUpdateTrigger())
{
protected override bool Update(ReadOnlySpan<(object key, Color color)> dataSet)
{
return true;
}
}
@@ -0,0 +1,36 @@
using Artemis.Core.DeviceProviders;
using RGB.NET.Core;
using RGB.NET.Layout;
namespace Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
/// <summary>
/// A device provider feature that provides fake devices created from layout files.
/// </summary>
public class TestDeviceProvider : DeviceProvider
{
private readonly TestRGBDeviceProvider _rgbDeviceProvider = new();
public override IRGBDeviceProvider RgbDeviceProvider => _rgbDeviceProvider;
/// <summary>
/// Creates a fake device from the provided layout.
/// </summary>
/// <param name="layout">The layout describing the device and its LEDs</param>
/// <param name="ledsUpdated">Called whenever the device sends colors to the "hardware", after color corrections</param>
public IRGBDevice CreateDevice(IDeviceLayout layout, Action<Led, Color> ledsUpdated)
{
_rgbDeviceProvider.Definitions.Add((layout, ledsUpdated));
_rgbDeviceProvider.Initialize(throwExceptions: true);
return _rgbDeviceProvider.Devices.Last();
}
public override void Enable()
{
}
public override void Disable()
{
_rgbDeviceProvider.Dispose();
}
}
@@ -0,0 +1,17 @@
namespace Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
/// <summary>
/// Paths to the device layouts shipped with Artemis.Core, and test layouts shipped with the tests
/// </summary>
public static class TestLayouts
{
private static readonly string Root = Path.Combine(AppContext.BaseDirectory, "DefaultLayouts", "Artemis");
public static string KeyboardAnsi => Path.Combine(Root, "Keyboard", "Artemis L keyboard-ANSI.xml");
public static string KeyboardIso => Path.Combine(Root, "Keyboard", "Artemis L keyboard-ISO.xml");
/// <summary>
/// A strip of 20 small 3x3 LEDs, 4 units apart. Smaller than a canvas pixel at a render scale of 0.25
/// </summary>
public static string SmallLedStrip => Path.Combine(AppContext.BaseDirectory, "TestLayouts", "Test LED strip.xml");
}
@@ -0,0 +1,34 @@
using RGB.NET.Core;
using RGB.NET.Layout;
namespace Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
/// <summary>
/// A fake device, similar to RGB.NET's DebugRGBDevice.
/// <para>
/// DebugRGBDevice overrides UpdateLeds which skips applying the requested colors to its LEDs and skips color
/// corrections, this device goes through the same update path as real devices.
/// </para>
/// </summary>
internal sealed class TestRGBDevice : AbstractRGBDevice<TestRGBDeviceInfo>, IUnknownDevice
{
private readonly Action<Led, Color> _ledsUpdated;
public TestRGBDevice(IDeviceLayout layout, Action<Led, Color> ledsUpdated)
: base(new TestRGBDeviceInfo(layout.Type, layout.Vendor ?? "Artemis", layout.Model ?? "Test device"), new NoOpUpdateQueue())
{
_ledsUpdated = ledsUpdated;
layout.ApplyTo(this, true);
}
protected override void UpdateLeds(IEnumerable<Led> ledsToUpdate)
{
List<Led> leds = ledsToUpdate.ToList();
// Applies the requested colors to the LEDs and hands them to the (no-op) update queue
base.UpdateLeds(leds);
foreach (Led led in leds)
_ledsUpdated(led, GetUpdateData(led).color);
}
}
@@ -0,0 +1,9 @@
using RGB.NET.Core;
namespace Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
internal sealed record TestRGBDeviceInfo(RGBDeviceType DeviceType, string Manufacturer, string Model) : IRGBDeviceInfo
{
public string DeviceName => $"{Manufacturer} {Model}";
public object? LayoutMetadata { get; set; }
}
@@ -0,0 +1,18 @@
using RGB.NET.Core;
using RGB.NET.Layout;
namespace Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
internal class TestRGBDeviceProvider : AbstractRGBDeviceProvider
{
public List<(IDeviceLayout Layout, Action<Led, Color> LedsUpdated)> Definitions { get; } = [];
protected override void InitializeSDK()
{
}
protected override IEnumerable<IRGBDevice> LoadDevices()
{
return Definitions.Select(d => new TestRGBDevice(d.Layout, d.LedsUpdated));
}
}
@@ -0,0 +1,36 @@
using Artemis.Core.LayerBrushes;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
/// <summary>
/// Fills the layer with a horizontal gradient, useful to see the direction of transformations.
/// </summary>
public class GradientTestBrush : LayerBrush<GradientTestBrushProperties>
{
public override void EnableLayerBrush()
{
}
public override void DisableLayerBrush()
{
}
public override void Update(double deltaTime)
{
}
public override void Render(SKCanvas canvas, SKRect bounds, SKPaint paint)
{
using SKShader shader = SKShader.CreateLinearGradient(
new SKPoint(bounds.Left, bounds.MidY),
new SKPoint(bounds.Right, bounds.MidY),
[Properties.Left.CurrentValue, Properties.Right.CurrentValue],
SKShaderTileMode.Clamp
);
paint.Shader = shader;
canvas.DrawRect(bounds, paint);
paint.Shader = null;
}
}
@@ -0,0 +1,23 @@
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
public class GradientTestBrushProperties : LayerPropertyGroup
{
public SKColorLayerProperty Left { get; set; } = null!;
public SKColorLayerProperty Right { get; set; } = null!;
protected override void PopulateDefaults()
{
Left.DefaultValue = SKColors.Black;
Right.DefaultValue = SKColors.White;
}
protected override void EnableProperties()
{
}
protected override void DisableProperties()
{
}
}
@@ -0,0 +1,32 @@
using Artemis.Core.LayerBrushes;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
/// <summary>
/// A per-LED brush that lets the test decide the color of each LED.
/// </summary>
public class PerLedTestBrush : PerLedLayerBrush<PerLedTestBrushProperties>
{
/// <summary>
/// Gets or sets the function that picks the color of an LED, receives the LED and its point relative to the layer
/// </summary>
public Func<ArtemisLed, SKPoint, SKColor> ColorSelector { get; set; } = (_, _) => SKColors.White;
public override void EnableLayerBrush()
{
}
public override void DisableLayerBrush()
{
}
public override void Update(double deltaTime)
{
}
public override SKColor GetColor(ArtemisLed led, SKPoint renderPoint)
{
return ColorSelector(led, renderPoint);
}
}
@@ -0,0 +1,16 @@
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
public class PerLedTestBrushProperties : LayerPropertyGroup
{
protected override void PopulateDefaults()
{
}
protected override void EnableProperties()
{
}
protected override void DisableProperties()
{
}
}
@@ -0,0 +1,26 @@
using Artemis.Core.LayerBrushes;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
public class SolidColorTestBrush : LayerBrush<SolidColorTestBrushProperties>
{
public override void EnableLayerBrush()
{
}
public override void DisableLayerBrush()
{
}
public override void Update(double deltaTime)
{
}
public override void Render(SKCanvas canvas, SKRect bounds, SKPaint paint)
{
// Like the real brushes this overrides the paint color, the layer applies its opacity when compositing
paint.Color = Properties.Color.CurrentValue;
canvas.DrawRect(bounds, paint);
}
}
@@ -0,0 +1,21 @@
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
public class SolidColorTestBrushProperties : LayerPropertyGroup
{
public SKColorLayerProperty Color { get; set; } = null!;
protected override void PopulateDefaults()
{
Color.DefaultValue = SKColors.Red;
}
protected override void EnableProperties()
{
}
protected override void DisableProperties()
{
}
}
@@ -0,0 +1,20 @@
using Artemis.Core.LayerBrushes;
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
/// <summary>
/// Provides deterministic brushes for render tests. The real brushes live in the Artemis.Plugins repository.
/// </summary>
public class TestLayerBrushProvider : LayerBrushProvider
{
public override void Enable()
{
RegisterLayerBrushDescriptor<SolidColorTestBrush>("Solid color", "Fills the layer with a single color", "Brush");
RegisterLayerBrushDescriptor<GradientTestBrush>("Gradient", "Fills the layer with a left to right gradient", "Gradient");
RegisterLayerBrushDescriptor<PerLedTestBrush>("Per LED", "Lets the test pick a color for each LED", "Led");
}
public override void Disable()
{
}
}
@@ -0,0 +1,40 @@
using Artemis.Core.LayerEffects;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
/// <summary>
/// Gives the paint the element is composited with a color filter that swaps the red and blue channels, in pre-processing
/// </summary>
public class ChannelSwapTestEffect : LayerEffect<ChannelSwapTestEffectProperties>
{
private static readonly float[] SwapRedBlue =
[
0, 0, 1, 0, 0,
0, 1, 0, 0, 0,
1, 0, 0, 0, 0,
0, 0, 0, 1, 0
];
public override void EnableLayerEffect()
{
}
public override void DisableLayerEffect()
{
}
public override void Update(double deltaTime)
{
}
public override void PreProcess(SKCanvas canvas, SKRect renderBounds, SKPaint paint)
{
SKColorFilter swap = SKColorFilter.CreateColorMatrix(SwapRedBlue);
paint.ColorFilter = paint.ColorFilter == null ? swap : SKColorFilter.CreateCompose(swap, paint.ColorFilter);
}
public override void PostProcess(SKCanvas canvas, SKRect renderBounds, SKPaint paint)
{
}
}
@@ -0,0 +1,16 @@
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
public class ChannelSwapTestEffectProperties : LayerEffectPropertyGroup
{
protected override void PopulateDefaults()
{
}
protected override void EnableProperties()
{
}
protected override void DisableProperties()
{
}
}
@@ -0,0 +1,33 @@
using Artemis.Core.LayerEffects;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
/// <summary>
/// Grows whatever the element renders outwards, in pre-processing. Real glow effects blur, this uses a dilate filter so
/// the edge of the glow is crisp and predictable.
/// </summary>
public class GlowTestEffect : LayerEffect<GlowTestEffectProperties>
{
public override void EnableLayerEffect()
{
}
public override void DisableLayerEffect()
{
}
public override void Update(double deltaTime)
{
}
public override void PreProcess(SKCanvas canvas, SKRect renderBounds, SKPaint paint)
{
float radius = Properties.Radius.CurrentValue;
paint.ImageFilter = SKImageFilter.CreateDilate(radius, radius, paint.ImageFilter);
}
public override void PostProcess(SKCanvas canvas, SKRect renderBounds, SKPaint paint)
{
}
}
@@ -0,0 +1,20 @@
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
public class GlowTestEffectProperties : LayerEffectPropertyGroup
{
public FloatLayerProperty Radius { get; set; } = null!;
protected override void PopulateDefaults()
{
// Keys are about 18 units wide with a 1 unit gap, 10 units reaches well into the neighbouring key
Radius.DefaultValue = 10;
}
protected override void EnableProperties()
{
}
protected override void DisableProperties()
{
}
}
@@ -0,0 +1,31 @@
using Artemis.Core.LayerEffects;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
/// <summary>
/// Lowers the alpha of the paint the element is composited with, in pre-processing
/// </summary>
public class OpacityTestEffect : LayerEffect<OpacityTestEffectProperties>
{
public override void EnableLayerEffect()
{
}
public override void DisableLayerEffect()
{
}
public override void Update(double deltaTime)
{
}
public override void PreProcess(SKCanvas canvas, SKRect renderBounds, SKPaint paint)
{
paint.Color = paint.Color.WithAlpha((byte) (paint.Color.Alpha * Properties.Opacity.CurrentValue / 100f));
}
public override void PostProcess(SKCanvas canvas, SKRect renderBounds, SKPaint paint)
{
}
}
@@ -0,0 +1,19 @@
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
public class OpacityTestEffectProperties : LayerEffectPropertyGroup
{
public FloatLayerProperty Opacity { get; set; } = null!;
protected override void PopulateDefaults()
{
Opacity.DefaultValue = 50;
}
protected override void EnableProperties()
{
}
protected override void DisableProperties()
{
}
}
@@ -0,0 +1,21 @@
using Artemis.Core.LayerEffects;
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
/// <summary>
/// Provides deterministic effects for render tests. The real effects live in the Artemis.Plugins repository.
/// </summary>
public class TestLayerEffectProvider : LayerEffectProvider
{
public override void Enable()
{
RegisterLayerEffectDescriptor<OpacityTestEffect>("Opacity", "Pre-processes the paint by lowering its alpha", "Opacity");
RegisterLayerEffectDescriptor<ChannelSwapTestEffect>("Channel swap", "Pre-processes the paint with a filter that swaps red and blue", "SwapHorizontal");
RegisterLayerEffectDescriptor<TintTestEffect>("Tint", "Post-processes the canvas by multiplying it with a color", "Palette");
RegisterLayerEffectDescriptor<GlowTestEffect>("Glow", "Pre-processes the paint with a filter that grows the content outwards", "Brightness7");
}
public override void Disable()
{
}
}
@@ -0,0 +1,32 @@
using Artemis.Core.LayerEffects;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
/// <summary>
/// Multiplies whatever was rendered with a color, in post-processing
/// </summary>
public class TintTestEffect : LayerEffect<TintTestEffectProperties>
{
public override void EnableLayerEffect()
{
}
public override void DisableLayerEffect()
{
}
public override void Update(double deltaTime)
{
}
public override void PreProcess(SKCanvas canvas, SKRect renderBounds, SKPaint paint)
{
}
public override void PostProcess(SKCanvas canvas, SKRect renderBounds, SKPaint paint)
{
using SKPaint tint = new() {Color = Properties.Color.CurrentValue, BlendMode = SKBlendMode.Modulate};
canvas.DrawRect(renderBounds, tint);
}
}
@@ -0,0 +1,21 @@
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
public class TintTestEffectProperties : LayerEffectPropertyGroup
{
public SKColorLayerProperty Color { get; set; } = null!;
protected override void PopulateDefaults()
{
Color.DefaultValue = new SKColor(255, 0, 0);
}
protected override void EnableProperties()
{
}
protected override void DisableProperties()
{
}
}
@@ -0,0 +1,69 @@
using Artemis.Core.Modules;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Plugins.Modules;
/// <summary>
/// A data model with a property of every kind data model paths, bindings and conditions deal with.
/// Tests set the values directly.
/// </summary>
public class TestDataModel : DataModel
{
public int Integer { get; set; }
public float Float { get; set; }
public double Double { get; set; }
public bool Boolean { get; set; }
public string? Text { get; set; }
public SKColor Color { get; set; }
public TestEnum Enum { get; set; }
/// <summary>
/// A nested data model, paths into it look like <c>Child.Integer</c>
/// </summary>
public TestChildDataModel Child { get; } = new();
/// <summary>
/// A nested plain object rather than a data model
/// </summary>
public TestItem Item { get; set; } = new();
public List<int> Integers { get; set; } = [];
public List<TestItem> Items { get; set; } = [];
/// <summary>
/// An event without arguments
/// </summary>
public DataModelEvent Event { get; } = new();
/// <summary>
/// An event with arguments, conditions can read them through paths like <c>EventWithArgs.Value</c>
/// </summary>
public DataModelEvent<TestEventArgs> EventWithArgs { get; } = new();
[DataModelIgnore]
public int Ignored { get; set; }
}
public class TestChildDataModel : DataModel
{
public int Integer { get; set; }
public string? Text { get; set; }
}
public class TestItem
{
public int Integer { get; set; }
public string? Text { get; set; }
}
public class TestEventArgs : DataModelEventArgs
{
public int Value { get; set; }
}
public enum TestEnum
{
First,
Second,
Third
}
@@ -0,0 +1,35 @@
using Artemis.Core.Modules;
namespace Artemis.Core.Tests.Infrastructure.Plugins.Modules;
/// <summary>
/// A module exposing a <see cref="TestDataModel" />. Add it with <see cref="TestPlugin.AddFeature{T}" />, which also
/// registers the data model the way the data model service does.
/// </summary>
public class TestModule : Module<TestDataModel>
{
/// <summary>
/// Gets or sets the activation requirements, none by default
/// </summary>
public List<IModuleActivationRequirement>? Requirements { get; set; }
/// <summary>
/// Gets the total time the module was updated for
/// </summary>
public double UpdatedFor { get; private set; }
public override List<IModuleActivationRequirement>? ActivationRequirements => Requirements;
public override void Enable()
{
}
public override void Disable()
{
}
public override void Update(double deltaTime)
{
UpdatedFor += deltaTime;
}
}
@@ -0,0 +1,33 @@
using Artemis.Core.Nodes;
using Artemis.Core.Tests.Infrastructure.VisualScripting;
namespace Artemis.Core.Tests.Infrastructure.Plugins.Nodes;
/// <summary>
/// Registers the nodes used by data binding and event condition tests in the node type store, so scripts using them can
/// be saved and loaded again.
/// </summary>
public class DataModelTestNodeProvider : NodeProvider
{
public override void Enable()
{
RegisterNodeType<TestDataModelNumberNode>();
RegisterNodeType<TestColorNode>();
RegisterNodeType<TestGreaterThanNode>();
}
public override void Disable()
{
}
/// <summary>
/// Creates a registered node, the way the editor does, and adds it to the script.
/// </summary>
public T AddNode<T>(INodeScript script) where T : INode
{
NodeData nodeData = NodeDescriptors.Single(d => d.Type == typeof(T));
T node = (T) nodeData.CreateNode(script, null);
script.AddNode(node);
return node;
}
}
@@ -0,0 +1,32 @@
using Artemis.Core.Nodes;
using Artemis.Core.Tests.Infrastructure.VisualScripting;
namespace Artemis.Core.Tests.Infrastructure.Plugins.Nodes;
/// <summary>
/// Registers the test node types in the node type store, so scripts using them can be saved and loaded again.
/// The real nodes live in the Artemis.Plugins repository.
/// </summary>
public class TestNodeProvider : NodeProvider
{
public override void Enable()
{
RegisterNodeType<TestBooleanNode>();
RegisterNodeType<TestNumericNode>();
}
public override void Disable()
{
}
/// <summary>
/// Creates a registered test node, the way the editor does, and adds it to the script.
/// </summary>
public T AddNode<T>(INodeScript script) where T : INode
{
NodeData nodeData = NodeDescriptors.Single(d => d.Type == typeof(T));
T node = (T) nodeData.CreateNode(script, null);
script.AddNode(node);
return node;
}
}
@@ -0,0 +1,75 @@
using System.Reflection;
using Artemis.Storage.Entities.Plugins;
using DryIoc;
namespace Artemis.Core.Tests.Infrastructure.Plugins;
/// <summary>
/// A plugin that hosts test features without going through plugin loading.
/// <para>
/// Features are enabled and disabled the way PluginFeature.SetEnabled does it, minus the lock files it writes to the
/// plugin folder. Modules get their data model registered the way the data model service does it.
/// </para>
/// </summary>
public sealed class TestPlugin : IDisposable
{
private static readonly MethodInfo OnEnabled = typeof(PluginFeature).GetMethod("OnEnabled", BindingFlags.NonPublic | BindingFlags.Instance)!;
private static readonly MethodInfo OnDisabled = typeof(PluginFeature).GetMethod("OnDisabled", BindingFlags.NonPublic | BindingFlags.Instance)!;
private readonly List<PluginFeature> _features = [];
public TestPlugin()
{
PluginInfo pluginInfo = new() {Guid = Guid.NewGuid(), Name = "Artemis.Core.Tests", Version = "1.0.0", Main = "Artemis.Core.Tests.dll"};
Plugin = new Plugin(pluginInfo, new DirectoryInfo(AppContext.BaseDirectory), new PluginEntity {PluginGuid = pluginInfo.Guid}, false)
{
// Same rules as ArtemisBootstrapper and PluginManagementService
Container = new Container(rules => rules.WithConcreteTypeDynamicRegistrations().WithoutThrowOnRegisteringDisposableTransient())
};
}
/// <summary>
/// Gets the underlying Artemis plugin
/// </summary>
public Plugin Plugin { get; }
/// <summary>
/// Adds the feature to the plugin and enables it.
/// </summary>
public T AddFeature<T>(T feature) where T : PluginFeature
{
PluginFeatureInfo featureInfo = new(Plugin, null, feature);
feature.Plugin = Plugin;
feature.Info = featureInfo;
feature.Profiler = Plugin.GetProfiler(typeof(T).Name);
Plugin.AddFeature(featureInfo);
feature.IsEnabled = true;
feature.InternalEnable();
OnEnabled.Invoke(feature, null);
_features.Add(feature);
// Mirrors DataModelService, which registers the data model of a module once it is enabled
if (feature is Core.Modules.Module {InternalDataModel: not null} module)
{
module.InternalDataModel.IsExpansion = module.IsAlwaysAvailable;
DataModelStore.Add(module.InternalDataModel);
}
return feature;
}
/// <inheritdoc />
public void Dispose()
{
// Raising Disabled makes every registration (brushes, effects, data models, node types) remove itself from its store
foreach (PluginFeature feature in _features)
{
feature.InternalDisable();
feature.IsEnabled = false;
OnDisabled.Invoke(feature, null);
}
Plugin.Container?.Dispose();
}
}
@@ -0,0 +1,26 @@
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Rendering;
public static class FrameAssertions
{
/// <summary>
/// Asserts that, compared to <paramref name="before" />, the affected LEDs changed according to
/// <paramref name="expected" /> and every other LED (apart from the ignored ones) stayed the same.
/// <para>Also asserts that most affected LEDs actually changed, to guard against a vacuous pass.</para>
/// </summary>
public static void ShouldOnlyDifferFrom(this RenderFrame after, RenderFrame before, IReadOnlyCollection<ArtemisLed> affected, Func<SKColor, SKColor> expected,
IReadOnlyCollection<ArtemisLed>? ignored = null, int tolerance = 3)
{
affected.Count(l => before.LedColors[l] != after.LedColors[l]).ShouldBeGreaterThan(affected.Count / 2, "Most of the affected LEDs should have changed");
foreach ((ArtemisLed led, SKColor beforeColor) in before.LedColors)
{
if (ignored != null && ignored.Contains(led))
continue;
SKColor expectedColor = affected.Contains(led) ? expected(beforeColor) : beforeColor;
after.LedColors[led].ShouldBeColor(expectedColor, tolerance, $"{led.RgbLed.Id} was {beforeColor}");
}
}
}
@@ -0,0 +1,70 @@
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Rendering;
/// <summary>
/// The result of rendering a single frame: the raw canvas and the colors that ended up on each LED.
/// </summary>
public sealed class RenderFrame : IDisposable
{
internal RenderFrame(TimeSpan time, SKBitmap canvas, IReadOnlyDictionary<ArtemisLed, SKColor> ledColors)
{
Time = time;
Canvas = canvas;
LedColors = ledColors;
}
/// <summary>
/// Gets the profile time at which this frame was rendered
/// </summary>
public TimeSpan Time { get; }
/// <summary>
/// Gets the raw canvas the profile rendered to, at the harness' render scale
/// </summary>
public SKBitmap Canvas { get; }
/// <summary>
/// Gets the color of each LED as sampled from the canvas, this is what the hardware would show
/// </summary>
public IReadOnlyDictionary<ArtemisLed, SKColor> LedColors { get; }
/// <summary>
/// Gets the color of the given LED on the given device
/// </summary>
public SKColor GetColor(ArtemisDevice device, LedId ledId)
{
ArtemisLed led = device.GetLed(ledId, false) ?? throw new ArgumentException($"Device {device} has no LED {ledId}", nameof(ledId));
return LedColors[led];
}
/// <summary>
/// Writes canvas.png, leds.png and leds.json to <c>render-output/&lt;test namespace&gt;/&lt;test class&gt;/&lt;name&gt;</c>
/// in the test project folder and attaches them to the test result.
/// </summary>
/// <returns>The folder the files were written to</returns>
public string Save(string name)
{
using RenderOutputWriter writer = RenderOutput.Begin(name);
writer.WritePng("canvas.png", Canvas);
using (SKBitmap leds = RenderOutput.DrawLeds(this))
writer.WritePng("leds.png", leds);
writer.WriteJson("leds.json", RenderOutput.ToJson(this));
return writer.Directory;
}
/// <summary>
/// Creates a copy that remains usable after this frame is disposed
/// </summary>
internal RenderFrame Copy()
{
return new RenderFrame(Time, Canvas.Copy(), new Dictionary<ArtemisLed, SKColor>(LedColors));
}
/// <inheritdoc />
public void Dispose()
{
Canvas.Dispose();
}
}
@@ -0,0 +1,333 @@
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;
/// <summary>
/// Renders profiles onto fake RGB.NET devices through Artemis' real render pipeline (<see cref="SurfaceManager" />,
/// <see cref="SKTexture" /> sampling and RGB.NET LED updates) without starting any services or loading plugins.
/// <para>Time is fully controlled by the harness, frames are only rendered when requested.</para>
/// </summary>
public sealed class RenderHarness : IRenderer, IDisposable
{
/// <summary>
/// The render scale Artemis uses by default
/// </summary>
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<ArtemisDevice> _devices = [];
private readonly List<Exception> _renderExceptions = [];
private readonly Dictionary<Led, SKColor> _deviceColors = [];
private readonly Dictionary<Profile, TimeSpan> _elapsed = [];
private readonly int _previousRenderScaleMultiplier;
private Action<SKCanvas>? _draw;
private SKBitmap? _lastCanvas;
private readonly List<Profile> _profiles = [];
private bool _disposed;
/// <param name="renderScale">The scale of the canvas relative to the surface, Artemis defaults to 0.5</param>
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);
}
/// <summary>
/// Gets the devices added to the harness
/// </summary>
public IReadOnlyList<ArtemisDevice> Devices => _devices;
/// <summary>
/// Adds a fake device based on an RGB.NET layout file, see <see cref="TestLayouts" /> for the layouts shipped with Artemis.
/// </summary>
/// <param name="layoutPath">The path to the layout XML file</param>
/// <param name="x">The X position of the device on the surface</param>
/// <param name="y">The Y position of the device on the surface</param>
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;
}
/// <summary>
/// Removes the device from the surface, like disabling a device in Artemis does.
/// </summary>
public void RemoveDevice(ArtemisDevice device)
{
_surfaceManager.RemoveDevices([device]);
_devices.Remove(device);
}
/// <summary>
/// Creates a new, empty profile containing only a root folder.
/// </summary>
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;
}
/// <summary>
/// Adds a layer to the provided parent that uses the provided test brush.
/// <para>
/// Note: like any layer created in code, its timeline starts at the end. Call <see cref="ProfileElement.Reset" />
/// on the profile (like the editor does) or use <see cref="SaveAndReload" /> when a test depends on the timeline
/// starting at the beginning.
/// </para>
/// </summary>
/// <param name="parent">The folder (or profile root folder) to add the layer to</param>
/// <param name="leds">The LEDs the layer should cover</param>
/// <param name="name">The name of the layer</param>
/// <param name="order">
/// The position among the parent's children (0-based), defaults to the end. The first child renders on top.
/// </param>
/// <typeparam name="TBrush">The type of test brush to use, must be registered by <see cref="TestLayerBrushProvider" /></typeparam>
/// <returns>The layer and its brush, the brush exposes the brush properties</returns>
public (Layer Layer, TBrush Brush) AddLayer<TBrush>(Folder parent, IEnumerable<ArtemisLed> leds, string name = "Test layer", int? order = null) where TBrush : BaseLayerBrush
{
// Note: despite its documentation 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<TBrush>(layer));
}
/// <summary>
/// Gives the layer a new instance of the provided test brush.
/// </summary>
/// <typeparam name="TBrush">The type of test brush to use, must be registered by <see cref="TestLayerBrushProvider" /></typeparam>
public TBrush ChangeBrush<TBrush>(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!;
}
/// <summary>
/// Adds an empty folder to the provided parent.
/// </summary>
/// <param name="parent">The folder (or profile root folder) to add the folder to</param>
/// <param name="name">The name of the folder</param>
/// <param name="order">
/// The position among the parent's children (0-based), defaults to the end. The first child renders on top.
/// </param>
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;
}
/// <summary>
/// Adds a test effect to the provided layer or folder.
/// </summary>
/// <typeparam name="TEffect">The type of test effect to use, must be registered by <see cref="TestLayerEffectProvider" /></typeparam>
public TEffect AddEffect<TEffect>(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;
}
/// <summary>
/// Saves the profile and loads it back into a new profile instance, the way Artemis stores and activates profiles.
/// </summary>
/// <returns>The reloaded profile, its time starts at zero</returns>
public Profile SaveAndReload(Profile profile)
{
return Load(Save(profile));
}
/// <summary>
/// Saves the profile the way Artemis stores it.
/// </summary>
/// <returns>A copy of the stored entity, detached from the profile</returns>
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<ProfileEntity>(JsonSerializer.Serialize(profile.ProfileEntity))!;
}
/// <summary>
/// Loads a stored profile onto the devices of this harness, the way Artemis activates profiles.
/// </summary>
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;
}
/// <summary>
/// Advances the profile by <paramref name="delta" /> and renders a single frame.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public RenderFrame Render(Action<SKCanvas> draw)
{
return RenderSurface(TimeSpan.Zero, draw);
}
private RenderFrame RenderSurface(TimeSpan time, Action<SKCanvas> 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<ArtemisLed, SKColor> 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);
}
/// <summary>
/// Renders the profile at each of the provided timestamps, advancing time between them.
/// <para>
/// Timestamps are the total time the harness rendered the profile for, each must be later than the time the
/// profile is currently at.
/// </para>
/// </summary>
public RenderTimeline RenderTimeline(Profile profile, params TimeSpan[] timestamps)
{
List<RenderFrame> frames = [];
foreach (TimeSpan timestamp in timestamps.Order())
frames.Add(Render(profile, timestamp - GetElapsed(profile)));
return new RenderTimeline(frames);
}
/// <summary>
/// Gets the total time the harness rendered the profile for
/// </summary>
public TimeSpan GetElapsed(Profile profile)
{
return _elapsed.GetValueOrDefault(profile);
}
/// <inheritdoc />
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());
}
}
@@ -0,0 +1,171 @@
using System.Collections.Concurrent;
using System.Globalization;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Rendering;
/// <summary>
/// Writes render results to disk and attaches them to the test result so they can be inspected by humans and LLMs.
/// </summary>
internal static class RenderOutput
{
private const float LedViewScale = 3;
private const int StripHeaderHeight = 24;
private static readonly ConcurrentDictionary<string, int> SaveCounts = [];
/// <summary>
/// Gets the root folder all output is written to, <c>render-output</c> in the test project folder
/// </summary>
public static string Root { get; } = Path.Combine(FindProjectDirectory(), "render-output");
/// <summary>
/// Gets a boolean indicating whether tests should write their render output even when they pass, enabled by setting
/// the <c>ARTEMIS_RENDER_OUTPUT</c> environment variable to <c>always</c>
/// </summary>
public static bool AlwaysWrite { get; } = string.Equals(Environment.GetEnvironmentVariable("ARTEMIS_RENDER_OUTPUT"), "always", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Starts a save under <c>render-output/&lt;test namespace&gt;/&lt;test class&gt;/&lt;name&gt;</c>, replacing previous
/// output of the same name.
/// </summary>
public static RenderOutputWriter Begin(string name)
{
// When a test saves under the same name more than once, number the later saves so they don't overwrite the first
int count = SaveCounts.AddOrUpdate($"{TestContext.Current.Test?.UniqueID}/{name}", 1, (_, c) => c + 1);
if (count > 1)
name = $"{name}_{count}";
string relativePath = Path.Combine(GetTestClassPath(), Sanitize(name));
string directory = Path.Combine(Root, relativePath);
if (Directory.Exists(directory))
Directory.Delete(directory, true);
Directory.CreateDirectory(directory);
return new RenderOutputWriter(directory, relativePath.Replace(Path.DirectorySeparatorChar, '/'));
}
/// <summary>
/// Draws every LED at its position on the surface, filled with the color it received and labelled with its ID
/// </summary>
public static SKBitmap DrawLeds(RenderFrame frame)
{
SKRect surface = frame.LedColors.Keys.Select(l => l.AbsoluteRectangle).Aggregate(SKRect.Union);
SKBitmap bitmap = new((int) Math.Ceiling(surface.Right * LedViewScale) + 1, (int) Math.Ceiling(surface.Bottom * LedViewScale) + 1);
using SKCanvas canvas = new(bitmap);
canvas.Clear(new SKColor(32, 32, 32));
using SKPaint fill = new() {IsAntialias = true};
using SKPaint border = new() {IsAntialias = true, Style = SKPaintStyle.Stroke, Color = new SKColor(80, 80, 80)};
using SKPaint text = new() {IsAntialias = true};
using SKFont font = new(SKTypeface.Default, 10);
foreach ((ArtemisLed led, SKColor color) in frame.LedColors)
{
SKRect rect = new(
led.AbsoluteRectangle.Left * LedViewScale + 1,
led.AbsoluteRectangle.Top * LedViewScale + 1,
led.AbsoluteRectangle.Right * LedViewScale - 1,
led.AbsoluteRectangle.Bottom * LedViewScale - 1
);
fill.Color = color.WithAlpha(255);
canvas.DrawRoundRect(rect, 3, 3, fill);
canvas.DrawRoundRect(rect, 3, 3, border);
// Pick a label color that stays readable on the LED color
text.Color = color.Red * 0.299 + color.Green * 0.587 + color.Blue * 0.114 > 128 ? SKColors.Black : SKColors.White;
canvas.DrawText(GetLabel(led), rect.Left + 3, rect.Top + font.Size + 2, font, text);
}
return bitmap;
}
/// <summary>
/// Stacks a bitmap per frame top to bottom, each with a header showing the frame time
/// </summary>
public static SKBitmap DrawStrip(IReadOnlyList<RenderFrame> frames, Func<RenderFrame, SKBitmap> draw)
{
List<SKBitmap> bitmaps = frames.Select(draw).ToList();
try
{
SKBitmap strip = new(bitmaps.Max(b => b.Width), bitmaps.Sum(b => b.Height + StripHeaderHeight));
using SKCanvas canvas = new(strip);
canvas.Clear(SKColors.Black);
using SKPaint text = new() {IsAntialias = true, Color = SKColors.White};
using SKFont font = new(SKTypeface.Default, 14);
float y = 0;
for (int i = 0; i < frames.Count; i++)
{
canvas.DrawText(string.Create(CultureInfo.InvariantCulture, $"t = {frames[i].Time.TotalSeconds:0.000}s"), 4, y + 17, font, text);
canvas.DrawBitmap(bitmaps[i], 0, y + StripHeaderHeight);
y += bitmaps[i].Height + StripHeaderHeight;
}
return strip;
}
finally
{
foreach (SKBitmap bitmap in bitmaps)
bitmap.Dispose();
}
}
public static object ToJson(RenderFrame frame)
{
return new
{
time = frame.Time.TotalSeconds,
devices = frame.LedColors
.GroupBy(l => l.Key.Device)
.Select(d => new
{
device = d.Key.RgbDevice.DeviceInfo.DeviceName,
leds = d.ToDictionary(l => l.Key.RgbLed.Id.ToString(), l => ToHex(l.Value))
})
};
}
private static string ToHex(SKColor color)
{
return color.Alpha == 255
? $"#{color.Red:X2}{color.Green:X2}{color.Blue:X2}"
: $"#{color.Alpha:X2}{color.Red:X2}{color.Green:X2}{color.Blue:X2}";
}
private static string GetLabel(ArtemisLed led)
{
string id = led.RgbLed.Id.ToString();
int separator = id.IndexOf('_');
return separator >= 0 ? id[(separator + 1)..] : id;
}
/// <summary>
/// Gets the path of the running test class relative to the root namespace, e.g. <c>Profiles/TimelineTests</c>
/// </summary>
private static string GetTestClassPath()
{
string? className = TestContext.Current.TestClass?.TestClassName;
if (className == null)
return string.Empty;
string rootNamespace = typeof(RenderOutput).Assembly.GetName().Name + ".";
if (className.StartsWith(rootNamespace))
className = className[rootNamespace.Length..];
return Path.Combine(className.Split('.').Select(Sanitize).ToArray());
}
private static string Sanitize(string name)
{
return string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
}
private static string FindProjectDirectory()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Artemis.Core.Tests.csproj")))
directory = directory.Parent;
return directory?.FullName ?? AppContext.BaseDirectory;
}
}
@@ -0,0 +1,66 @@
using System.Text;
using System.Text.Json;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Rendering;
/// <summary>
/// Writes the files of a single save to its output folder, attaches them to the test result and, once disposed, lists
/// them in the test output as clickable links.
/// </summary>
internal sealed class RenderOutputWriter : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new() {WriteIndented = true};
private readonly string _name;
private readonly List<string> _files = [];
public RenderOutputWriter(string directory, string name)
{
Directory = directory;
_name = name;
}
/// <summary>
/// Gets the folder the files are written to
/// </summary>
public string Directory { get; }
public void WritePng(string fileName, SKBitmap bitmap)
{
// Favour speed over size, the default compression takes about half again as long
using SKPixmap pixmap = bitmap.PeekPixels();
using SKData data = pixmap.Encode(new SKPngEncoderOptions(SKPngEncoderFilterFlags.NoFilters, 1));
Write(fileName, data.ToArray(), "image/png");
}
public void WriteJson(string fileName, object value)
{
Write(fileName, JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions), "application/json");
}
public void Dispose()
{
ITestOutputHelper? output = TestContext.Current.TestOutputHelper;
if (output == null)
return;
// File URIs are clickable in most test output windows, they are long so each gets a line of its own
StringBuilder builder = new();
builder.AppendLine($"Render output: {_name}");
builder.AppendLine($" folder: {new Uri(Directory).AbsoluteUri}");
foreach (string file in _files)
builder.AppendLine($" {Path.GetFileName(file)}: {new Uri(file).AbsoluteUri}");
output.WriteLine(builder.ToString());
}
private void Write(string fileName, byte[] data, string mediaType)
{
string path = Path.Combine(Directory, fileName);
File.WriteAllBytes(path, data);
_files.Add(path);
// Prefix with the output name, a test may save under multiple names
TestContext.Current.AddAttachment($"{Path.GetFileName(Directory)}/{fileName}", data, mediaType);
}
}
@@ -0,0 +1,118 @@
using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
using RGB.NET.Core;
namespace Artemis.Core.Tests.Infrastructure.Rendering;
/// <summary>
/// Base class for tests that render onto a single ANSI keyboard
/// </summary>
public abstract class RenderTestBase : IDisposable
{
private readonly List<IDisposable> _tracked = [];
private readonly List<(IDisposable Copy, Action Save)> _pendingOutput = [];
protected RenderTestBase()
{
Harness = new RenderHarness();
Keyboard = Harness.AddDevice(TestLayouts.KeyboardAnsi);
}
protected RenderHarness Harness { get; }
protected ArtemisDevice Keyboard { get; }
/// <summary>
/// Gets the name of the running test, useful as the name of saved render output
/// </summary>
protected static string TestName => TestContext.Current.TestCase?.TestMethodName ?? "Unknown";
/// <summary>
/// Gets the keyboard LED with the given ID
/// </summary>
protected ArtemisLed Led(LedId ledId)
{
return Keyboard.GetLed(ledId, false) ?? throw new ArgumentException($"The keyboard has no LED {ledId}", nameof(ledId));
}
/// <summary>
/// Gets the keyboard LEDs with the given IDs
/// </summary>
protected ArtemisLed[] Leds(params LedId[] ledIds)
{
return ledIds.Select(Led).ToArray();
}
/// <summary>
/// Gets the top letter row, Q through P, ordered left to right
/// </summary>
protected ArtemisLed[] TopLetterRow => Leds(
LedId.Keyboard_Q, LedId.Keyboard_W, LedId.Keyboard_E, LedId.Keyboard_R, LedId.Keyboard_T,
LedId.Keyboard_Y, LedId.Keyboard_U, LedId.Keyboard_I, LedId.Keyboard_O, LedId.Keyboard_P
);
/// <summary>
/// Advances the profile by the given amount of seconds and renders a frame, see <see cref="SaveOutput(RenderFrame, string?)" />.
/// </summary>
protected RenderFrame Render(Profile profile, double seconds = 0.1)
{
RenderFrame frame = Harness.Render(profile, TimeSpan.FromSeconds(seconds));
SaveOutput(frame);
return frame;
}
/// <summary>
/// Renders the profile at each of the given timestamps (in seconds), see <see cref="SaveOutput(RenderTimeline, string?)" />.
/// </summary>
protected RenderTimeline RenderTimeline(Profile profile, params double[] seconds)
{
RenderTimeline timeline = Harness.RenderTimeline(profile, seconds.Select(TimeSpan.FromSeconds).ToArray());
SaveOutput(timeline);
return timeline;
}
/// <summary>
/// Saves the frame as render output once the test finished, but only if the test failed or
/// <see cref="RenderOutput.AlwaysWrite" /> is enabled. Writing the output takes far longer than rendering.
/// </summary>
/// <param name="frame">The frame to save, may be disposed by the test</param>
/// <param name="name">The name to save the frame under, defaults to the name of the test</param>
protected void SaveOutput(RenderFrame frame, string? name = null)
{
string outputName = name ?? TestName;
RenderFrame copy = frame.Copy();
_pendingOutput.Add((copy, () => copy.Save(outputName)));
}
/// <inheritdoc cref="SaveOutput(RenderFrame, string?)" />
protected void SaveOutput(RenderTimeline timeline, string? name = null)
{
string outputName = name ?? TestName;
RenderTimeline copy = timeline.Copy();
_pendingOutput.Add((copy, () => copy.Save(outputName)));
}
/// <summary>
/// Disposes the provided object at the end of the test
/// </summary>
protected T Track<T>(T disposable) where T : IDisposable
{
_tracked.Add(disposable);
return disposable;
}
public void Dispose()
{
// The result of the test is known by the time the test class is disposed
bool writeOutput = RenderOutput.AlwaysWrite || TestContext.Current.TestState?.Result == TestResult.Failed;
foreach ((IDisposable copy, Action save) in _pendingOutput)
{
if (writeOutput)
save();
copy.Dispose();
}
foreach (IDisposable disposable in _tracked)
disposable.Dispose();
Harness.Dispose();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,50 @@
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Rendering;
/// <summary>
/// A sequence of frames rendered at increasing timestamps
/// </summary>
public sealed class RenderTimeline : IDisposable
{
internal RenderTimeline(IReadOnlyList<RenderFrame> frames)
{
Frames = frames;
}
public IReadOnlyList<RenderFrame> Frames { get; }
public RenderFrame this[int index] => Frames[index];
/// <summary>
/// Writes canvas-strip.png, leds-strip.png (all frames stacked top to bottom) and timeline.json to
/// <c>render-output/&lt;test namespace&gt;/&lt;test class&gt;/&lt;name&gt;</c> in the test project folder and attaches
/// them to the test result.
/// </summary>
/// <returns>The folder the files were written to</returns>
public string Save(string name)
{
using RenderOutputWriter writer = RenderOutput.Begin(name);
using (SKBitmap canvasStrip = RenderOutput.DrawStrip(Frames, f => f.Canvas.Copy()))
writer.WritePng("canvas-strip.png", canvasStrip);
using (SKBitmap ledStrip = RenderOutput.DrawStrip(Frames, RenderOutput.DrawLeds))
writer.WritePng("leds-strip.png", ledStrip);
writer.WriteJson("timeline.json", Frames.Select(RenderOutput.ToJson).ToList());
return writer.Directory;
}
/// <summary>
/// Creates a copy that remains usable after this timeline is disposed
/// </summary>
internal RenderTimeline Copy()
{
return new RenderTimeline(Frames.Select(f => f.Copy()).ToList());
}
/// <inheritdoc />
public void Dispose()
{
foreach (RenderFrame frame in Frames)
frame.Dispose();
}
}
@@ -0,0 +1,17 @@
using Artemis.Core.Tests.Infrastructure.Plugins.Modules;
namespace Artemis.Core.Tests.Infrastructure.Services;
/// <summary>
/// A test module that ships a default profile, like game modules do
/// </summary>
public class DefaultProfileTestModule : TestModule
{
/// <param name="category">The category the default profile goes in</param>
/// <param name="profilePath">The full path of an exported profile</param>
public DefaultProfileTestModule(DefaultCategoryName category, string profilePath)
{
// Before the module is enabled this is remembered, it is checked and added when the module is enabled
AddDefaultProfile(category, profilePath);
}
}
@@ -0,0 +1,78 @@
using Artemis.Core.Internal;
using Artemis.Core.LayerBrushes;
using Artemis.Core.LayerEffects;
using Artemis.Core.Services;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
using Artemis.Core.Tests.Infrastructure.Plugins.Nodes;
using Artemis.Core.Tests.Infrastructure.VisualScripting;
using Artemis.Storage.Entities.Profile;
using Microsoft.EntityFrameworkCore;
namespace Artemis.Core.Tests.Infrastructure.Services;
/// <summary>
/// Base class for tests on the profile service, with helpers to build profiles the way the editor does.
/// </summary>
public abstract class ProfileServiceTestBase : ServiceTestBase
{
protected IProfileService ProfileService => Resolve<IProfileService>();
/// <summary>
/// Gets the category the default categories end with
/// </summary>
protected ProfileCategory General => ProfileService.ProfileCategories.Single(c => c.Name == "General");
/// <summary>
/// Gets the categories as stored in the database, ordered by their order
/// </summary>
protected List<ProfileCategoryEntity> GetStoredCategories()
{
using var context = Services.CreateDbContext();
return context.ProfileCategories.Include(c => c.ProfileConfigurations).OrderBy(c => c.Order).ToList();
}
/// <summary>
/// Gets the profile configurations as stored in the database
/// </summary>
protected List<ProfileContainerEntity> GetStoredProfiles()
{
using var context = Services.CreateDbContext();
return context.ProfileContainers.ToList();
}
/// <summary>
/// Adds a layer covering the provided LEDs to the profile, using the test brush of the provided type.
/// </summary>
protected static Layer AddLayer<TBrush>(Profile profile, TestLayerBrushProvider brushes, IEnumerable<ArtemisLed> leds, string name = "Test layer") where TBrush : BaseLayerBrush
{
Layer layer = new(profile.GetRootFolder(), name);
profile.GetRootFolder().AddChild(layer);
layer.AddLeds(leds);
LayerBrushDescriptor descriptor = brushes.LayerBrushDescriptors.Single(d => d.LayerBrushType == typeof(TBrush));
layer.ChangeLayerBrush(descriptor.CreateInstance(layer, null));
return layer;
}
/// <summary>
/// Adds the test effect of the provided type to the element.
/// </summary>
protected static TEffect AddEffect<TEffect>(RenderProfileElement element, TestLayerEffectProvider effects) where TEffect : BaseLayerEffect
{
LayerEffectDescriptor descriptor = effects.LayerEffectDescriptors.Single(d => d.LayerEffectType == typeof(TEffect));
TEffect effect = (TEffect) descriptor.CreateInstance(element, null);
element.AddLayerEffect(effect);
return effect;
}
/// <summary>
/// Makes the activation condition of the profile configuration follow the value of the returned node
/// </summary>
protected static TestBooleanNode UseActivationCondition(ProfileConfiguration configuration, TestNodeProvider nodes, bool isMet)
{
TestBooleanNode node = nodes.AddNode<TestBooleanNode>(configuration.ActivationCondition);
node.Value = isMet;
configuration.ActivationCondition.Nodes.OfType<ExitNode<bool>>().Single().Input.ConnectTo(node.Output);
return node;
}
}
@@ -0,0 +1,160 @@
using Artemis.Core.DryIoc;
using Artemis.Core.Services;
using Artemis.Core.DryIoc.Factories;
using Artemis.Storage;
using DryIoc;
using DryIoc.Microsoft.DependencyInjection;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Artemis.Core.Tests.Infrastructure.Services;
/// <summary>
/// A DryIoc container with the Artemis.Core services registered the way the app registers them, but backed by a fresh
/// database in a temporary data folder and with logging going to the test output.
/// <para>
/// The database is a copy of one that is migrated once per test run, and the temporary folders are removed when the
/// test run ends rather than after every test. Both would otherwise make up most of the time service tests take.
/// </para>
/// <para>
/// Services are created when they are first resolved, nothing is initialized or started. Avoid anything that works
/// on <see cref="Constants.DataFolder" /> or <see cref="Constants.PluginsFolder" /> directly, such as loading plugins
/// or starting the web server, those still point at your real Artemis folders.
/// </para>
/// </summary>
public sealed class ServiceHarness : IDisposable
{
private static readonly string RunFolder = CreateRunFolder();
private static readonly Lazy<string> TemplateDatabase = new(CreateTemplateDatabase);
private readonly List<ProfileService> _profileServices = [];
private bool _disposed;
/// <param name="configure">Optionally replaces or adds registrations before anything is resolved</param>
public ServiceHarness(Action<IContainer>? configure = null)
{
DataFolder = Path.Combine(RunFolder, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(DataFolder);
File.Copy(TemplateDatabase.Value, Path.Combine(DataFolder, "artemis.db"));
// Same rules as ArtemisBootstrapper
Container = new Container(rules => rules
.WithMicrosoftDependencyInjectionRules()
.WithConcreteTypeDynamicRegistrations()
.WithoutThrowOnRegisteringDisposableTransient());
Container.RegisterCore();
// The real registrations use the database and log folder in your Artemis data folder
Container.RegisterDelegate(CreateDbContext, Reuse.Transient, ifAlreadyRegistered: IfAlreadyRegistered.Replace);
Container.Register<ILoggerFactory, TestLoggerFactory>(Reuse.Singleton, ifAlreadyRegistered: IfAlreadyRegistered.Replace);
// Remember profile services so their profiles can be disposed, see Dispose
Container.RegisterInitializer<ProfileService>((service, _) => _profileServices.Add(service));
configure?.Invoke(Container);
}
/// <summary>
/// Gets the temporary data folder containing the database, removed when the test run ends
/// </summary>
public string DataFolder { get; }
/// <summary>
/// Gets the container the services are registered in
/// </summary>
public IContainer Container { get; }
/// <summary>
/// Resolves a service, including protected services like the settings service which the container only hands to
/// classes outside the plugins folder.
/// </summary>
public T Resolve<T>() where T : notnull
{
return Container.New<ServiceResolver<T>>().Service;
}
/// <summary>
/// Creates a database context on the database of this harness, for checking what a service stored
/// </summary>
public ArtemisDbContext CreateDbContext()
{
return new ArtemisDbContext {DataFolder = DataFolder};
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
// Profiles subscribe to static events of Core's stores, dispose them like the profile service does when it
// deactivates a profile. Otherwise every profile of every test stays in memory and receives the store events of
// later tests
foreach (ProfileConfiguration configuration in _profileServices.SelectMany(s => s.ProfileCategories).SelectMany(c => c.ProfileConfigurations))
{
if (configuration.Profile is {Disposed: false} profile)
profile.Dispose();
configuration.Dispose();
}
Container.Dispose();
}
private static string CreateRunFolder()
{
string root = Path.Combine(Path.GetTempPath(), "Artemis.Core.Tests");
// Remove what interrupted test runs left behind, leaving recent folders alone since other test runs may be using them
if (Directory.Exists(root))
{
foreach (DirectoryInfo folder in new DirectoryInfo(root).GetDirectories().Where(d => d.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1)))
TryDelete(folder.FullName);
}
string runFolder = Path.Combine(root, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(runFolder);
AppDomain.CurrentDomain.ProcessExit += (_, _) =>
{
// SQLite keeps the database files open in its connection pool
SqliteConnection.ClearAllPools();
TryDelete(runFolder);
};
return runFolder;
}
private static string CreateTemplateDatabase()
{
// StorageManager only migrates the first database of a process, so migrate the template ourselves
string folder = Path.Combine(RunFolder, "template");
Directory.CreateDirectory(folder);
using (ArtemisDbContext context = new() {DataFolder = folder})
context.Database.Migrate();
// Close the template so it can be copied
SqliteConnection.ClearAllPools();
return Path.Combine(folder, "artemis.db");
}
private static void TryDelete(string folder)
{
try
{
Directory.Delete(folder, true);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// Left for the next test run, it is in the temp folder
}
}
}
/// <summary>
/// Receives a service through its constructor, the container hands protected services to it since it lives in the test
/// assembly rather than in the plugins folder.
/// </summary>
internal sealed class ServiceResolver<T>(T service)
{
public T Service { get; } = service;
}
@@ -0,0 +1,160 @@
using System.Reflection;
using DryIoc;
using Artemis.Core.Services;
using Artemis.Core.Tests.Infrastructure.Plugins;
using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
using RGB.NET.Layout;
namespace Artemis.Core.Tests.Infrastructure.Services;
/// <summary>
/// Base class for tests on Artemis.Core services. Gives every test a fresh <see cref="ServiceHarness" /> and a
/// <see cref="TestPlugin" /> that the plugin management service knows about, as if Artemis had loaded it from the
/// plugins folder.
/// <para>
/// Features are enabled through the plugin management service with <see cref="EnableFeature{T}" />, the way
/// Artemis enables them, so services listening for enabled and disabled features see them. When the test ends the
/// harness unloads the plugin like Artemis does on shutdown, which disables those features again.
/// </para>
/// </summary>
public abstract class ServiceTestBase : IDisposable
{
private static readonly FieldInfo PluginsField = typeof(PluginManagementService).GetField("_plugins", BindingFlags.NonPublic | BindingFlags.Instance)!;
private static readonly FieldInfo ActivationTimerField = typeof(ModuleService).GetField("_activationUpdateTimer", BindingFlags.NonPublic | BindingFlags.Instance)!;
private static readonly MethodInfo RenderScaleHandler = typeof(DeviceService).GetMethod("RenderScaleOnRenderScaleMultiplierChanged", BindingFlags.NonPublic | BindingFlags.Instance)!;
private IModuleService? _moduleService;
private bool _disposed;
protected ServiceTestBase()
{
Services = new ServiceHarness();
TestPlugin = new TestPlugin();
// Plugin loading reads the plugins folder, put the test plugin straight into the list of loaded plugins instead
TestPlugin.Plugin.SetEnabled(true);
List<Plugin> plugins = (List<Plugin>) PluginsField.GetValue(PluginManagement)!;
lock (plugins)
{
plugins.Add(TestPlugin.Plugin);
}
}
protected ServiceHarness Services { get; }
protected TestPlugin TestPlugin { get; }
protected IPluginManagementService PluginManagement => Services.Resolve<IPluginManagementService>();
protected T Resolve<T>() where T : notnull
{
return Services.Resolve<T>();
}
/// <summary>
/// Adds the feature to the test plugin and enables it through the plugin management service, like Artemis does when
/// a plugin is enabled. Services that listen for enabled features pick it up.
/// </summary>
protected T EnableFeature<T>(T feature) where T : PluginFeature
{
// Mirrors what PluginManagementService.EnablePlugin sets up before enabling a feature
PluginFeatureInfo featureInfo = new(TestPlugin.Plugin, null, feature);
feature.Info = featureInfo;
feature.Plugin = TestPlugin.Plugin;
feature.Profiler = TestPlugin.Plugin.GetProfiler("Feature - " + featureInfo.Name);
feature.Entity = featureInfo.Entity;
TestPlugin.Plugin.AddFeature(featureInfo);
// Note: enabling briefly creates a lock file next to the test assembly, the plugin's folder
PluginManagement.EnablePluginFeature(feature, false, false);
return feature;
}
/// <summary>
/// Disables the feature through the plugin management service, like a user disabling it in the settings does.
/// </summary>
protected void DisableFeature(PluginFeature feature)
{
PluginManagement.DisablePluginFeature(feature, false);
}
/// <summary>
/// Creates fake devices from layout files and adds them through the device service, the way device provider
/// plugins add their devices when they are enabled.
/// </summary>
/// <param name="layoutPaths">The layouts of the devices, one device per layout, see <see cref="TestLayouts" /></param>
/// <returns>The device provider, its devices are on <see cref="IDeviceService.Devices" /></returns>
protected TestDeviceProvider AddDeviceProvider(params string[] layoutPaths)
{
return AddDeviceProvider(new TestDeviceProvider(), layoutPaths);
}
/// <summary>
/// Enables the device provider and adds devices created from layout files through the device service.
/// <para>
/// Note: features of the same type share their ID, which the device service uses to tell providers apart, use
/// a different type of provider for each provider in a test.
/// </para>
/// </summary>
protected T AddDeviceProvider<T>(T deviceProvider, params string[] layoutPaths) where T : TestDeviceProvider
{
EnableFeature(deviceProvider);
foreach (string layoutPath in layoutPaths)
{
DeviceLayout layout = DeviceLayout.Load(layoutPath) ?? throw new ArgumentException($"Failed to load layout {layoutPath}", nameof(layoutPaths));
deviceProvider.CreateDevice(layout, (_, _) => { });
}
// The device service initializes the provider again, which recreates the devices from the same layouts
Resolve<IDeviceService>().AddDeviceProvider(deviceProvider);
return deviceProvider;
}
/// <summary>
/// Creates a new profile service on the same database, like starting Artemis again
/// </summary>
protected IProfileService CreateNewProfileService()
{
return Services.Container.New<ProfileService>();
}
/// <summary>
/// Gets the module service with its 2 second activation timer stopped, tests call
/// <see cref="IModuleService.UpdateModuleActivation" /> themselves. Use this rather than <see cref="Resolve{T}" />.
/// </summary>
protected IModuleService ResolveModuleService()
{
_moduleService ??= Resolve<IModuleService>();
((System.Timers.Timer) ActivationTimerField.GetValue(_moduleService)!).Stop();
return _moduleService;
}
public virtual void Dispose()
{
if (_disposed)
return;
_disposed = true;
// Neither service is disposable, stop what would otherwise outlive the test. The module service's timer keeps
// running and the device service stays subscribed to the static render scale event
if (_moduleService != null)
((System.Timers.Timer) ActivationTimerField.GetValue(_moduleService)!).Dispose();
RenderScale.RenderScaleMultiplierChanged -= (EventHandler) Delegate.CreateDelegate(typeof(EventHandler), Resolve<IDeviceService>(), RenderScaleHandler);
// Disposing the plugin management service unloads its plugins, which forces two full garbage collections per
// plugin. Disable the test plugin's features the way unloading does and take the plugin out of the list instead
foreach (PluginFeatureInfo featureInfo in TestPlugin.Plugin.Features)
{
if (featureInfo.Instance is {IsEnabled: true} feature)
PluginManagement.DisablePluginFeature(feature, false);
}
List<Plugin> plugins = (List<Plugin>) PluginsField.GetValue(PluginManagement)!;
lock (plugins)
{
plugins.Remove(TestPlugin.Plugin);
}
Services.Dispose();
TestPlugin.Dispose();
}
}
@@ -0,0 +1,25 @@
using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
namespace Artemis.Core.Tests.Infrastructure.Services;
/// <summary>
/// A test device provider that supports being suspended, like providers that need to let go of the hardware while the
/// computer sleeps
/// </summary>
public class SuspendableTestDeviceProvider : TestDeviceProvider
{
public SuspendableTestDeviceProvider()
{
SuspendSupported = true;
}
/// <summary>
/// Gets the number of times the provider was suspended
/// </summary>
public int Suspended { get; private set; }
public override void Suspend()
{
Suspended++;
}
}
@@ -0,0 +1,18 @@
using Artemis.Core.Services;
namespace Artemis.Core.Tests.Infrastructure.Services;
/// <summary>
/// An input provider the test presses keys on, the real providers hook into the operating system
/// </summary>
public class TestInputProvider : InputProvider
{
/// <summary>
/// Presses and releases the key
/// </summary>
public void Press(KeyboardKey key)
{
OnKeyboardDataReceived(null, key, true);
OnKeyboardDataReceived(null, key, false);
}
}
@@ -0,0 +1,31 @@
using Artemis.Core.DryIoc.Factories;
using Serilog;
using Serilog.Core;
using Serilog.Events;
namespace Artemis.Core.Tests.Infrastructure.Services;
/// <summary>
/// Creates loggers that write to the output of the running test instead of to log files in the Artemis data folder.
/// </summary>
internal sealed class TestLoggerFactory : ILoggerFactory
{
private readonly ILogger _logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Sink(new TestOutputSink())
.CreateLogger();
public ILogger CreateLogger(Type type)
{
return _logger.ForContext(type);
}
private sealed class TestOutputSink : ILogEventSink
{
public void Emit(LogEvent logEvent)
{
string source = logEvent.Properties.TryGetValue(Serilog.Core.Constants.SourceContextPropertyName, out LogEventPropertyValue? value) ? value.ToString().Trim('"') : "";
TestContext.Current.TestOutputHelper?.WriteLine($"[{logEvent.Level}] {source}: {logEvent.RenderMessage()}{(logEvent.Exception != null ? Environment.NewLine + logEvent.Exception : "")}");
}
}
}
@@ -0,0 +1,23 @@
using Artemis.Core.Nodes;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.Services;
/// <summary>
/// A node provider that only registers pin type colors, the way node plugins give their pin types a color
/// </summary>
public class TypeColorTestNodeProvider : NodeProvider
{
public static readonly SKColor NumberColor = new(10, 20, 30);
public static readonly SKColor TextColor = new(40, 50, 60);
public override void Enable()
{
RegisterTypeColor<Numeric>(NumberColor);
RegisterTypeColor<string>(TextColor);
}
public override void Disable()
{
}
}
@@ -0,0 +1,93 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Artemis.Core.Tests.Infrastructure.Storage;
public static class JsonTestExtensions
{
private static readonly JsonSerializerOptions Indented = new() {WriteIndented = true};
/// <summary>
/// Checks that the JSON holds the same values as the expected JSON, the order of properties doesn't matter.
/// Shows both when they differ.
/// </summary>
public static void ShouldBeJson(this JsonNode? actual, string expected)
{
JsonNode? expectedNode = JsonNode.Parse(expected);
if (JsonNode.DeepEquals(actual, expectedNode))
return;
throw new ShouldAssertException($"""
The JSON is different from what was expected.
Expected:
{expectedNode?.ToJsonString(Indented)}
Actual:
{actual?.ToJsonString(Indented)}
""");
}
/// <summary>
/// Finds every node of every node script in the JSON, in old and new profiles alike
/// </summary>
public static List<JsonObject> FindNodes(this JsonNode root)
{
List<JsonObject> nodes = [];
Collect(root);
return nodes;
void Collect(JsonNode? node)
{
switch (node)
{
case JsonObject jsonObject:
if (jsonObject.ContainsKey("IsExitNode"))
nodes.Add(jsonObject);
foreach ((string _, JsonNode? value) in jsonObject)
Collect(value);
break;
case JsonArray jsonArray:
foreach (JsonNode? value in jsonArray)
Collect(value);
break;
}
}
}
/// <summary>
/// Finds the node with the given ID anywhere in the JSON
/// </summary>
public static JsonObject FindNode(this JsonNode root, string id)
{
return root.FindNodes().Single(n => n["Id"]?.GetValue<string>() == id);
}
/// <summary>
/// Gets the text of every <c>$type</c> left anywhere in the JSON
/// </summary>
public static List<string> FindTypeNames(this JsonNode root)
{
List<string> types = [];
Collect(root);
return types;
void Collect(JsonNode? node)
{
switch (node)
{
case JsonObject jsonObject:
foreach ((string key, JsonNode? value) in jsonObject)
{
if (key == "$type" && value != null)
types.Add(value.ToString());
Collect(value);
}
break;
case JsonArray jsonArray:
foreach (JsonNode? value in jsonArray)
Collect(value);
break;
}
}
}
}
@@ -0,0 +1,866 @@
using System.Text.Json.Nodes;
namespace Artemis.Core.Tests.Infrastructure.Storage;
/// <summary>
/// Profile JSON the way older versions of Artemis stored and exported it, for testing the profile migrations.
/// <para>
/// Before version 3 profiles were written by JSON.NET with type names turned on: every object starts with a
/// <c>$type</c> naming its .NET class and every list is wrapped in <c>{"$type": ..., "$values": [...]}</c>. Node
/// storage is a JSON string inside the JSON, written the same way. Before version 1 the general nodes lived in
/// the <c>Artemis.VisualScripting.Nodes</c> namespace and nodes had no <c>ProviderId</c>.
/// </para>
/// </summary>
public static class OldProfileJson
{
public const string ProfileId = "8f6a1c2e-8d0b-4b8e-9c6e-2f2d3f1b7a10";
public const string CategoryId = "c4f1d1a0-5b3e-4a7d-8e9f-0a1b2c3d4e5f";
public const string GeneralNodesProviderId = "Artemis.Plugins.Nodes.General.GeneralNodesProvider-d9e1ee78";
// The nodes in the JSON below, by where they are
public const string ActivationExitNodeId = "a0000000-0000-0000-0000-000000000001";
public const string ActivationDataModelNodeId = "a0000000-0000-0000-0000-000000000002";
public const string FolderEffectNodeId = "b0000000-0000-0000-0000-000000000001";
public const string FolderConditionNodeId = "c0000000-0000-0000-0000-000000000001";
public const string LayerConditionNodeId = "c0000000-0000-0000-0000-000000000002";
public const string LayerTransformNodeId = "d0000000-0000-0000-0000-000000000001";
public const string LayerBrushNodeId = "e0000000-0000-0000-0000-000000000001";
/// <summary>
/// A profile configuration from before version 1, it has no <c>Version</c> yet. Its activation condition reads a
/// data model value.
/// </summary>
public const string Version0Configuration =
"""
{
"$type": "Artemis.Storage.Entities.Profile.ProfileConfigurationEntity, Artemis.Storage",
"Name": "Old profile",
"MaterialIcon": "Keyboard",
"IconType": 0,
"IconFill": false,
"Order": 0,
"IsSuspended": false,
"ActivationBehaviour": 1,
"ActivationCondition": {
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeScriptEntity, Artemis.Storage",
"Name": "Activate profile",
"Description": "Whether or not the profile should be active",
"Nodes": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage",
"Id": "a0000000-0000-0000-0000-000000000001",
"Type": "Artemis.Core.Internal.ExitNode`1",
"Name": "Activate profile",
"Description": "Whether or not the profile should be active",
"IsExitNode": true,
"X": 300.0,
"Y": 0.0,
"Storage": "",
"PinCollections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodePinCollectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
},
{
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage",
"Id": "a0000000-0000-0000-0000-000000000002",
"Type": "Artemis.VisualScripting.Nodes.DataModel.DataModelNode",
"Name": "Data Model-Value",
"Description": "Outputs a selectable data model value",
"IsExitNode": false,
"X": 0.0,
"Y": 0.0,
"Storage": "{\"$type\":\"Artemis.Storage.Entities.Profile.DataModelPathEntity, Artemis.Storage\",\"Path\":\"IsInGame\",\"DataModelId\":\"Artemis.Plugins.Modules.Game-12345678\"}",
"PinCollections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodePinCollectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
]
},
"Connections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodeConnectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeConnectionEntity, Artemis.Storage",
"SourceType": "System.Boolean",
"SourceNode": "a0000000-0000-0000-0000-000000000002",
"TargetNode": "a0000000-0000-0000-0000-000000000001",
"SourcePinCollectionId": 0,
"SourcePinId": 0,
"TargetType": "System.Boolean",
"TargetPinCollectionId": 0,
"TargetPinId": 0
}
]
}
},
"HotkeyMode": 1,
"EnableHotkey": {
"$type": "Artemis.Storage.Entities.Profile.ProfileConfigurationHotkeyEntity, Artemis.Storage",
"Key": 44,
"Modifiers": 2
},
"DisableHotkey": null,
"ModuleId": "Artemis.Plugins.Modules.Game-12345678",
"ProfileCategoryId": "c4f1d1a0-5b3e-4a7d-8e9f-0a1b2c3d4e5f",
"ProfileId": "8f6a1c2e-8d0b-4b8e-9c6e-2f2d3f1b7a10",
"FadeInAndOut": true
}
""";
/// <summary>
/// A profile from before version 1 with nodes in every place the migrations look for them:
/// <list type="bullet">
/// <item>the static display condition of the root folder</item>
/// <item>a data binding on a property of the blur effect on the root folder</item>
/// <item>the event display condition of the layer</item>
/// <item>a data binding in the transform properties of the layer</item>
/// <item>a data binding in a property group inside the brush properties of the layer</item>
/// </list>
/// The layer also has adaption hints and LEDs, whose type names have to survive the conversion.
/// </summary>
public const string Version0Profile =
"""
{
"$type": "Artemis.Storage.Entities.Profile.ProfileEntity, Artemis.Storage",
"Id": "8f6a1c2e-8d0b-4b8e-9c6e-2f2d3f1b7a10",
"Name": "Old profile",
"IsFreshImport": false,
"Folders": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.FolderEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.FolderEntity, Artemis.Storage",
"Order": 1,
"Name": "Root folder",
"IsExpanded": true,
"Suspended": false,
"Id": "f0000000-0000-0000-0000-000000000001",
"ParentId": "8f6a1c2e-8d0b-4b8e-9c6e-2f2d3f1b7a10",
"LayerEffects": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.LayerEffectEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.LayerEffectEntity, Artemis.Storage",
"ProviderId": "Artemis.Plugins.LayerEffects.Filter.FilterEffectProvider-0c6e3b7d",
"EffectType": "BlurEffect",
"Name": "Blur",
"HasBeenRenamed": false,
"Order": 1,
"PropertyGroup": {
"$type": "Artemis.Storage.Entities.Profile.PropertyGroupEntity, Artemis.Storage",
"Identifier": "LayerEffect",
"Properties": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.PropertyEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.PropertyEntity, Artemis.Storage",
"Identifier": "BlurAmount",
"Value": "{\"Width\":10.0,\"Height\":10.0}",
"KeyframesEnabled": false,
"DataBinding": {
"$type": "Artemis.Storage.Entities.Profile.DataBindings.DataBindingEntity, Artemis.Storage",
"IsEnabled": true,
"NodeScript": {
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeScriptEntity, Artemis.Storage",
"Name": "Blur amount",
"Description": "The data binding of the blur amount",
"Nodes": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage",
"Id": "b0000000-0000-0000-0000-000000000001",
"Type": "Artemis.VisualScripting.Nodes.Mathematics.MathExpressionNode",
"Name": "Math Expression",
"Description": "Outputs the result of a math expression",
"IsExitNode": false,
"X": 0.0,
"Y": 0.0,
"Storage": "\"1 + 1\"",
"PinCollections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodePinCollectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodePinCollectionEntity, Artemis.Storage",
"Id": 0,
"Direction": 0,
"Amount": 2
}
]
}
}
]
},
"Connections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodeConnectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
},
"KeyframeEntities": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.KeyframeEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
]
},
"PropertyGroups": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.PropertyGroupEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
}
]
},
"DisplayCondition": {
"$type": "Artemis.Storage.Entities.Profile.Conditions.StaticConditionEntity, Artemis.Storage",
"PlayMode": 0,
"StopMode": 1,
"Script": {
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeScriptEntity, Artemis.Storage",
"Name": "Display condition",
"Description": "Whether or not the folder should be displayed",
"Nodes": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage",
"Id": "c0000000-0000-0000-0000-000000000001",
"Type": "Artemis.VisualScripting.Nodes.DataModel.DataModelNode",
"Name": "Data Model-Value",
"Description": "Outputs a selectable data model value",
"IsExitNode": false,
"X": 0.0,
"Y": 0.0,
"Storage": "{\"$type\":\"Artemis.Storage.Entities.Profile.DataModelPathEntity, Artemis.Storage\",\"Path\":\"Player.IsAlive\",\"DataModelId\":\"Artemis.Plugins.Modules.Game-12345678\"}",
"PinCollections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodePinCollectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
]
},
"Connections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodeConnectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
},
"Timeline": {
"$type": "Artemis.Storage.Entities.Profile.TimelineEntity, Artemis.Storage",
"StartSegmentLength": "00:00:00",
"MainSegmentLength": "00:00:05",
"EndSegmentLength": "00:00:00"
}
}
]
},
"Layers": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.LayerEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.LayerEntity, Artemis.Storage",
"Order": 1,
"Name": "Gradient layer",
"Suspended": false,
"Leds": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.LedEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.LedEntity, Artemis.Storage",
"LedName": "Keyboard_A",
"DeviceIdentifier": "Artemis.Plugins.Devices.Test-Keyboard",
"PhysicalLayout": 1
},
{
"$type": "Artemis.Storage.Entities.Profile.LedEntity, Artemis.Storage",
"LedName": "Keyboard_S",
"DeviceIdentifier": "Artemis.Plugins.Devices.Test-Keyboard",
"PhysicalLayout": null
}
]
},
"AdaptionHints": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.AdaptionHints.IAdaptionHintEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.AdaptionHints.CategoryAdaptionHintEntity, Artemis.Storage",
"Category": 1,
"LimitAmount": true,
"Skip": 0,
"Amount": 1
},
{
"$type": "Artemis.Storage.Entities.Profile.AdaptionHints.KeyboardSectionAdaptionHintEntity, Artemis.Storage",
"Section": 2
}
]
},
"GeneralPropertyGroup": {
"$type": "Artemis.Storage.Entities.Profile.PropertyGroupEntity, Artemis.Storage",
"Identifier": "General",
"Properties": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.PropertyEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.PropertyEntity, Artemis.Storage",
"Identifier": "ShapeType",
"Value": "1",
"KeyframesEnabled": false,
"DataBinding": null,
"KeyframeEntities": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.KeyframeEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
]
},
"PropertyGroups": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.PropertyGroupEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
},
"TransformPropertyGroup": {
"$type": "Artemis.Storage.Entities.Profile.PropertyGroupEntity, Artemis.Storage",
"Identifier": "Transform",
"Properties": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.PropertyEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.PropertyEntity, Artemis.Storage",
"Identifier": "Opacity",
"Value": "100.0",
"KeyframesEnabled": true,
"DataBinding": {
"$type": "Artemis.Storage.Entities.Profile.DataBindings.DataBindingEntity, Artemis.Storage",
"IsEnabled": false,
"NodeScript": {
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeScriptEntity, Artemis.Storage",
"Name": "Opacity",
"Description": "The data binding of the opacity",
"Nodes": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage",
"Id": "d0000000-0000-0000-0000-000000000001",
"Type": "Artemis.VisualScripting.Nodes.Static.StaticNumericValueNode",
"Name": "Numeric-Value",
"Description": "Outputs a configurable static numeric value",
"IsExitNode": false,
"X": 0.0,
"Y": 0.0,
"Storage": "50.0",
"PinCollections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodePinCollectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
]
},
"Connections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodeConnectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
},
"KeyframeEntities": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.KeyframeEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.KeyframeEntity, Artemis.Storage",
"Position": "00:00:01",
"Timeline": 0,
"Value": "25.0",
"EasingFunction": 3
}
]
}
}
]
},
"PropertyGroups": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.PropertyGroupEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
},
"LayerBrush": {
"$type": "Artemis.Storage.Entities.Profile.LayerBrushEntity, Artemis.Storage",
"ProviderId": "Artemis.Plugins.LayerBrushes.Color.ColorBrushProvider-ed2e1c49",
"BrushType": "ColorBrush",
"PropertyGroup": {
"$type": "Artemis.Storage.Entities.Profile.PropertyGroupEntity, Artemis.Storage",
"Identifier": "LayerBrush",
"Properties": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.PropertyEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
},
"PropertyGroups": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.PropertyGroupEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.PropertyGroupEntity, Artemis.Storage",
"Identifier": "Colors",
"Properties": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.PropertyEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.PropertyEntity, Artemis.Storage",
"Identifier": "Gradient",
"Value": "[{\"Color\":\"#ffff0000\",\"Position\":0.0},{\"Color\":\"#ff0000ff\",\"Position\":1.0}]",
"KeyframesEnabled": false,
"DataBinding": {
"$type": "Artemis.Storage.Entities.Profile.DataBindings.DataBindingEntity, Artemis.Storage",
"IsEnabled": true,
"NodeScript": {
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeScriptEntity, Artemis.Storage",
"Name": "Gradient",
"Description": "The data binding of the gradient",
"Nodes": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage",
"Id": "e0000000-0000-0000-0000-000000000001",
"Type": "Artemis.VisualScripting.Nodes.Color.ColorGradientNode",
"Name": "Color Gradient",
"Description": "Outputs a color gradient with the given colors",
"IsExitNode": false,
"X": 0.0,
"Y": 0.0,
"Storage": "{\"$type\":\"Artemis.Core.ColorGradient, Artemis.Core\",\"$values\":[{\"$type\":\"Artemis.Core.ColorGradientStop, Artemis.Core\",\"Color\":\"#ffff0000\",\"Position\":0.0},{\"$type\":\"Artemis.Core.ColorGradientStop, Artemis.Core\",\"Color\":\"#ff0000ff\",\"Position\":1.0}]}",
"PinCollections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodePinCollectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
]
},
"Connections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodeConnectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
},
"KeyframeEntities": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.KeyframeEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
]
},
"PropertyGroups": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.PropertyGroupEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
]
}
}
},
"Id": "f0000000-0000-0000-0000-000000000002",
"ParentId": "f0000000-0000-0000-0000-000000000001",
"LayerEffects": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.LayerEffectEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
},
"DisplayCondition": {
"$type": "Artemis.Storage.Entities.Profile.Conditions.EventConditionEntity, Artemis.Storage",
"TriggerMode": 0,
"OverlapMode": 1,
"ToggleOffMode": 0,
"EventPath": {
"$type": "Artemis.Storage.Entities.Profile.DataModelPathEntity, Artemis.Storage",
"Path": "Player.Died",
"DataModelId": "Artemis.Plugins.Modules.Game-12345678"
},
"Script": {
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeScriptEntity, Artemis.Storage",
"Name": "Event condition",
"Description": "Whether or not the event should trigger",
"Nodes": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": [
{
"$type": "Artemis.Storage.Entities.Profile.Nodes.NodeEntity, Artemis.Storage",
"Id": "c0000000-0000-0000-0000-000000000002",
"Type": "Artemis.VisualScripting.Nodes.Static.StaticBooleanValueNode",
"Name": "Boolean-Value",
"Description": "Outputs a configurable static boolean value",
"IsExitNode": false,
"X": 0.0,
"Y": 0.0,
"Storage": "true",
"PinCollections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodePinCollectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
]
},
"Connections": {
"$type": "System.Collections.Generic.List`1[[Artemis.Storage.Entities.Profile.Nodes.NodeConnectionEntity, Artemis.Storage]], System.Private.CoreLib",
"$values": []
}
}
},
"Timeline": {
"$type": "Artemis.Storage.Entities.Profile.TimelineEntity, Artemis.Storage",
"StartSegmentLength": "00:00:00",
"MainSegmentLength": "00:00:02",
"EndSegmentLength": "00:00:01"
}
}
]
}
}
""";
/// <summary>
/// <see cref="Version0Configuration" /> after all migrations, as stored by the current version
/// </summary>
public const string MigratedConfiguration =
"""
{
"Name": "Old profile",
"MaterialIcon": "Keyboard",
"IconType": 0,
"IconFill": false,
"Order": 0,
"IsSuspended": false,
"ActivationBehaviour": 1,
"ActivationCondition": {
"Name": "Activate profile",
"Description": "Whether or not the profile should be active",
"Nodes": [
{
"Id": "a0000000-0000-0000-0000-000000000001",
"Type": "Artemis.Core.Internal.ExitNode`1",
"Name": "Activate profile",
"Description": "Whether or not the profile should be active",
"IsExitNode": true,
"X": 300.0,
"Y": 0.0,
"Storage": "",
"PinCollections": [],
"ProviderId": "Artemis.Plugins.Nodes.General.GeneralNodesProvider-d9e1ee78"
},
{
"Id": "a0000000-0000-0000-0000-000000000002",
"Type": "Artemis.Plugins.Nodes.General.Nodes.DataModel.DataModelNode",
"Name": "Data Model-Value",
"Description": "Outputs a selectable data model value",
"IsExitNode": false,
"X": 0.0,
"Y": 0.0,
"Storage": "{\"Path\":\"IsInGame\",\"DataModelId\":\"Artemis.Plugins.Modules.Game-12345678\"}",
"PinCollections": [],
"ProviderId": "Artemis.Plugins.Nodes.General.GeneralNodesProvider-d9e1ee78"
}
],
"Connections": [
{
"SourceType": "System.Boolean",
"SourceNode": "a0000000-0000-0000-0000-000000000002",
"TargetNode": "a0000000-0000-0000-0000-000000000001",
"SourcePinCollectionId": 0,
"SourcePinId": 0,
"TargetType": "System.Boolean",
"TargetPinCollectionId": 0,
"TargetPinId": 0
}
]
},
"HotkeyMode": 1,
"EnableHotkey": {
"Key": 44,
"Modifiers": 2
},
"DisableHotkey": null,
"ModuleId": "Artemis.Plugins.Modules.Game-12345678",
"ProfileCategoryId": "c4f1d1a0-5b3e-4a7d-8e9f-0a1b2c3d4e5f",
"ProfileId": "8f6a1c2e-8d0b-4b8e-9c6e-2f2d3f1b7a10",
"FadeInAndOut": true,
"Version": 4
}
""";
/// <summary>
/// <see cref="Version0Profile" /> after all migrations, as stored by the current version
/// </summary>
public const string MigratedProfile =
"""
{
"Id": "8f6a1c2e-8d0b-4b8e-9c6e-2f2d3f1b7a10",
"Name": "Old profile",
"IsFreshImport": false,
"Folders": [
{
"Order": 1,
"Name": "Root folder",
"IsExpanded": true,
"Suspended": false,
"Id": "f0000000-0000-0000-0000-000000000001",
"ParentId": "8f6a1c2e-8d0b-4b8e-9c6e-2f2d3f1b7a10",
"LayerEffects": [
{
"ProviderId": "Artemis.Plugins.LayerEffects.Filter.FilterEffectProvider-0c6e3b7d",
"EffectType": "BlurEffect",
"Name": "Blur",
"HasBeenRenamed": false,
"Order": 1,
"PropertyGroup": {
"Identifier": "LayerEffect",
"Properties": [
{
"Identifier": "BlurAmount",
"Value": "{\"Width\":10.0,\"Height\":10.0}",
"KeyframesEnabled": false,
"DataBinding": {
"IsEnabled": true,
"NodeScript": {
"Name": "Blur amount",
"Description": "The data binding of the blur amount",
"Nodes": [
{
"Id": "b0000000-0000-0000-0000-000000000001",
"Type": "Artemis.Plugins.Nodes.General.Nodes.Mathematics.MathExpressionNode",
"Name": "Math Expression",
"Description": "Outputs the result of a math expression",
"IsExitNode": false,
"X": 0.0,
"Y": 0.0,
"Storage": "\"1 + 1\"",
"PinCollections": [
{
"Id": 0,
"Direction": 0,
"Amount": 2
}
],
"ProviderId": "Artemis.Plugins.Nodes.General.GeneralNodesProvider-d9e1ee78"
}
],
"Connections": []
}
},
"KeyframeEntities": []
}
],
"PropertyGroups": []
}
}
],
"DisplayCondition": {
"$type": "Static",
"PlayMode": 0,
"StopMode": 1,
"Script": {
"Name": "Display condition",
"Description": "Whether or not the folder should be displayed",
"Nodes": [
{
"Id": "c0000000-0000-0000-0000-000000000001",
"Type": "Artemis.Plugins.Nodes.General.Nodes.DataModel.DataModelNode",
"Name": "Data Model-Value",
"Description": "Outputs a selectable data model value",
"IsExitNode": false,
"X": 0.0,
"Y": 0.0,
"Storage": "{\"Path\":\"Player.IsAlive\",\"DataModelId\":\"Artemis.Plugins.Modules.Game-12345678\"}",
"PinCollections": [],
"ProviderId": "Artemis.Plugins.Nodes.General.GeneralNodesProvider-d9e1ee78"
}
],
"Connections": []
}
},
"Timeline": {
"StartSegmentLength": "00:00:00",
"MainSegmentLength": "00:00:05",
"EndSegmentLength": "00:00:00"
}
}
],
"Layers": [
{
"Order": 1,
"Name": "Gradient layer",
"Suspended": false,
"Leds": [
{
"LedName": "Keyboard_A",
"DeviceIdentifier": "Artemis.Plugins.Devices.Test-Keyboard",
"PhysicalLayout": 1
},
{
"LedName": "Keyboard_S",
"DeviceIdentifier": "Artemis.Plugins.Devices.Test-Keyboard",
"PhysicalLayout": null
}
],
"AdaptionHints": [
{
"$type": "Category",
"Category": 1,
"LimitAmount": true,
"Skip": 0,
"Amount": 1
},
{
"$type": "KeyboardSection",
"Section": 2
}
],
"GeneralPropertyGroup": {
"Identifier": "General",
"Properties": [
{
"Identifier": "ShapeType",
"Value": "1",
"KeyframesEnabled": false,
"DataBinding": null,
"KeyframeEntities": []
}
],
"PropertyGroups": []
},
"TransformPropertyGroup": {
"Identifier": "Transform",
"Properties": [
{
"Identifier": "Opacity",
"Value": "100.0",
"KeyframesEnabled": true,
"DataBinding": {
"IsEnabled": false,
"NodeScript": {
"Name": "Opacity",
"Description": "The data binding of the opacity",
"Nodes": [
{
"Id": "d0000000-0000-0000-0000-000000000001",
"Type": "Artemis.Plugins.Nodes.General.Nodes.Static.StaticNumericValueNode",
"Name": "Numeric-Value",
"Description": "Outputs a configurable static numeric value",
"IsExitNode": false,
"X": 0.0,
"Y": 0.0,
"Storage": "50.0",
"PinCollections": [],
"ProviderId": "Artemis.Plugins.Nodes.General.GeneralNodesProvider-d9e1ee78"
}
],
"Connections": []
}
},
"KeyframeEntities": [
{
"Position": "00:00:01",
"Timeline": 0,
"Value": "25.0",
"EasingFunction": 3
}
]
}
],
"PropertyGroups": []
},
"LayerBrush": {
"ProviderId": "Artemis.Plugins.LayerBrushes.Color.ColorBrushProvider-ed2e1c49",
"BrushType": "ColorBrush",
"PropertyGroup": {
"Identifier": "LayerBrush",
"Properties": [],
"PropertyGroups": [
{
"Identifier": "Colors",
"Properties": [
{
"Identifier": "Gradient",
"Value": "[{\"Color\":\"#ffff0000\",\"Position\":0.0},{\"Color\":\"#ff0000ff\",\"Position\":1.0}]",
"KeyframesEnabled": false,
"DataBinding": {
"IsEnabled": true,
"NodeScript": {
"Name": "Gradient",
"Description": "The data binding of the gradient",
"Nodes": [
{
"Id": "e0000000-0000-0000-0000-000000000001",
"Type": "Artemis.Plugins.Nodes.General.Nodes.Color.ColorGradientNode",
"Name": "Color Gradient",
"Description": "Outputs a color gradient with the given colors",
"IsExitNode": false,
"X": 0.0,
"Y": 0.0,
"Storage": "[{\"Color\":\"#ffff0000\",\"Position\":0.0},{\"Color\":\"#ff0000ff\",\"Position\":1.0}]",
"PinCollections": [],
"ProviderId": "Artemis.Plugins.Nodes.General.GeneralNodesProvider-d9e1ee78"
}
],
"Connections": []
}
},
"KeyframeEntities": []
}
],
"PropertyGroups": []
}
]
}
},
"Id": "f0000000-0000-0000-0000-000000000002",
"ParentId": "f0000000-0000-0000-0000-000000000001",
"LayerEffects": [],
"DisplayCondition": {
"$type": "Event",
"TriggerMode": 0,
"OverlapMode": 1,
"ToggleOffMode": 0,
"EventPath": {
"Path": "Player.Died",
"DataModelId": "Artemis.Plugins.Modules.Game-12345678"
},
"Script": {
"Name": "Event condition",
"Description": "Whether or not the event should trigger",
"Nodes": [
{
"Id": "c0000000-0000-0000-0000-000000000002",
"Type": "Artemis.Plugins.Nodes.General.Nodes.Static.StaticBooleanValueNode",
"Name": "Boolean-Value",
"Description": "Outputs a configurable static boolean value",
"IsExitNode": false,
"X": 0.0,
"Y": 0.0,
"Storage": "true",
"PinCollections": [],
"ProviderId": "Artemis.Plugins.Nodes.General.GeneralNodesProvider-d9e1ee78"
}
],
"Connections": []
}
},
"Timeline": {
"StartSegmentLength": "00:00:00",
"MainSegmentLength": "00:00:02",
"EndSegmentLength": "00:00:01"
}
}
]
}
""";
/// <summary>
/// Parses one of the JSON strings above into a fresh, editable object
/// </summary>
public static JsonObject Parse(string json)
{
return JsonNode.Parse(json)!.AsObject();
}
}
@@ -0,0 +1,26 @@
using Serilog;
using Serilog.Core;
using Serilog.Events;
namespace Artemis.Core.Tests.Infrastructure.Storage;
/// <summary>
/// A logger that keeps everything logged to it, for checking what a class reported
/// </summary>
public sealed class RecordingLogger : ILogEventSink
{
public RecordingLogger()
{
Logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(this).CreateLogger();
}
public ILogger Logger { get; }
public List<LogEvent> Events { get; } = [];
public IEnumerable<LogEvent> Errors => Events.Where(e => e.Level == LogEventLevel.Error);
public void Emit(LogEvent logEvent)
{
Events.Add(logEvent);
}
}
@@ -0,0 +1,75 @@
using System.Text.Json.Nodes;
using Artemis.Storage;
using Artemis.Storage.Entities;
using Artemis.Storage.Entities.Profile;
using Artemis.Storage.Migrations;
using Artemis.Storage.Migrations.Profile;
using Microsoft.EntityFrameworkCore;
using Serilog;
namespace Artemis.Core.Tests.Infrastructure.Storage;
/// <summary>
/// Helpers for putting profiles in a test database the way older versions of Artemis left them, and reading back the
/// JSON that is stored
/// </summary>
public static class StoredProfiles
{
/// <summary>
/// Creates the profile migrations Artemis.Core registers
/// </summary>
public static List<IProfileMigration> AllMigrations(ILogger logger)
{
return [new M0001NodeProviders(), new M0002NodeProvidersProfileConfig(), new M0003SystemTextJson(), new M0004NodeStorage(logger)];
}
/// <summary>
/// Adds a profile category to the database
/// </summary>
public static ProfileCategoryEntity AddCategory(Func<ArtemisDbContext> createContext, string name = "Category")
{
using ArtemisDbContext context = createContext();
ProfileCategoryEntity category = new() {Name = name};
context.ProfileCategories.Add(category);
context.SaveChanges();
return category;
}
/// <summary>
/// The JSON of <see cref="OldProfileJson.Version0Configuration" /> as the database migration from the old LiteDB
/// database left it: still in the old format, but with a version, which that migration always wrote
/// </summary>
public static string Version0ConfigurationWithVersion()
{
JsonObject configuration = OldProfileJson.Parse(OldProfileJson.Version0Configuration);
configuration["Version"] = 0;
return configuration.ToJsonString();
}
/// <summary>
/// Writes a profile container with the given JSON straight into the database, without going through Entity
/// Framework
/// </summary>
public static Guid InsertRaw(Func<ArtemisDbContext> createContext, Guid categoryId, string configurationJson, string profileJson)
{
Guid id = Guid.NewGuid();
using ArtemisDbContext context = createContext();
context.Database.ExecuteSqlRaw(
"INSERT INTO ProfileContainers (Id, Icon, Profile, ProfileCategoryId, ProfileConfiguration) VALUES ({0}, {1}, {2}, {3}, {4})",
id, Array.Empty<byte>(), profileJson, categoryId, configurationJson);
return id;
}
/// <summary>
/// Reads the JSON of a profile container straight from the database
/// </summary>
public static (string Configuration, string Profile) ReadRaw(Func<ArtemisDbContext> createContext, Guid id)
{
using ArtemisDbContext context = createContext();
RawProfileContainer container = context.Database
.SqlQueryRaw<RawProfileContainer>("SELECT Id, Profile, ProfileConfiguration FROM ProfileContainers WHERE Id = {0}", id)
.AsEnumerable()
.Single();
return (container.ProfileConfiguration, container.Profile);
}
}
@@ -0,0 +1,85 @@
using Artemis.Core.Internal;
using Artemis.Core.Tests.Infrastructure.Plugins;
using Artemis.Core.Tests.Infrastructure.Plugins.Nodes;
using Artemis.Storage.Entities.Profile.Nodes;
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// Base class for visual scripting tests. Registers the test nodes of both <see cref="TestNodeProvider" /> and
/// <see cref="ScriptTestNodeProvider" /> and disposes the scripts a test creates, since scripts listen to the static
/// node type store until they are disposed.
/// </summary>
public abstract class ScriptTestBase : IDisposable
{
private readonly List<IDisposable> _tracked = [];
protected ScriptTestBase()
{
Plugin = new TestPlugin();
Nodes = Plugin.AddFeature(new TestNodeProvider());
ScriptNodes = Plugin.AddFeature(new ScriptTestNodeProvider());
}
protected TestPlugin Plugin { get; }
protected TestNodeProvider Nodes { get; }
protected ScriptTestNodeProvider ScriptNodes { get; }
/// <summary>
/// Creates a new script that is disposed at the end of the test
/// </summary>
protected NodeScript<T> CreateScript<T>(string name = "Test", List<DefaultNode>? defaultNodes = null)
{
return Track(new NodeScript<T>(name, "A test script", null, defaultNodes));
}
/// <summary>
/// Saves the script and loads a new script from a copy of what was saved, the way a profile is stored and loaded
/// again. The new script is disposed at the end of the test.
/// </summary>
protected NodeScript<T> SaveAndLoad<T>(NodeScript<T> script, List<DefaultNode>? defaultNodes = null)
{
script.Save();
return Load<T>(script.Entity, script.Name, defaultNodes);
}
/// <summary>
/// Loads a new script from a copy of the provided entity. The new script is disposed at the end of the test.
/// </summary>
protected NodeScript<T> Load<T>(NodeScriptEntity entity, string name = "Test", List<DefaultNode>? defaultNodes = null)
{
return Track(new NodeScript<T>(name, "A test script", Copy(entity), null, defaultNodes));
}
/// <summary>
/// Copies the entity through JSON, so a loaded script never shares the entity of the script that saved it
/// </summary>
protected static NodeScriptEntity Copy(NodeScriptEntity entity)
{
return CoreJson.Deserialize<NodeScriptEntity>(CoreJson.Serialize(entity))!;
}
protected T Track<T>(T disposable) where T : IDisposable
{
_tracked.Add(disposable);
return disposable;
}
public virtual void Dispose()
{
foreach (IDisposable disposable in _tracked)
disposable.Dispose();
Plugin.Dispose();
}
}
internal static class ScriptTestExtensions
{
/// <summary>
/// Gets the exit node of the script, the node whose input becomes the result of the script
/// </summary>
public static ExitNode<T> GetExitNode<T>(this NodeScript<T> script)
{
return script.Nodes.OfType<ExitNode<T>>().Single();
}
}
@@ -0,0 +1,47 @@
using Artemis.Core.Nodes;
using Artemis.Core.Tests.Infrastructure.Plugins;
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// Registers the nodes used by the visual scripting tests, so scripts using them can be saved and loaded again.
/// Also gives text a type color, which <see cref="ObjectOutputPins" /> needs before it creates pins for text
/// properties.
/// </summary>
public class ScriptTestNodeProvider : NodeProvider
{
public override void Enable()
{
RegisterNodeType<TestSumNode>();
RegisterNodeType<TestThrowingNode>();
RegisterNodeType<TestTextNode>();
RegisterNodeType<TestSettingsNode>();
RegisterNodeType<TestViewModelNode>();
RegisterTypeColor<string>(SKColors.Orange);
}
public override void Disable()
{
}
/// <summary>
/// Creates a registered node, the way the editor does, and adds it to the script.
/// </summary>
public T AddNode<T>(INodeScript script) where T : INode
{
NodeData nodeData = NodeDescriptors.Single(d => d.Type == typeof(T));
T node = (T) nodeData.CreateNode(script, null);
script.AddNode(node);
return node;
}
/// <summary>
/// Gets the ID a <see cref="ScriptTestNodeProvider" /> gets once it is added to the given plugin, saved nodes refer
/// to their provider by this ID.
/// </summary>
public static string IdFor(TestPlugin plugin)
{
return $"{typeof(ScriptTestNodeProvider).FullName}-{plugin.Plugin.Guid.ToString()[..8]}";
}
}
@@ -0,0 +1,30 @@
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// A node that outputs a boolean the test controls. The value is stored, so it survives saving and loading a script
/// when the node is created through <see cref="Plugins.Nodes.TestNodeProvider" />.
/// </summary>
[Node("Test boolean", "Outputs a boolean controlled by the test", "Tests", OutputType = typeof(bool))]
public class TestBooleanNode : Node<bool>
{
public TestBooleanNode()
{
Output = CreateOutputPin<bool>();
}
public OutputPin<bool> Output { get; }
/// <summary>
/// Gets or sets the value the node outputs
/// </summary>
public bool Value
{
get => Storage;
set => Storage = value;
}
public override void Evaluate()
{
Output.Value = Value;
}
}
@@ -0,0 +1,32 @@
using SkiaSharp;
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// A node that outputs a color the test controls. The color is stored, so it survives saving and loading a script
/// when the node is created through <see cref="Plugins.Nodes.DataModelTestNodeProvider" />.
/// </summary>
[Node("Test color", "Outputs a color controlled by the test", "Tests", OutputType = typeof(SKColor))]
public class TestColorNode : Node<SKColor>
{
public TestColorNode()
{
Output = CreateOutputPin<SKColor>();
}
public OutputPin<SKColor> Output { get; }
/// <summary>
/// Gets or sets the color the node outputs
/// </summary>
public SKColor Value
{
get => Storage;
set => Storage = value;
}
public override void Evaluate()
{
Output.Value = Value;
}
}
@@ -0,0 +1,28 @@
using Artemis.Core.Internal;
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
public static class TestConditions
{
/// <summary>
/// Gives the element a static display condition whose script is driven by the returned node.
/// </summary>
public static TestBooleanNode UseStaticCondition(RenderProfileElement element, bool isMet, StaticPlayMode playMode = StaticPlayMode.Repeat, StaticStopMode stopMode = StaticStopMode.Finish)
{
StaticCondition condition = new(element) {PlayMode = playMode, StopMode = stopMode};
TestBooleanNode node = new() {Value = isMet};
condition.Script.AddNode(node);
condition.Script.Nodes.OfType<ExitNode<bool>>().Single().Input.ConnectTo(node.Output);
element.DisplayCondition = condition;
return node;
}
/// <summary>
/// Gives the element a play once display condition.
/// </summary>
public static void UsePlayOnceCondition(RenderProfileElement element)
{
element.DisplayCondition = new PlayOnceCondition(element);
}
}
@@ -0,0 +1,60 @@
using Artemis.Storage.Entities.Profile;
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// A node that outputs the number found at a data model path, like the data model node in the Artemis.Plugins
/// repository. The path is stored, so it survives saving and loading a script when the node is created through
/// <see cref="Plugins.Nodes.DataModelTestNodeProvider" />.
/// </summary>
[Node("Test data model number", "Outputs the number at a data model path", "Tests", OutputType = typeof(Numeric))]
public class TestDataModelNumberNode : Node<DataModelPathEntity>, IDisposable
{
private DataModelPath? _path;
public TestDataModelNumberNode()
{
Output = CreateOutputPin<Numeric>();
StorageModified += (_, _) => ClearPath();
}
public OutputPin<Numeric> Output { get; }
/// <summary>
/// Gets the path the node reads, created from storage the first time the node is evaluated
/// </summary>
public DataModelPath? Path => _path;
/// <summary>
/// Makes the node read the value at the provided path
/// </summary>
public void UsePath(DataModelPath path)
{
path.Save();
Storage = new DataModelPathEntity {Path = path.Entity.Path, DataModelId = path.Entity.DataModelId, Type = path.Entity.Type};
}
public override void Evaluate()
{
if (_path == null && Storage != null)
_path = new DataModelPath(Storage);
Output.Value = new Numeric(_path?.GetValue());
}
public override IEnumerable<PluginFeature> GetFeatureDependencies()
{
return base.GetFeatureDependencies().Concat(_path?.GetFeatureDependencies() ?? []);
}
public void Dispose()
{
ClearPath();
}
private void ClearPath()
{
_path?.Dispose();
_path = null;
}
}
@@ -0,0 +1,39 @@
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// A node that outputs whether its input is greater than a stored number, used to look at event arguments in event
/// conditions. The number is stored, so it survives saving and loading a script when the node is created through
/// <see cref="Plugins.Nodes.DataModelTestNodeProvider" />.
/// </summary>
[Node("Test greater than", "Outputs whether the input is greater than a number", "Tests", InputType = typeof(Numeric), OutputType = typeof(bool))]
public class TestGreaterThanNode : Node<float>
{
public TestGreaterThanNode()
{
Input = CreateInputPin<Numeric>();
Output = CreateOutputPin<bool>();
}
public InputPin<Numeric> Input { get; }
public OutputPin<bool> Output { get; }
/// <summary>
/// Gets or sets the number the input must be greater than
/// </summary>
public float Threshold
{
get => Storage;
set => Storage = value;
}
/// <summary>
/// Gets the last input the node saw
/// </summary>
public float LastInput { get; private set; }
public override void Evaluate()
{
LastInput = Input.Value;
Output.Value = LastInput > Threshold;
}
}
@@ -0,0 +1,64 @@
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// A node without pins of its own that isn't registered anywhere. Tests add the pins they need and decide what
/// evaluating does. Because it isn't registered, it disappears when a script using it is loaded again.
/// </summary>
public class TestNode : Node
{
public TestNode(string name = "Test node") : base(name, "")
{
}
/// <summary>
/// Gets or sets what the node does when it is evaluated
/// </summary>
public Action<TestNode>? OnEvaluate { get; set; }
/// <summary>
/// Gets the number of times the node was evaluated
/// </summary>
public int Evaluations { get; private set; }
public override void Evaluate()
{
Evaluations++;
OnEvaluate?.Invoke(this);
}
}
/// <summary>
/// A default node, the kind of node a script is created with and that can't be deleted, such as the start node of
/// an event condition.
/// </summary>
public class TestDefaultNode : DefaultNode
{
public static readonly Guid NodeId = new("8B3C35B2-6F6B-4C4A-9D4C-2A6A3F0F1E01");
public TestDefaultNode() : base(NodeId, "Test default node", "A node every script starts with")
{
Output = CreateOutputPin<Numeric>();
}
public OutputPin<Numeric> Output { get; }
public float Value { get; set; }
public override void Evaluate()
{
Output.Value = new Numeric(Value);
}
}
/// <summary>
/// A node that tracks whether it was disposed.
/// </summary>
public class TestDisposableNode : TestNode, IDisposable
{
public bool IsDisposed { get; private set; }
public void Dispose()
{
IsDisposed = true;
}
}
@@ -0,0 +1,36 @@
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// A node that outputs a number the test controls. The value is stored, so it survives saving and loading a script
/// when the node is created through <see cref="Plugins.Nodes.TestNodeProvider" />.
/// </summary>
[Node("Test number", "Outputs a number controlled by the test", "Tests", OutputType = typeof(Numeric))]
public class TestNumericNode : Node<float>
{
public TestNumericNode()
{
Output = CreateOutputPin<Numeric>();
}
public OutputPin<Numeric> Output { get; }
/// <summary>
/// Gets or sets the value the node outputs
/// </summary>
public float Value
{
get => Storage;
set => Storage = value;
}
/// <summary>
/// Gets the number of times the node was evaluated
/// </summary>
public int Evaluations { get; private set; }
public override void Evaluate()
{
Evaluations++;
Output.Value = new Numeric(Value);
}
}
@@ -0,0 +1,25 @@
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// A node that stores an object rather than a single value, to see what survives saving and loading. It also sets
/// its own name and help URL, which the node data should leave alone.
/// </summary>
[Node("Test settings", "Stores settings", "Tests", "https://example.com/node-from-attribute")]
public class TestSettingsNode : Node<TestNodeSettings>
{
public TestSettingsNode() : base("Own name", "")
{
HelpUrl = "https://example.com/own-help";
}
public override void Evaluate()
{
}
}
public class TestNodeSettings
{
public string? Name { get; set; }
public double Amount { get; set; }
public List<int> Numbers { get; set; } = [];
}
@@ -0,0 +1,28 @@
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// A node that adds up the numbers on its inputs. The inputs are a pin collection that starts with two pins.
/// </summary>
[Node("Test sum", "Adds up its inputs", "Tests", InputType = typeof(Numeric), OutputType = typeof(Numeric))]
public class TestSumNode : Node
{
public TestSumNode()
{
Values = CreateInputPinCollection<Numeric>("Values", 2);
Result = CreateOutputPin<Numeric>("Result");
}
public InputPinCollection<Numeric> Values { get; }
public OutputPin<Numeric> Result { get; }
/// <summary>
/// Gets the number of times the node was evaluated
/// </summary>
public int Evaluations { get; private set; }
public override void Evaluate()
{
Evaluations++;
Result.Value = Values.Values.Sum();
}
}
@@ -0,0 +1,26 @@
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// A node that outputs text the test controls. The text is stored, so it survives saving and loading a script.
/// </summary>
[Node("Test text", "Outputs text controlled by the test")]
public class TestTextNode : Node<string>
{
public TestTextNode()
{
Output = CreateOutputPin<string>();
}
public OutputPin<string> Output { get; }
public string? Value
{
get => Storage;
set => Storage = value;
}
public override void Evaluate()
{
Output.Value = Value;
}
}
@@ -0,0 +1,50 @@
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// A node that outputs a number and can be told to fail while evaluating or initializing. What it does is stored, so
/// it survives saving and loading a script.
/// </summary>
[Node("Test throwing", "Fails when told to", "Tests", OutputType = typeof(Numeric))]
public class TestThrowingNode : Node<TestThrowingNode.FailureMode>
{
public TestThrowingNode()
{
Output = CreateOutputPin<Numeric>();
}
public OutputPin<Numeric> Output { get; }
/// <summary>
/// Gets or sets the number the node outputs while it isn't failing
/// </summary>
public float Value { get; set; }
/// <summary>
/// Gets or sets when the node fails
/// </summary>
public FailureMode FailsOn
{
get => Storage;
set => Storage = value;
}
public override void Initialize(INodeScript script)
{
if (FailsOn == FailureMode.Initialize)
throw new InvalidOperationException("Test node failed to initialize");
}
public override void Evaluate()
{
if (FailsOn == FailureMode.Evaluate)
throw new InvalidOperationException("Test node failed to evaluate");
Output.Value = new Numeric(Value);
}
public enum FailureMode
{
Never,
Evaluate,
Initialize
}
}
@@ -0,0 +1,30 @@
namespace Artemis.Core.Tests.Infrastructure.VisualScripting;
/// <summary>
/// A node with a custom view model, the view model is created through the plugin's container.
/// </summary>
[Node("Test view model")]
public class TestViewModelNode : Node<int, TestNodeViewModel>
{
public override void Evaluate()
{
}
}
public class TestNodeViewModel : ICustomNodeViewModel
{
public TestNodeViewModel(TestViewModelNode node, NodeScript script)
{
Node = node;
Script = script;
}
public INode Node { get; }
public NodeScript Script { get; }
public event EventHandler? NodeModified
{
add { }
remove { }
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,309 @@
using RGB.NET.Core;
namespace Artemis.Core.Tests.Profiles.Adaption;
public class AdaptionHintTests : AdaptionTestBase
{
#region Device hints
[Fact]
public void DeviceHint_AddsEveryLedOfDevicesOfThatType()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
ArtemisDevice strip = AddDevice(AdaptionLayouts.LedStrip, 0, 200);
Layer layer = AddLayer(Harness.CreateProfile());
new DeviceAdaptionHint {DeviceType = RGBDeviceType.Keyboard}.Apply(layer, Devices);
LedIdsOn(layer, keyboard).ShouldBe(AllLedIds(keyboard));
LedIdsOn(layer, strip).ShouldBeEmpty();
}
[Fact]
public void DeviceHint_ForAllTypesAddsEveryLed()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
ArtemisDevice strip = AddDevice(AdaptionLayouts.LedStrip, 0, 200);
Layer layer = AddLayer(Harness.CreateProfile());
new DeviceAdaptionHint {DeviceType = RGBDeviceType.All}.Apply(layer, Devices);
layer.Leds.Count.ShouldBe(keyboard.Leds.Count + strip.Leds.Count);
}
[Fact]
public void DeviceHint_WithoutMatchingDevicesAddsNothing()
{
AddDevice(AdaptionLayouts.KeyboardAnsi);
Layer layer = AddLayer(Harness.CreateProfile());
new DeviceAdaptionHint {DeviceType = RGBDeviceType.Mouse}.Apply(layer, Devices);
layer.Leds.ShouldBeEmpty();
}
[Fact]
public void DeviceHint_SkipsAndLimitsDevicesFromTopToBottomThenLeftToRight()
{
// Added out of order to show the hint orders them by position
ArtemisDevice bottom = AddDevice(AdaptionLayouts.KeyboardTklAnsi, 0, 200);
ArtemisDevice topRight = AddDevice(AdaptionLayouts.KeyboardTklAnsi, 500, 0);
ArtemisDevice topLeft = AddDevice(AdaptionLayouts.KeyboardTklAnsi, 0, 0);
Profile profile = Harness.CreateProfile();
DevicesWithLeds(profile, new DeviceAdaptionHint {DeviceType = RGBDeviceType.Keyboard, Skip = 1}).ShouldBe([topRight, bottom]);
DevicesWithLeds(profile, new DeviceAdaptionHint {DeviceType = RGBDeviceType.Keyboard, LimitAmount = true, Amount = 2}).ShouldBe([topLeft, topRight]);
DevicesWithLeds(profile, new DeviceAdaptionHint {DeviceType = RGBDeviceType.Keyboard, Skip = 2, LimitAmount = true, Amount = 1}).ShouldBe([bottom]);
DevicesWithLeds(profile, new DeviceAdaptionHint {DeviceType = RGBDeviceType.Keyboard, Amount = 1}).ShouldBe([topLeft, topRight, bottom], "the amount is only used when limiting");
}
[Fact]
public void DeviceHint_KeepsTheLayersOtherLeds()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
ArtemisDevice strip = AddDevice(AdaptionLayouts.LedStrip, 0, 200);
Layer layer = AddLayer(Harness.CreateProfile(), [Led(strip, LedId.LedStripe1), Led(keyboard, LedId.Keyboard_A)]);
new DeviceAdaptionHint {DeviceType = RGBDeviceType.Keyboard}.Apply(layer, Devices);
layer.Leds.Count.ShouldBe(keyboard.Leds.Count + 1);
layer.Leds.Distinct().Count().ShouldBe(layer.Leds.Count);
}
#endregion
#region Category hints
[Fact]
public void CategoryHint_AddsEveryLedOfDevicesInThatCategory()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
ArtemisDevice headset = AddDevice(AdaptionLayouts.Headset, 500, 0);
ArtemisDevice strip = AddDevice(AdaptionLayouts.LedStrip, 0, 200);
foreach (ArtemisDevice device in Devices)
device.ApplyDefaultCategories();
Layer peripherals = AddLayer(Harness.CreateProfile());
Layer pcCase = AddLayer(Harness.CreateProfile());
new CategoryAdaptionHint {Category = DeviceCategory.Peripherals}.Apply(peripherals, Devices);
new CategoryAdaptionHint {Category = DeviceCategory.Case}.Apply(pcCase, Devices);
LedIdsOn(peripherals, keyboard).ShouldBe(AllLedIds(keyboard));
LedIdsOn(peripherals, headset).ShouldBe(AllLedIds(headset));
LedIdsOn(peripherals, strip).ShouldBeEmpty();
pcCase.Leds.ShouldBe(strip.Leds, ignoreOrder: true);
}
[Fact]
public void CategoryHint_UsesTheCategoriesSetOnTheDevice()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
ArtemisDevice strip = AddDevice(AdaptionLayouts.LedStrip, 0, 200);
Layer layer = AddLayer(Harness.CreateProfile());
// New devices have no categories until they are given their defaults
new CategoryAdaptionHint {Category = DeviceCategory.Peripherals}.Apply(layer, Devices);
layer.Leds.ShouldBeEmpty();
strip.Categories.Add(DeviceCategory.Desk);
keyboard.Categories.Add(DeviceCategory.Desk);
new CategoryAdaptionHint {Category = DeviceCategory.Desk}.Apply(layer, Devices);
layer.Leds.Count.ShouldBe(keyboard.Leds.Count + strip.Leds.Count);
}
[Fact]
public void CategoryHint_SkipsAndLimitsDevicesFromTopToBottomThenLeftToRight()
{
ArtemisDevice bottom = AddDevice(AdaptionLayouts.LedStrip, 0, 200);
ArtemisDevice topRight = AddDevice(AdaptionLayouts.LedStrip, 500, 0);
ArtemisDevice topLeft = AddDevice(AdaptionLayouts.LedStrip, 0, 0);
foreach (ArtemisDevice device in Devices)
device.ApplyDefaultCategories();
Profile profile = Harness.CreateProfile();
DevicesWithLeds(profile, new CategoryAdaptionHint {Category = DeviceCategory.Case, Skip = 1}).ShouldBe([topRight, bottom]);
DevicesWithLeds(profile, new CategoryAdaptionHint {Category = DeviceCategory.Case, LimitAmount = true, Amount = 1}).ShouldBe([topLeft]);
DevicesWithLeds(profile, new CategoryAdaptionHint {Category = DeviceCategory.Case, Skip = 1, LimitAmount = true, Amount = 5}).ShouldBe([topRight, bottom]);
}
#endregion
#region Keyboard section hints
public static TheoryData<KeyboardSection, LedId[]> SectionsOnAFullSizeKeyboard => new()
{
{KeyboardSection.FunctionKeys, Enumerable.Range(0, 12).Select(i => LedId.Keyboard_F1 + i).ToArray()},
{
KeyboardSection.NumberKeys, [
LedId.Keyboard_1, LedId.Keyboard_2, LedId.Keyboard_3, LedId.Keyboard_4, LedId.Keyboard_5,
LedId.Keyboard_6, LedId.Keyboard_7, LedId.Keyboard_8, LedId.Keyboard_9, LedId.Keyboard_0
]
},
{
KeyboardSection.NumPad, [
LedId.Keyboard_NumLock, LedId.Keyboard_NumSlash, LedId.Keyboard_NumAsterisk, LedId.Keyboard_NumMinus,
LedId.Keyboard_Num7, LedId.Keyboard_Num8, LedId.Keyboard_Num9, LedId.Keyboard_NumPlus,
LedId.Keyboard_Num4, LedId.Keyboard_Num5, LedId.Keyboard_Num6,
LedId.Keyboard_Num1, LedId.Keyboard_Num2, LedId.Keyboard_Num3, LedId.Keyboard_NumEnter,
LedId.Keyboard_Num0, LedId.Keyboard_NumPeriodAndDelete
]
},
// Pinned as-is: the arrow keys section starts one key early and includes Page Down
{KeyboardSection.ArrowKeys, [LedId.Keyboard_PageDown, LedId.Keyboard_ArrowUp, LedId.Keyboard_ArrowLeft, LedId.Keyboard_ArrowDown, LedId.Keyboard_ArrowRight]},
{
KeyboardSection.MediaKeys, [
LedId.Keyboard_MediaMute, LedId.Keyboard_MediaStop, LedId.Keyboard_MediaPreviousTrack,
LedId.Keyboard_MediaPlay, LedId.Keyboard_MediaNextTrack
]
},
{KeyboardSection.LedStrips, Enumerable.Range(0, 46).Select(i => LedId.LedStripe1 + i).ToArray()},
{KeyboardSection.Movement, [LedId.Keyboard_W, LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D]},
// The full size keyboard has no macro keys or extra LEDs
{KeyboardSection.MacroKeys, []},
{KeyboardSection.Extra, []}
};
[Theory]
[MemberData(nameof(SectionsOnAFullSizeKeyboard))]
public void KeyboardSectionHint_AddsTheKeysOfThatSection(KeyboardSection section, LedId[] expected)
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
Layer layer = AddLayer(Harness.CreateProfile());
new KeyboardSectionAdaptionHint {Section = section}.Apply(layer, Devices);
LedIdsOn(layer, keyboard).ShouldBe(expected.Order().ToArray());
}
[Fact]
public void KeyboardSectionHint_AddsTheMacroKeysOfAnXlKeyboard()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardXlAnsi);
Layer layer = AddLayer(Harness.CreateProfile());
new KeyboardSectionAdaptionHint {Section = KeyboardSection.MacroKeys}.Apply(layer, Devices);
LedIdsOn(layer, keyboard).ShouldBe(Enumerable.Range(0, 6).Select(i => LedId.Keyboard_Programmable1 + i).ToArray());
}
[Fact]
public void KeyboardSectionHint_OnlyLooksAtKeyboards()
{
AddDevice(AdaptionLayouts.LedStrip, 0, 200);
Layer layer = AddLayer(Harness.CreateProfile());
new KeyboardSectionAdaptionHint {Section = KeyboardSection.LedStrips}.Apply(layer, Devices);
layer.Leds.ShouldBeEmpty();
}
[Fact]
public void KeyboardSectionHint_AppliesToEveryKeyboard()
{
ArtemisDevice first = AddDevice(AdaptionLayouts.KeyboardAnsi);
ArtemisDevice second = AddDevice(AdaptionLayouts.KeyboardTklAnsi, 0, 200);
Layer layer = AddLayer(Harness.CreateProfile());
new KeyboardSectionAdaptionHint {Section = KeyboardSection.FunctionKeys}.Apply(layer, Devices);
LedIdsOn(layer, first).Length.ShouldBe(12);
LedIdsOn(layer, second).Length.ShouldBe(12);
}
[Fact]
public void KeyboardSectionHint_MovementUsesZqsdOnAzertyKeyboards()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.AzertyKeys);
Layer layer = AddLayer(Harness.CreateProfile());
new KeyboardSectionAdaptionHint {Section = KeyboardSection.Movement}.Apply(layer, Devices);
LedIdsOn(layer, keyboard).ShouldBe(new[] {LedId.Keyboard_Z, LedId.Keyboard_Q, LedId.Keyboard_S, LedId.Keyboard_D}.Order().ToArray());
}
#endregion
#region Single LED hints
[Fact]
public void SingleLedHint_AddsThatLedOfEveryDeviceThatHasIt()
{
ArtemisDevice first = AddDevice(AdaptionLayouts.KeyboardAnsi);
ArtemisDevice second = AddDevice(AdaptionLayouts.KeyboardTklAnsi, 0, 200);
ArtemisDevice strip = AddDevice(AdaptionLayouts.LedStrip, 0, 400);
Layer layer = AddLayer(Harness.CreateProfile());
new SingleLedAdaptionHint {LedId = LedId.Keyboard_Escape}.Apply(layer, Devices);
layer.Leds.ShouldBe([Led(first, LedId.Keyboard_Escape), Led(second, LedId.Keyboard_Escape)], ignoreOrder: true);
LedIdsOn(layer, strip).ShouldBeEmpty();
}
[Fact]
public void SingleLedHint_SkipsAndLimitsDevicesFromTopToBottomThenLeftToRight()
{
ArtemisDevice bottom = AddDevice(AdaptionLayouts.KeyboardTklAnsi, 0, 200);
ArtemisDevice topRight = AddDevice(AdaptionLayouts.KeyboardTklAnsi, 500, 0);
ArtemisDevice topLeft = AddDevice(AdaptionLayouts.KeyboardTklAnsi, 0, 0);
Profile profile = Harness.CreateProfile();
DevicesWithLeds(profile, new SingleLedAdaptionHint {LedId = LedId.Keyboard_Escape, LimitAmount = true, Amount = 1}).ShouldBe([topLeft]);
DevicesWithLeds(profile, new SingleLedAdaptionHint {LedId = LedId.Keyboard_Escape, Skip = 1}).ShouldBe([topRight, bottom]);
DevicesWithLeds(profile, new SingleLedAdaptionHint {LedId = LedId.Keyboard_Escape, Skip = 1, LimitAmount = true, Amount = 1}).ShouldBe([topRight]);
}
[Fact]
public void SingleLedHint_ForAnLedNoDeviceHasAddsNothing()
{
AddDevice(AdaptionLayouts.KeyboardAnsi);
Layer layer = AddLayer(Harness.CreateProfile());
new SingleLedAdaptionHint {LedId = LedId.Mouse1}.Apply(layer, Devices);
layer.Leds.ShouldBeEmpty();
}
#endregion
#region Descriptions and storage
[Fact]
public void ToString_DescribesTheHint()
{
new CategoryAdaptionHint {Category = DeviceCategory.Desk, Skip = 1, LimitAmount = true, Amount = 2}.ToString()
.ShouldBe("Category adaption - Category: Desk, Skip: 1, LimitAmount: True, Amount: 2");
new DeviceAdaptionHint {DeviceType = RGBDeviceType.Mouse, Skip = 3}.ToString()
.ShouldBe("Device adaption - DeviceType: Mouse, Skip: 3, LimitAmount: False, Amount: 0");
new KeyboardSectionAdaptionHint {Section = KeyboardSection.NumPad}.ToString()
.ShouldBe("Keyboard section adaption - Section: NumPad");
new SingleLedAdaptionHint {LedId = LedId.Keyboard_Escape, Amount = 1, LimitAmount = true}.ToString()
.ShouldBe("Single LED adaption - LedId: Keyboard_Escape, Skip: 0, LimitAmount: True, Amount: 1");
}
[Fact]
public void Properties_RaisePropertyChanged()
{
CategoryAdaptionHint hint = new();
List<string?> changed = [];
hint.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
hint.Category = DeviceCategory.Room;
hint.Skip = 1;
hint.LimitAmount = true;
hint.Amount = 2;
changed.ShouldBe([nameof(CategoryAdaptionHint.Category), nameof(CategoryAdaptionHint.Skip), nameof(CategoryAdaptionHint.LimitAmount), nameof(CategoryAdaptionHint.Amount)]);
}
#endregion
/// <summary>
/// Applies the hint to a new layer and returns the devices that got LEDs, in the order they were added
/// </summary>
private List<ArtemisDevice> DevicesWithLeds(Profile profile, IAdaptionHint hint)
{
Layer layer = AddLayer(profile);
hint.Apply(layer, Devices);
return layer.Leds.Select(l => l.Device).Distinct().ToList();
}
}
@@ -0,0 +1,39 @@
using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
namespace Artemis.Core.Tests.Profiles.Adaption;
/// <summary>
/// The device layouts used by the adaption tests on top of <see cref="TestLayouts" />
/// </summary>
public static class AdaptionLayouts
{
private static readonly string Root = Path.Combine(AppContext.BaseDirectory, "DefaultLayouts", "Artemis");
public static string KeyboardAnsi => TestLayouts.KeyboardAnsi;
public static string KeyboardTklAnsi => Keyboard("Artemis TKL keyboard-ANSI");
public static string KeyboardXlAnsi => Keyboard("Artemis XL keyboard-ANSI");
public static string Headset => Path.Combine(Root, "Headset", "Artemis 2 LEDs headset.xml");
public static string LedStrip => TestLayouts.SmallLedStrip;
/// <summary>
/// Just the keys around the movement keys of an AZERTY keyboard, A sits above Q
/// </summary>
public static string AzertyKeys => Path.Combine(AppContext.BaseDirectory, "TestLayouts", "Test AZERTY keys.xml");
/// <summary>
/// The names of every keyboard layout shipped with Artemis, use <see cref="Keyboard" /> to get its path
/// </summary>
public static TheoryData<string> KeyboardNames => new(
"Artemis L keyboard-ANSI", "Artemis L keyboard-ISO",
"Artemis TKL keyboard-ANSI", "Artemis TKL keyboard-ISO",
"Artemis XL keyboard-ANSI", "Artemis XL keyboard-ISO"
);
/// <summary>
/// Gets the path to the keyboard layout shipped with Artemis by its name
/// </summary>
public static string Keyboard(string name)
{
return Path.Combine(Root, "Keyboard", name + ".xml");
}
}
@@ -0,0 +1,62 @@
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Rendering;
using RGB.NET.Core;
namespace Artemis.Core.Tests.Profiles.Adaption;
/// <summary>
/// Base class for adaption tests, gives each test its own harness so tests can pick their own devices
/// </summary>
public abstract class AdaptionTestBase : IDisposable
{
protected RenderHarness Harness { get; } = new();
protected ArtemisDevice AddDevice(string layoutPath, float x = 0, float y = 0)
{
return Harness.AddDevice(layoutPath, x, y);
}
protected List<ArtemisDevice> Devices => Harness.Devices.ToList();
protected Layer AddLayer(Profile profile, IEnumerable<ArtemisLed> leds, string name = "Test layer")
{
return Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), leds, name).Layer;
}
protected Layer AddLayer(Profile profile, string name = "Test layer")
{
return AddLayer(profile, [], name);
}
protected static ArtemisLed Led(ArtemisDevice device, LedId ledId)
{
return device.GetLed(ledId, false) ?? throw new ArgumentException($"{device.RgbDevice.DeviceInfo.Model} has no LED {ledId}", nameof(ledId));
}
protected static ArtemisLed[] Leds(ArtemisDevice device, params LedId[] ledIds)
{
return ledIds.Select(id => Led(device, id)).ToArray();
}
/// <summary>
/// Gets the IDs of the layer's LEDs on the given device, sorted so they can be compared
/// </summary>
protected static LedId[] LedIdsOn(Layer layer, ArtemisDevice device)
{
return layer.Leds.Where(l => l.Device == device).Select(l => l.RgbLed.Id).Order().ToArray();
}
/// <summary>
/// Gets the IDs of all LEDs on the given device, sorted so they can be compared
/// </summary>
protected static LedId[] AllLedIds(ArtemisDevice device)
{
return device.Leds.Select(l => l.RgbLed.Id).Order().ToArray();
}
public void Dispose()
{
Harness.Dispose();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,354 @@
using Artemis.Storage.Entities.Profile;
using RGB.NET.Core;
namespace Artemis.Core.Tests.Profiles.Adaption;
public class LayerAdapterTests : AdaptionTestBase
{
#region Adapting without hints
[Fact]
public void Adapt_WithoutHintsFindsTheSameKeysOnAnotherKeyboard()
{
ArtemisDevice full = AddDevice(AdaptionLayouts.KeyboardAnsi);
Profile profile = Harness.CreateProfile();
AddLayer(profile, Leds(full, LedId.Keyboard_W, LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D));
ProfileEntity saved = Harness.Save(profile);
Harness.RemoveDevice(full);
ArtemisDevice tenkeyless = AddDevice(AdaptionLayouts.KeyboardTklAnsi);
Layer layer = Harness.Load(saved).GetAllLayers().Single();
layer.Leds.ShouldBeEmpty("the stored LEDs belong to a different device");
layer.Adapter.Adapt(Devices);
layer.Leds.ShouldBe(Leds(tenkeyless, LedId.Keyboard_W, LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D), ignoreOrder: true);
}
[Fact]
public void Adapt_WithoutHintsSkipsKeysTheOtherKeyboardDoesNotHave()
{
ArtemisDevice full = AddDevice(AdaptionLayouts.KeyboardAnsi);
Profile profile = Harness.CreateProfile();
AddLayer(profile, Leds(full, LedId.Keyboard_Escape, LedId.Keyboard_Num5));
ProfileEntity saved = Harness.Save(profile);
Harness.RemoveDevice(full);
ArtemisDevice tenkeyless = AddDevice(AdaptionLayouts.KeyboardTklAnsi);
Layer layer = Harness.Load(saved).GetAllLayers().Single();
layer.Adapter.Adapt(Devices);
layer.Leds.ShouldBe([Led(tenkeyless, LedId.Keyboard_Escape)]);
}
[Fact]
public void Adapt_WithoutHintsUsesEachKeyOnce()
{
ArtemisDevice full = AddDevice(AdaptionLayouts.KeyboardAnsi);
Profile profile = Harness.CreateProfile();
AddLayer(profile, Leds(full, LedId.Keyboard_Escape));
ProfileEntity saved = Harness.Save(profile);
Harness.RemoveDevice(full);
ArtemisDevice first = AddDevice(AdaptionLayouts.KeyboardTklAnsi, 0, 200);
AddDevice(AdaptionLayouts.KeyboardTklAnsi, 0, 0);
Layer layer = Harness.Load(saved).GetAllLayers().Single();
layer.Adapter.Adapt(Devices);
// The first device in the list gets the key, regardless of where the devices are
layer.Leds.ShouldBe([Led(first, LedId.Keyboard_Escape)]);
}
[Fact]
public void Adapt_WithoutHintsWorksFromTheStoredLeds()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
Profile profile = Harness.CreateProfile();
Layer layer = AddLayer(profile, Leds(keyboard, LedId.Keyboard_Escape));
Harness.Save(profile);
layer.AddLed(Led(keyboard, LedId.Keyboard_F1));
layer.ClearLeds();
layer.Adapter.Adapt(Devices);
// The LEDs the layer had when it was last saved, not the ones it has now
layer.Leds.ShouldBe([Led(keyboard, LedId.Keyboard_Escape)]);
}
[Fact]
public void Adapt_KeepsTheLayersCurrentLeds()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
Profile profile = Harness.CreateProfile();
Layer layer = AddLayer(profile, Leds(keyboard, LedId.Keyboard_Escape));
layer.Adapter.Add(new SingleLedAdaptionHint {LedId = LedId.Keyboard_F1});
layer.Adapter.Adapt(Devices);
// Callers clear the layer first if they want only the adapted LEDs, like the editor's "apply adaption hints" does
layer.Leds.ShouldBe(Leds(keyboard, LedId.Keyboard_Escape, LedId.Keyboard_F1), ignoreOrder: true);
}
[Fact]
public void Adapt_WithHintsIgnoresTheStoredLeds()
{
ArtemisDevice full = AddDevice(AdaptionLayouts.KeyboardAnsi);
Profile profile = Harness.CreateProfile();
Layer original = AddLayer(profile, Leds(full, LedId.Keyboard_W, LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D));
original.Adapter.Add(new SingleLedAdaptionHint {LedId = LedId.Keyboard_Escape});
ProfileEntity saved = Harness.Save(profile);
Harness.RemoveDevice(full);
ArtemisDevice tenkeyless = AddDevice(AdaptionLayouts.KeyboardTklAnsi);
Layer layer = Harness.Load(saved).GetAllLayers().Single();
layer.Adapter.Adapt(Devices);
layer.Leds.ShouldBe([Led(tenkeyless, LedId.Keyboard_Escape)]);
}
[Fact]
public void Adapt_AppliesEveryHint()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
ArtemisDevice strip = AddDevice(AdaptionLayouts.LedStrip, 0, 200);
Layer layer = AddLayer(Harness.CreateProfile());
layer.Adapter.Add(new KeyboardSectionAdaptionHint {Section = KeyboardSection.FunctionKeys});
layer.Adapter.Add(new DeviceAdaptionHint {DeviceType = RGBDeviceType.LedStripe});
layer.Adapter.Adapt(Devices);
LedIdsOn(layer, keyboard).Length.ShouldBe(12);
LedIdsOn(layer, strip).ShouldBe(AllLedIds(strip));
}
#endregion
#region Determining hints
[Fact]
public void DetermineHints_ForALayerCoveringEverythingAddsAHintForAllDevices()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
ArtemisDevice strip = AddDevice(AdaptionLayouts.LedStrip, 0, 200);
Layer layer = AddLayer(Harness.CreateProfile(), [..keyboard.Leds, ..strip.Leds]);
List<IAdaptionHint> hints = layer.Adapter.DetermineHints(Devices);
DeviceAdaptionHint hint = hints.Single().ShouldBeOfType<DeviceAdaptionHint>();
hint.DeviceType.ShouldBe(RGBDeviceType.All);
layer.Adapter.AdaptionHints.ShouldBe(hints);
}
[Fact]
public void DetermineHints_ForALayerCoveringADeviceAddsAHintForItsTypeAndCategory()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
AddDevice(AdaptionLayouts.LedStrip, 0, 200);
foreach (ArtemisDevice device in Devices)
device.ApplyDefaultCategories();
Layer layer = AddLayer(Harness.CreateProfile(), keyboard.Leds);
List<IAdaptionHint> hints = layer.Adapter.DetermineHints(Devices);
hints.Count.ShouldBe(2);
hints[0].ShouldBeOfType<DeviceAdaptionHint>().DeviceType.ShouldBe(RGBDeviceType.Keyboard);
hints[1].ShouldBeOfType<CategoryAdaptionHint>().Category.ShouldBe(DeviceCategory.Peripherals);
}
[Fact]
public void DetermineHints_OnlyAddsACategoryHintWhenEveryDeviceInItIsCovered()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
AddDevice(AdaptionLayouts.Headset, 500, 0);
foreach (ArtemisDevice device in Devices)
device.ApplyDefaultCategories();
Layer layer = AddLayer(Harness.CreateProfile(), keyboard.Leds);
List<IAdaptionHint> hints = layer.Adapter.DetermineHints(Devices);
hints.Single().ShouldBeOfType<DeviceAdaptionHint>().DeviceType.ShouldBe(RGBDeviceType.Keyboard);
}
[Fact]
public void DetermineHints_ForASingleLedAddsAHintForOneSuchLed()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
Layer layer = AddLayer(Harness.CreateProfile(), Leds(keyboard, LedId.Keyboard_Escape));
List<IAdaptionHint> hints = layer.Adapter.DetermineHints(Devices);
SingleLedAdaptionHint hint = hints.Single().ShouldBeOfType<SingleLedAdaptionHint>();
hint.LedId.ShouldBe(LedId.Keyboard_Escape);
hint.LimitAmount.ShouldBeTrue();
hint.Amount.ShouldBe(1);
hint.Skip.ShouldBe(0);
}
[Fact]
public void DetermineHints_ForAFewLedsAddsNothing()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
Layer layer = AddLayer(Harness.CreateProfile(), Leds(keyboard, LedId.Keyboard_W, LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D));
layer.Adapter.DetermineHints(Devices).ShouldBeEmpty();
layer.Adapter.AdaptionHints.ShouldBeEmpty();
}
[Fact]
public void DetermineHints_DoesNotRepeatExistingHints()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
AddDevice(AdaptionLayouts.LedStrip, 0, 200);
foreach (ArtemisDevice device in Devices)
device.ApplyDefaultCategories();
Layer layer = AddLayer(Harness.CreateProfile(), keyboard.Leds);
layer.Adapter.DetermineHints(Devices);
List<IAdaptionHint> second = layer.Adapter.DetermineHints(Devices);
second.ShouldBeEmpty();
layer.Adapter.AdaptionHints.Count.ShouldBe(2);
}
[Fact]
public void DetermineHints_WithoutDevicesAddsAHintForAllDevices()
{
Layer layer = AddLayer(Harness.CreateProfile());
// Every one of no devices is covered
layer.Adapter.DetermineHints([]).Single().ShouldBeOfType<DeviceAdaptionHint>().DeviceType.ShouldBe(RGBDeviceType.All);
}
[Fact]
public void DetermineHints_ThenAdaptingRestoresTheLayer()
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
ArtemisDevice strip = AddDevice(AdaptionLayouts.LedStrip, 0, 200);
foreach (ArtemisDevice device in Devices)
device.ApplyDefaultCategories();
Profile profile = Harness.CreateProfile();
Layer wholeKeyboard = AddLayer(profile, keyboard.Leds);
Layer escape = AddLayer(profile, Leds(keyboard, LedId.Keyboard_Escape));
Layer wholeStrip = AddLayer(profile, strip.Leds);
foreach (Layer layer in profile.GetAllLayers())
{
List<ArtemisLed> before = layer.Leds.ToList();
layer.Adapter.DetermineHints(Devices);
layer.ClearLeds();
layer.Adapter.Adapt(Devices);
layer.Leds.ShouldBe(before, ignoreOrder: true, layer.Name);
}
wholeKeyboard.Adapter.AdaptionHints.Count.ShouldBe(2);
escape.Adapter.AdaptionHints.Count.ShouldBe(1);
wholeStrip.Adapter.AdaptionHints.Count.ShouldBe(2);
}
#endregion
#region Managing hints
[Fact]
public void Add_AddsTheHintOnceAndRaisesAnEvent()
{
AddDevice(AdaptionLayouts.KeyboardAnsi);
LayerAdapter adapter = AddLayer(Harness.CreateProfile()).Adapter;
List<IAdaptionHint> added = [];
adapter.AdapterHintAdded += (_, e) => added.Add(e.AdaptionHint);
DeviceAdaptionHint hint = new();
adapter.Add(hint);
adapter.Add(hint);
adapter.AdaptionHints.ShouldBe([hint]);
added.ShouldBe([hint]);
}
[Fact]
public void Remove_RemovesTheHintAndRaisesAnEvent()
{
AddDevice(AdaptionLayouts.KeyboardAnsi);
LayerAdapter adapter = AddLayer(Harness.CreateProfile()).Adapter;
DeviceAdaptionHint hint = new();
adapter.Add(hint);
List<IAdaptionHint> removed = [];
adapter.AdapterHintRemoved += (_, e) => removed.Add(e.AdaptionHint);
adapter.Remove(hint);
adapter.Remove(hint);
adapter.AdaptionHints.ShouldBeEmpty();
removed.ShouldBe([hint]);
}
[Fact]
public void Clear_RemovesEveryHintOneByOne()
{
AddDevice(AdaptionLayouts.KeyboardAnsi);
LayerAdapter adapter = AddLayer(Harness.CreateProfile()).Adapter;
DeviceAdaptionHint first = new();
CategoryAdaptionHint second = new();
adapter.Add(first);
adapter.Add(second);
List<IAdaptionHint> removed = [];
adapter.AdapterHintRemoved += (_, e) => removed.Add(e.AdaptionHint);
adapter.Clear();
adapter.AdaptionHints.ShouldBeEmpty();
removed.ShouldBe([first, second]);
}
[Fact]
public void Adapter_BelongsToItsLayer()
{
AddDevice(AdaptionLayouts.KeyboardAnsi);
Layer layer = AddLayer(Harness.CreateProfile());
layer.Adapter.Layer.ShouldBeSameAs(layer);
}
#endregion
#region Storage
[Fact]
public void Hints_AreStoredWithTheProfile()
{
AddDevice(AdaptionLayouts.KeyboardAnsi);
Profile profile = Harness.CreateProfile();
Layer layer = AddLayer(profile);
layer.Adapter.Add(new DeviceAdaptionHint {DeviceType = RGBDeviceType.Keyboard, Skip = 1, LimitAmount = true, Amount = 2});
layer.Adapter.Add(new CategoryAdaptionHint {Category = DeviceCategory.Desk, Skip = 3, LimitAmount = true, Amount = 4});
layer.Adapter.Add(new KeyboardSectionAdaptionHint {Section = KeyboardSection.NumPad});
layer.Adapter.Add(new SingleLedAdaptionHint {LedId = LedId.Keyboard_Escape, Skip = 5, LimitAmount = true, Amount = 6});
Layer reloaded = Harness.SaveAndReload(profile).GetAllLayers().Single();
reloaded.Adapter.AdaptionHints.Select(h => h.ToString()).ShouldBe(layer.Adapter.AdaptionHints.Select(h => h.ToString()));
reloaded.Adapter.AdaptionHints.Select(h => h.GetType()).ShouldBe([
typeof(DeviceAdaptionHint), typeof(CategoryAdaptionHint), typeof(KeyboardSectionAdaptionHint), typeof(SingleLedAdaptionHint)
]);
}
[Fact]
public void Load_ReplacesTheHintsWithTheStoredOnes()
{
AddDevice(AdaptionLayouts.KeyboardAnsi);
Profile profile = Harness.CreateProfile();
Layer layer = AddLayer(profile);
layer.Adapter.Add(new KeyboardSectionAdaptionHint {Section = KeyboardSection.NumPad});
layer.Save();
List<IAdaptionHint> removed = [];
layer.Adapter.AdapterHintRemoved += (_, e) => removed.Add(e.AdaptionHint);
layer.Adapter.Add(new DeviceAdaptionHint());
layer.Adapter.Load();
layer.Adapter.AdaptionHints.Single().ShouldBeOfType<KeyboardSectionAdaptionHint>().Section.ShouldBe(KeyboardSection.NumPad);
removed.ShouldBeEmpty("loading drops the current hints without reporting them as removed");
}
#endregion
}
@@ -0,0 +1,112 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Rendering;
using Artemis.Storage.Entities.Profile;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Profiles.Adaption;
/// <summary>
/// Profiles made on a full size ANSI keyboard and adapted to each keyboard Artemis ships a layout for, the way
/// Artemis adapts a profile that is imported or shared through the workshop
/// </summary>
public class ProfileAdaptionTests : AdaptionTestBase
{
[Theory]
[MemberData(nameof(AdaptionLayouts.KeyboardNames), MemberType = typeof(AdaptionLayouts))]
public void Profile_AdaptsToEveryKeyboard(string keyboardName)
{
ProfileEntity saved = CreateProfileOnAFullSizeKeyboard();
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.Keyboard(keyboardName));
Profile profile = Harness.Load(saved);
AdaptProfile(profile);
LedIdsOn(Layer(profile, "Everything"), keyboard).ShouldBe(AllLedIds(keyboard));
LedIdsOn(Layer(profile, "F-keys"), keyboard).ShouldBe(Enumerable.Range(0, 12).Select(i => LedId.Keyboard_F1 + i).ToArray());
LedIdsOn(Layer(profile, "Escape"), keyboard).ShouldBe([LedId.Keyboard_Escape]);
LedIdsOn(Layer(profile, "WASD"), keyboard).ShouldBe(new[] {LedId.Keyboard_W, LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D}.Order().ToArray());
LedIdsOn(Layer(profile, "Numpad"), keyboard).Length.ShouldBe(keyboardName.Contains("TKL") ? 0 : 17);
}
[Fact]
public void Profile_AdaptsToTwoKeyboards()
{
ProfileEntity saved = CreateProfileOnAFullSizeKeyboard();
ArtemisDevice tenkeyless = AddDevice(AdaptionLayouts.KeyboardTklAnsi);
ArtemisDevice extraLarge = AddDevice(AdaptionLayouts.KeyboardXlAnsi, 0, 200);
Profile profile = Harness.Load(saved);
AdaptProfile(profile);
// Hints apply to every keyboard, without hints each LED goes to the first keyboard only
Layer(profile, "Everything").Leds.Count.ShouldBe(tenkeyless.Leds.Count + extraLarge.Leds.Count);
LedIdsOn(Layer(profile, "F-keys"), extraLarge).Length.ShouldBe(12);
LedIdsOn(Layer(profile, "Escape"), tenkeyless).ShouldBe([LedId.Keyboard_Escape]);
LedIdsOn(Layer(profile, "Escape"), extraLarge).ShouldBeEmpty("the hint is limited to one LED");
LedIdsOn(Layer(profile, "WASD"), tenkeyless).Length.ShouldBe(4);
LedIdsOn(Layer(profile, "WASD"), extraLarge).ShouldBeEmpty();
LedIdsOn(Layer(profile, "Numpad"), extraLarge).Length.ShouldBe(17);
}
[Fact]
public void AdaptedProfile_RendersOnTheNewKeyboard()
{
ProfileEntity saved = CreateProfileOnAFullSizeKeyboard(["F-keys"]);
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardXlAnsi);
Profile profile = Harness.Load(saved);
AdaptProfile(profile);
using RenderFrame frame = Harness.Render(profile, TimeSpan.FromSeconds(0.1));
for (LedId ledId = LedId.Keyboard_F1; ledId <= LedId.Keyboard_F12; ledId++)
frame.GetColor(keyboard, ledId).ShouldBeColor(SKColors.Red, context: ledId.ToString());
frame.GetColor(keyboard, LedId.Keyboard_Escape).ShouldBeColor(SKColors.Black);
}
/// <summary>
/// Creates a profile on a full size ANSI keyboard and returns it as stored, the keyboard is removed again
/// </summary>
/// <param name="only">The names of the layers to keep, defaults to all of them</param>
private ProfileEntity CreateProfileOnAFullSizeKeyboard(string[]? only = null)
{
ArtemisDevice keyboard = AddDevice(AdaptionLayouts.KeyboardAnsi);
Profile profile = Harness.CreateProfile();
// Gets a hint for all devices
AddLayer(profile, keyboard.Leds, "Everything").Adapter.DetermineHints(Devices);
// Gets a single LED hint
AddLayer(profile, Leds(keyboard, LedId.Keyboard_Escape), "Escape").Adapter.DetermineHints(Devices);
// Gets a keyboard section hint, picked by hand
Layer functionKeys = AddLayer(profile, Enumerable.Range(0, 12).Select(i => Led(keyboard, LedId.Keyboard_F1 + i)), "F-keys");
functionKeys.Adapter.Add(new KeyboardSectionAdaptionHint {Section = KeyboardSection.FunctionKeys});
Layer numpad = AddLayer(profile, [], "Numpad");
numpad.Adapter.Add(new KeyboardSectionAdaptionHint {Section = KeyboardSection.NumPad});
// No hints, adapting finds the same keys
AddLayer(profile, Leds(keyboard, LedId.Keyboard_W, LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D), "WASD");
if (only != null)
{
foreach (Layer layer in profile.GetAllLayers().Where(l => !only.Contains(l.Name)))
layer.Parent.RemoveChild(layer);
}
ProfileEntity saved = Harness.Save(profile);
Harness.RemoveDevice(keyboard);
return saved;
}
/// <summary>
/// Adapts every layer the way ProfileService.AdaptProfile does
/// </summary>
private void AdaptProfile(Profile profile)
{
foreach (Layer layer in profile.GetAllLayers())
layer.Adapter.Adapt(Devices);
}
private static Layer Layer(Profile profile, string name)
{
return profile.GetAllLayers().Single(l => l.Name == name);
}
}
@@ -0,0 +1,181 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Rendering;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Profiles;
/// <summary>
/// Every blend mode in the situations that matter for Artemis. The expected colors come from a reference render: the
/// same scene drawn directly with Skia onto a single pixel, stacked the way Artemis does it. The canvas is cleared to
/// black, the root folder and every other folder composite their children in a layer of their own, and every layer
/// composites itself with its blend mode and opacity.
/// <para>
/// Skia's blend math is taken as a given, what's tested is that Artemis composites each element in the right place
/// with the right mode and alpha.
/// </para>
/// </summary>
public class BlendModeTests : RenderTestBase
{
private static readonly SKColor Source = new(200, 100, 50);
private static readonly SKColor Destination = new(40, 160, 220);
public static TheoryData<SKBlendMode> BlendModes => new(Enum.GetValues<SKBlendMode>());
[Theory]
[MemberData(nameof(BlendModes))]
public void BlendMode_OverASiblingInTheSameFolder(SKBlendMode blendMode)
{
Profile profile = Harness.CreateProfile();
AddSolid(profile.GetRootFolder(), Source, blendMode, 100);
AddSolid(profile.GetRootFolder(), Destination, SKBlendMode.SrcOver, 100);
using RenderFrame frame = Harness.Render(profile, TimeSpan.FromSeconds(0.1));
SaveOutput(frame, $"{TestName}_{blendMode}");
SKColor expected = ReferenceRender(canvas =>
{
using (RootFolder(canvas))
{
DrawLayer(canvas, Destination, SKBlendMode.SrcOver, 255);
DrawLayer(canvas, Source, blendMode, 255);
}
});
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(expected);
}
[Theory]
[MemberData(nameof(BlendModes))]
public void BlendMode_WithHalfLayerOpacity(SKBlendMode blendMode)
{
Profile profile = Harness.CreateProfile();
AddSolid(profile.GetRootFolder(), Source, blendMode, 50);
AddSolid(profile.GetRootFolder(), Destination, SKBlendMode.SrcOver, 100);
using RenderFrame frame = Harness.Render(profile, TimeSpan.FromSeconds(0.1));
SaveOutput(frame, $"{TestName}_{blendMode}");
SKColor expected = ReferenceRender(canvas =>
{
using (RootFolder(canvas))
{
DrawLayer(canvas, Destination, SKBlendMode.SrcOver, 255);
// Artemis turns the opacity percentage into an alpha with (byte) (opacity * 2.55f)
DrawLayer(canvas, Source, blendMode, 127);
}
});
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(expected);
}
[Theory]
[MemberData(nameof(BlendModes))]
public void BlendMode_InAFolderAboveTheDestination(SKBlendMode blendMode)
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
AddSolid(folder, Source, blendMode, 100);
AddSolid(profile.GetRootFolder(), Destination, SKBlendMode.SrcOver, 100);
using RenderFrame frame = Harness.Render(profile, TimeSpan.FromSeconds(0.1));
SaveOutput(frame, $"{TestName}_{blendMode}");
// The folder isolates the blending layer, it blends with the empty folder and the folder is drawn over the destination
SKColor expected = ReferenceRender(canvas =>
{
using (RootFolder(canvas))
{
DrawLayer(canvas, Destination, SKBlendMode.SrcOver, 255);
using (Folder(canvas))
DrawLayer(canvas, Source, blendMode, 255);
}
});
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(expected);
}
/// <summary>
/// Hand calculated for an opaque source S = (200, 100, 50) and an opaque destination D = (40, 160, 220), independent
/// of the reference render. In a folder the blend happens against the empty folder, which is then drawn over D.
/// </summary>
[Theory]
[InlineData(SKBlendMode.SrcATop, "#C86432", "#28A0DC")] // S atop D is S, S atop nothing is nothing
[InlineData(SKBlendMode.SrcIn, "#C86432", "#28A0DC")] // S within D is S, S within nothing is nothing
[InlineData(SKBlendMode.DstOut, "#000000", "#28A0DC")] // Cuts D away, in a folder there is nothing to cut
[InlineData(SKBlendMode.Clear, "#000000", "#28A0DC")] // Clears D, in a folder it clears nothing
[InlineData(SKBlendMode.Xor, "#000000", "#C86432")] // Two opaque colors cancel out, against nothing S remains
[InlineData(SKBlendMode.Screen, "#D1C5E3", "#C86432")] // S + D - S * D, against nothing S remains
public void CommonBlendModes_BehaveDifferentlyInAFolder(SKBlendMode blendMode, string expectedInRoot, string expectedInFolder)
{
Profile profile = Harness.CreateProfile();
Folder root = profile.GetRootFolder();
// A: blending layer in a folder above D
Folder folder = Harness.AddFolder(root);
(Layer inFolder, SolidColorTestBrush inFolderBrush) = Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)], "In folder");
inFolderBrush.Properties.Color.SetCurrentValue(Source);
inFolder.General.BlendMode.SetCurrentValue(blendMode);
// S: blending layer directly above D
(Layer inRoot, SolidColorTestBrush inRootBrush) = Harness.AddLayer<SolidColorTestBrush>(root, [Led(LedId.Keyboard_S)], "In root");
inRootBrush.Properties.Color.SetCurrentValue(Source);
inRoot.General.BlendMode.SetCurrentValue(blendMode);
(_, SolidColorTestBrush destination) = Harness.AddLayer<SolidColorTestBrush>(root, Leds(LedId.Keyboard_A, LedId.Keyboard_S), "Destination");
destination.Properties.Color.SetCurrentValue(Destination);
using RenderFrame frame = Harness.Render(profile, TimeSpan.FromSeconds(0.1));
SaveOutput(frame, $"{TestName}_{blendMode}");
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(SKColor.Parse(expectedInRoot));
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColor.Parse(expectedInFolder));
}
private void AddSolid(Folder parent, SKColor color, SKBlendMode blendMode, float opacity)
{
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(parent, [Led(LedId.Keyboard_A)], $"{color} {blendMode}");
brush.Properties.Color.SetCurrentValue(color);
layer.General.BlendMode.SetCurrentValue(blendMode);
layer.Transform.Opacity.SetCurrentValue(opacity);
}
#region Reference render
private static SKColor ReferenceRender(Action<SKCanvas> draw)
{
using SKBitmap bitmap = new(1, 1);
using SKCanvas canvas = new(bitmap);
// SurfaceManager clears the canvas to black before every frame
canvas.Clear(SKColors.Black);
draw(canvas);
canvas.Flush();
return bitmap.GetPixel(0, 0);
}
/// <summary>
/// Folder.Render composites its children in a layer, using a default paint
/// </summary>
private static SKAutoCanvasRestore Folder(SKCanvas canvas)
{
// Created before saving the layer so disposing it restores the layer
SKAutoCanvasRestore restore = new(canvas, false);
using SKPaint paint = new();
canvas.SaveLayer(paint);
return restore;
}
private static SKAutoCanvasRestore RootFolder(SKCanvas canvas)
{
return Folder(canvas);
}
/// <summary>
/// Layer.DelegateRendering composites the layer with its blend mode and opacity, the brush draws inside that layer
/// </summary>
private static void DrawLayer(SKCanvas canvas, SKColor color, SKBlendMode blendMode, byte alpha)
{
using SKPaint layerPaint = new() {BlendMode = blendMode, Color = new SKColor(0, 0, 0, alpha)};
canvas.SaveLayer(layerPaint);
using SKPaint brushPaint = new() {Color = color};
canvas.DrawRect(SKRect.Create(0, 0, 1, 1), brushPaint);
canvas.Restore();
}
#endregion
}
@@ -0,0 +1,99 @@
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
using Artemis.Core.Tests.Infrastructure.Rendering;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Profiles;
/// <summary>
/// A folder with a variety of children, so folder behaviour can't pass by accident on a single solid layer:
/// overlapping layers, a gradient, a per-LED brush and a nested folder with a half transparent layer and a layer that
/// has an effect of its own. Next to the folder the profile contains a layer that isn't part of it.
/// </summary>
public sealed class BusyFolder
{
private BusyFolder(Folder folder, Folder nested, Layer outside, ArtemisLed[] leds, ArtemisLed[] nestedLeds)
{
Folder = folder;
Nested = nested;
Outside = outside;
Leds = leds;
NestedLeds = nestedLeds;
}
/// <summary>
/// Gets the busy folder
/// </summary>
public Folder Folder { get; }
/// <summary>
/// Gets the folder nested inside the busy folder
/// </summary>
public Folder Nested { get; }
/// <summary>
/// Gets the layer next to the busy folder, it does not overlap any of the folder's LEDs
/// </summary>
public Layer Outside { get; }
/// <summary>
/// Gets all LEDs covered by the busy folder, including those of the nested folder
/// </summary>
public ArtemisLed[] Leds { get; }
/// <summary>
/// Gets the LEDs covered by the nested folder
/// </summary>
public ArtemisLed[] NestedLeds { get; }
/// <summary>
/// Adds a busy folder to the root of the profile, with a layer below it on LEDs the folder doesn't cover.
/// </summary>
public static BusyFolder Create(RenderHarness harness, ArtemisDevice keyboard, Profile profile)
{
ArtemisLed[] Leds(params LedId[] ids) => ids.Select(id => keyboard.GetLed(id, false)!).ToArray();
Folder folder = harness.AddFolder(profile.GetRootFolder(), "Busy folder");
// Solid red on top, partially covering a blue layer below it
(_, SolidColorTestBrush solid) = harness.AddLayer<SolidColorTestBrush>(folder, Leds(LedId.Keyboard_Q, LedId.Keyboard_W, LedId.Keyboard_E, LedId.Keyboard_R), "Solid");
solid.Properties.Color.SetCurrentValue(SKColors.Red);
(_, SolidColorTestBrush covered) = harness.AddLayer<SolidColorTestBrush>(folder, Leds(LedId.Keyboard_R, LedId.Keyboard_T, LedId.Keyboard_Y), "Partially covered");
covered.Properties.Color.SetCurrentValue(SKColors.Blue);
// A gradient, giving a range of colors
(_, GradientTestBrush gradient) = harness.AddLayer<GradientTestBrush>(folder, Leds(LedId.Keyboard_U, LedId.Keyboard_I, LedId.Keyboard_O, LedId.Keyboard_P), "Gradient");
gradient.Properties.Left.SetCurrentValue(SKColors.Lime);
gradient.Properties.Right.SetCurrentValue(new SKColor(255, 0, 255));
// A per-LED brush, which renders through a different code path
(_, PerLedTestBrush perLed) = harness.AddLayer<PerLedTestBrush>(folder, Leds(LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D, LedId.Keyboard_F), "Per LED");
perLed.ColorSelector = (led, _) => led.RgbLed.Id switch
{
LedId.Keyboard_A => SKColors.Red,
LedId.Keyboard_S => SKColors.Lime,
LedId.Keyboard_D => SKColors.Blue,
_ => SKColors.Yellow
};
// A nested folder with a half transparent layer and a layer with an effect of its own
Folder nested = harness.AddFolder(folder, "Nested folder");
(Layer halfTransparent, SolidColorTestBrush cyan) = harness.AddLayer<SolidColorTestBrush>(nested, Leds(LedId.Keyboard_Z, LedId.Keyboard_X, LedId.Keyboard_C), "Half transparent");
cyan.Properties.Color.SetCurrentValue(SKColors.Cyan);
halfTransparent.Transform.Opacity.SetCurrentValue(50);
(Layer tinted, SolidColorTestBrush white) = harness.AddLayer<SolidColorTestBrush>(nested, Leds(LedId.Keyboard_V, LedId.Keyboard_B, LedId.Keyboard_N), "Tinted");
white.Properties.Color.SetCurrentValue(SKColors.White);
harness.AddEffect<TintTestEffect>(tinted).Properties.Color.SetCurrentValue(new SKColor(255, 128, 0));
// Below the folder, on LEDs the folder doesn't cover
(Layer outside, SolidColorTestBrush orange) = harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), Leds(
LedId.Keyboard_G, LedId.Keyboard_H, LedId.Keyboard_J, LedId.Keyboard_1, LedId.Keyboard_2, LedId.Keyboard_3
), "Outside");
orange.Properties.Color.SetCurrentValue(new SKColor(255, 160, 32));
ArtemisLed[] nestedLeds = nested.GetAllLayers().SelectMany(l => l.Leds).Distinct().ToArray();
ArtemisLed[] leds = folder.GetAllLayers().SelectMany(l => l.Leds).Distinct().ToArray();
return new BusyFolder(folder, nested, outside, leds, nestedLeds);
}
}
@@ -0,0 +1,207 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
using Artemis.Core.Tests.Infrastructure.Rendering;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Profiles;
/// <summary>
/// Z-order and blend modes across folders. Every folder (the root folder included) composites its children into a
/// layer of its own, so a blend mode only blends with siblings below it in the same folder, never with what is below
/// the folder.
/// </summary>
public class CompositingTests : RenderTestBase
{
#region Z-order
[Fact]
public void ZOrder_IsDepthFirstWithTheFirstChildOnTop()
{
Profile profile = Harness.CreateProfile();
Folder root = profile.GetRootFolder();
Folder folderA = Harness.AddFolder(root, "A");
AddSolid(folderA, SKColors.Red, LedId.Keyboard_1, LedId.Keyboard_2, LedId.Keyboard_3);
Folder folderA2 = Harness.AddFolder(folderA, "A2");
AddSolid(folderA2, SKColors.Lime, LedId.Keyboard_2, LedId.Keyboard_3, LedId.Keyboard_4);
AddSolid(root, SKColors.Blue, LedId.Keyboard_3, LedId.Keyboard_4, LedId.Keyboard_5);
Folder folderC = Harness.AddFolder(root, "C");
AddSolid(folderC, SKColors.Yellow, LedId.Keyboard_4, LedId.Keyboard_5, LedId.Keyboard_6);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_1).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_2).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_3).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_4).ShouldBeColor(SKColors.Lime);
frame.GetColor(Keyboard, LedId.Keyboard_5).ShouldBeColor(SKColors.Blue);
frame.GetColor(Keyboard, LedId.Keyboard_6).ShouldBeColor(SKColors.Yellow);
frame.GetColor(Keyboard, LedId.Keyboard_7).ShouldBeColor(SKColors.Black);
}
[Fact]
public void ZOrder_MovingAFolderMovesAllOfItsChildren()
{
Profile profile = Harness.CreateProfile();
Folder root = profile.GetRootFolder();
Folder top = Harness.AddFolder(root, "Top");
AddSolid(top, SKColors.Red, LedId.Keyboard_1, LedId.Keyboard_2);
Folder bottom = Harness.AddFolder(root, "Bottom");
AddSolid(bottom, SKColors.Blue, LedId.Keyboard_1);
AddSolid(bottom, SKColors.Lime, LedId.Keyboard_2);
root.RemoveChild(bottom);
root.AddChild(bottom, 0);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_1).ShouldBeColor(SKColors.Blue);
frame.GetColor(Keyboard, LedId.Keyboard_2).ShouldBeColor(SKColors.Lime);
}
#endregion
#region Blend modes
[Fact]
public void BlendMode_BlendsWithSiblingsBelowItInTheSameFolder()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
AddSolid(folder, SKColors.Blue, SKBlendMode.Plus, LedId.Keyboard_A);
AddSolid(folder, SKColors.Red, LedId.Keyboard_A);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Magenta);
}
[Fact]
public void BlendMode_DoesNotReachElementsBelowTheFolder()
{
Profile profile = Harness.CreateProfile();
Folder root = profile.GetRootFolder();
// A: the blending layer is in a folder above the red layer
Folder folder = Harness.AddFolder(root);
AddSolid(folder, SKColors.Blue, SKBlendMode.Plus, LedId.Keyboard_A);
// S: the same setup without the folder
AddSolid(root, SKColors.Blue, SKBlendMode.Plus, LedId.Keyboard_S);
AddSolid(root, SKColors.Red, LedId.Keyboard_A, LedId.Keyboard_S);
using RenderFrame frame = Render(profile);
// Inside the folder the layer is added to nothing, the folder then simply covers the red layer
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Blue);
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(SKColors.Magenta);
}
[Fact]
public void BlendMode_DoesNotReachBeyondANestedFolder()
{
Profile profile = Harness.CreateProfile();
Folder outer = Harness.AddFolder(profile.GetRootFolder(), "Outer");
Folder nested = Harness.AddFolder(outer, "Nested");
AddSolid(nested, SKColors.Blue, SKBlendMode.Plus, LedId.Keyboard_A);
AddSolid(outer, SKColors.Red, LedId.Keyboard_A);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Blue);
}
[Fact]
public void MultiplyInAFolder_LeavesTheColorUnchangedInsteadOfDarkeningWhatIsBelow()
{
Profile profile = Harness.CreateProfile();
Folder root = profile.GetRootFolder();
SKColor orange = new(255, 128, 0);
Folder folder = Harness.AddFolder(root);
AddSolid(folder, orange, SKBlendMode.Multiply, LedId.Keyboard_A);
AddSolid(root, orange, SKBlendMode.Multiply, LedId.Keyboard_S);
AddSolid(root, new SKColor(128, 128, 255), LedId.Keyboard_A, LedId.Keyboard_S);
using RenderFrame frame = Render(profile);
// Multiplying onto a transparent folder yields the color itself
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(orange);
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(new SKColor(128, 64, 0));
}
[Fact]
public void MaskLayer_OnlyCutsThroughSiblingsInItsFolder()
{
Profile profile = Harness.CreateProfile();
Folder root = profile.GetRootFolder();
// In a folder: the mask punches a hole in the red layer, the green layer below the folder shows through
Folder folder = Harness.AddFolder(root);
AddSolid(folder, SKColors.White, SKBlendMode.DstOut, LedId.Keyboard_S);
AddSolid(folder, SKColors.Red, LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D);
AddSolid(root, SKColors.Lime, LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D);
// In the root folder: the mask punches a hole through everything below it
AddSolid(root, SKColors.White, SKBlendMode.DstOut, LedId.Keyboard_J, order: 0);
AddSolid(root, SKColors.Red, LedId.Keyboard_H, LedId.Keyboard_J, LedId.Keyboard_K);
AddSolid(root, SKColors.Lime, LedId.Keyboard_H, LedId.Keyboard_J, LedId.Keyboard_K);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(SKColors.Lime);
frame.GetColor(Keyboard, LedId.Keyboard_D).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_H).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_J).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.Keyboard_K).ShouldBeColor(SKColors.Red);
}
[Fact]
public void BlendMode_IsCombinedWithTheLayerOpacity()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
Layer blending = AddSolid(folder, SKColors.Blue, SKBlendMode.Plus, LedId.Keyboard_A);
blending.Transform.Opacity.SetCurrentValue(50);
AddSolid(folder, SKColors.Red, LedId.Keyboard_A);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(255, 0, 127));
}
[Fact]
public void FolderEffect_AppliesToTheBlendedResultOfItsChildren()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
AddSolid(folder, SKColors.Blue, SKBlendMode.Plus, LedId.Keyboard_A);
AddSolid(folder, SKColors.Red, LedId.Keyboard_A);
Harness.AddEffect<OpacityTestEffect>(folder);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 127));
}
#endregion
private Layer AddSolid(Folder parent, SKColor color, params LedId[] leds)
{
return AddSolid(parent, color, SKBlendMode.SrcOver, leds);
}
private Layer AddSolid(Folder parent, SKColor color, SKBlendMode blendMode, params LedId[] leds)
{
return AddSolid(parent, color, blendMode, null, leds);
}
private Layer AddSolid(Folder parent, SKColor color, SKBlendMode blendMode, LedId led, int order)
{
return AddSolid(parent, color, blendMode, order, led);
}
private Layer AddSolid(Folder parent, SKColor color, SKBlendMode blendMode, int? order, params LedId[] leds)
{
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(parent, Leds(leds), $"{color} {blendMode}", order);
brush.Properties.Color.SetCurrentValue(color);
layer.General.BlendMode.SetCurrentValue(blendMode);
return layer;
}
}
@@ -0,0 +1,213 @@
using Artemis.Core.LayerEffects;
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
using Artemis.Core.Tests.Infrastructure.Rendering;
using Artemis.Core.Tests.Infrastructure.VisualScripting;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Profiles;
/// <summary>
/// Most tests render a busy folder with and without an effect and check every LED: those covered by the folder must
/// change exactly as the effect dictates, all others must stay the same.
/// </summary>
public class FolderEffectTests : RenderTestBase
{
private static readonly SKColor TintColor = new(255, 128, 64);
[Fact]
public void PreProcessColorFilter_AppliesToEveryChildAndNothingElse()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
ChannelSwapTestEffect swap = Harness.AddEffect<ChannelSwapTestEffect>(busy.Folder);
(RenderFrame before, RenderFrame after) = RenderWithAndWithout(profile, swap);
after.ShouldOnlyDifferFrom(before, busy.Leds, Swap);
}
[Fact]
public void PreProcessOpacity_FadesEveryChildAndNothingElse()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
OpacityTestEffect opacity = Harness.AddEffect<OpacityTestEffect>(busy.Folder);
(RenderFrame before, RenderFrame after) = RenderWithAndWithout(profile, opacity);
after.ShouldOnlyDifferFrom(before, busy.Leds, c => Scale(c, 127));
}
[Fact]
public void PostProcessTint_AppliesToEveryChildAndNothingElse()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
TintTestEffect tint = Harness.AddEffect<TintTestEffect>(busy.Folder);
tint.Properties.Color.SetCurrentValue(TintColor);
(RenderFrame before, RenderFrame after) = RenderWithAndWithout(profile, tint);
after.ShouldOnlyDifferFrom(before, busy.Leds, c => Multiply(c, TintColor));
}
[Fact]
public void NestedFolderEffect_OnlyAppliesToTheNestedChildren()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
ChannelSwapTestEffect swap = Harness.AddEffect<ChannelSwapTestEffect>(busy.Nested);
(RenderFrame before, RenderFrame after) = RenderWithAndWithout(profile, swap);
after.ShouldOnlyDifferFrom(before, busy.NestedLeds, Swap);
}
[Fact]
public void OuterAndNestedFolderEffects_Compose()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
ChannelSwapTestEffect swap = Harness.AddEffect<ChannelSwapTestEffect>(busy.Folder);
OpacityTestEffect opacity = Harness.AddEffect<OpacityTestEffect>(busy.Nested);
(RenderFrame before, RenderFrame after) = RenderWithAndWithout(profile, swap, opacity);
// The nested children get both, the rest of the folder only gets the swap
ArtemisLed[] outerOnly = busy.Leds.Except(busy.NestedLeds).ToArray();
foreach (ArtemisLed led in busy.NestedLeds)
after.LedColors[led].ShouldBeColor(Scale(Swap(before.LedColors[led]), 127), 3);
after.ShouldOnlyDifferFrom(before, outerOnly, Swap, busy.NestedLeds);
}
[Fact]
public void RootFolderEffect_AppliesToTheWholeProfile()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
ChannelSwapTestEffect swap = Harness.AddEffect<ChannelSwapTestEffect>(profile.GetRootFolder());
(RenderFrame before, RenderFrame after) = RenderWithAndWithout(profile, swap);
after.ShouldOnlyDifferFrom(before, busy.Leds.Concat(busy.Outside.Leds).ToArray(), Swap);
}
[Fact]
public void FolderOpacityEffect_FadesOverlappingChildrenAsAWhole()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
(_, SolidColorTestBrush top) = Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)], "Top");
(_, SolidColorTestBrush bottom) = Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)], "Bottom");
top.Properties.Color.SetCurrentValue(SKColors.Lime);
bottom.Properties.Color.SetCurrentValue(SKColors.Red);
Harness.AddEffect<OpacityTestEffect>(folder);
using RenderFrame frame = Render(profile);
// The children are flattened before the folder is faded, the bottom layer does not shine through
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(0, 127, 0));
}
[Fact]
public void FolderOpacityEffect_LetsElementsBelowTheFolderShowThrough()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
(_, SolidColorTestBrush inFolder) = Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)], "In folder");
(_, SolidColorTestBrush below) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "Below folder");
inFolder.Properties.Color.SetCurrentValue(SKColors.Lime);
below.Properties.Color.SetCurrentValue(SKColors.Red);
Harness.AddEffect<OpacityTestEffect>(folder);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(128, 127, 0));
}
[Fact]
public void FolderOpacityEffectAtZero_SkipsRenderingTheChildren()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
OpacityTestEffect opacity = Harness.AddEffect<OpacityTestEffect>(busy.Folder);
opacity.Properties.Opacity.SetCurrentValue(0);
(RenderFrame before, RenderFrame after) = RenderWithAndWithout(profile, opacity);
after.ShouldOnlyDifferFrom(before, busy.Leds, _ => SKColors.Black);
}
[Fact]
public void FolderPostProcessEffect_LeavesTheGapsBetweenChildrenUntouched()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_Q)], "Q");
Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_P)], "P");
(_, SolidColorTestBrush below) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), TopLetterRow, "Below");
below.Properties.Color.SetCurrentValue(SKColors.White);
Harness.AddEffect<TintTestEffect>(folder);
using RenderFrame frame = Render(profile);
// The tint draws over the whole folder bounds, but inside the folder's own layer which is transparent in between
frame.GetColor(Keyboard, LedId.Keyboard_Q).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_T).ShouldBeColor(SKColors.White);
frame.GetColor(Keyboard, LedId.Keyboard_P).ShouldBeColor(SKColors.Red);
}
[Fact]
public void FolderConditionNotMet_HidesEveryChildIncludingTheEffect()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
Harness.AddEffect<ChannelSwapTestEffect>(busy.Folder);
TestBooleanNode condition = TestConditions.UseStaticCondition(busy.Folder, true, stopMode: StaticStopMode.SkipToEnd);
using RenderFrame met = Harness.Render(profile, TimeSpan.FromSeconds(0.1));
SaveOutput(met, TestName + "_met");
condition.Value = false;
using RenderFrame notMet = Harness.Render(profile, TimeSpan.FromSeconds(0.1));
SaveOutput(notMet, TestName + "_not_met");
notMet.ShouldOnlyDifferFrom(met, busy.Leds, _ => SKColors.Black);
}
/// <summary>
/// Renders a frame with the effects disabled and a frame with them enabled. The busy folder has no animation so the
/// frames only differ by the effects.
/// </summary>
private (RenderFrame Before, RenderFrame After) RenderWithAndWithout(Profile profile, params BaseLayerEffect[] effects)
{
foreach (BaseLayerEffect effect in effects)
effect.BaseProperties!.IsEnabled.SetCurrentValue(false);
RenderFrame before = Track(Harness.Render(profile, TimeSpan.FromSeconds(0.1)));
SaveOutput(before, TestName + "_without");
foreach (BaseLayerEffect effect in effects)
effect.BaseProperties!.IsEnabled.SetCurrentValue(true);
RenderFrame after = Track(Harness.Render(profile, TimeSpan.FromSeconds(0.1)));
SaveOutput(after, TestName + "_with");
return (before, after);
}
private static SKColor Swap(SKColor color)
{
return new SKColor(color.Blue, color.Green, color.Red, color.Alpha);
}
private static SKColor Scale(SKColor color, byte factor)
{
return new SKColor((byte) (color.Red * factor / 255), (byte) (color.Green * factor / 255), (byte) (color.Blue * factor / 255));
}
private static SKColor Multiply(SKColor color, SKColor with)
{
return new SKColor((byte) (color.Red * with.Red / 255), (byte) (color.Green * with.Green / 255), (byte) (color.Blue * with.Blue / 255));
}
}
@@ -0,0 +1,134 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
using Artemis.Core.Tests.Infrastructure.Rendering;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Profiles;
/// <summary>
/// Effects that render beyond the element itself. Layers clip to their own LEDs, folders don't clip at all.
/// </summary>
public class GlowTests : RenderTestBase
{
[Fact]
public void LayerGlow_IsClippedToTheLedsOfTheLayer()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
Harness.AddEffect<GlowTestEffect>(layer);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.Keyboard_CapsLock).ShouldBeColor(SKColors.Black);
}
[Fact]
public void FolderGlow_SpillsOntoNeighbouringLeds()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)]);
Harness.AddEffect<GlowTestEffect>(folder);
using RenderFrame frame = Render(profile);
// S and Caps Lock are not part of any layer, the glow reaches them anyway
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_S).Red.ShouldBeGreaterThan((byte) 32);
frame.GetColor(Keyboard, LedId.Keyboard_CapsLock).Red.ShouldBeGreaterThan((byte) 32);
frame.GetColor(Keyboard, LedId.Keyboard_F).ShouldBeColor(SKColors.Black);
}
[Fact]
public void FolderGlow_IsCoveredByElementsAboveTheFolder()
{
Profile profile = Harness.CreateProfile();
(_, SolidColorTestBrush above) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_S)], "Above");
above.Properties.Color.SetCurrentValue(SKColors.Blue);
Folder folder = Harness.AddFolder(profile.GetRootFolder());
Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)]);
Harness.AddEffect<GlowTestEffect>(folder);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(SKColors.Blue);
}
[Fact]
public void FolderGlow_CoversElementsBelowTheFolder()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)]);
Harness.AddEffect<GlowTestEffect>(folder);
(_, SolidColorTestBrush below) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_S)], "Below");
below.Properties.Color.SetCurrentValue(SKColors.Lime);
using RenderFrame frame = Render(profile);
// The glow covers part of S, the rest of S remains green
SKColor s = frame.GetColor(Keyboard, LedId.Keyboard_S);
s.Red.ShouldBeGreaterThan((byte) 32);
s.Green.ShouldBeGreaterThan((byte) 32);
((int) s.Red + s.Green).ShouldBeInRange(250, 260);
}
[Fact]
public void FolderGlow_SpillsWithinItsParentFolderAndIsAffectedByItsEffects()
{
Profile profile = Harness.CreateProfile();
Folder outer = Harness.AddFolder(profile.GetRootFolder(), "Outer");
Folder glowing = Harness.AddFolder(outer, "Glowing");
Harness.AddLayer<SolidColorTestBrush>(glowing, [Led(LedId.Keyboard_A)]);
Harness.AddEffect<GlowTestEffect>(glowing);
Harness.AddEffect<ChannelSwapTestEffect>(outer);
using RenderFrame frame = Render(profile);
// The spilled glow is part of the outer folder's content, so it gets swapped from red to blue as well
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Blue);
SKColor s = frame.GetColor(Keyboard, LedId.Keyboard_S);
s.Blue.ShouldBeGreaterThan((byte) 32);
s.Red.ShouldBe((byte) 0);
}
[Fact]
public void FolderGlow_GrowsTheBlendedResultOfItsChildren()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
(Layer blue, SolidColorTestBrush blueBrush) = Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)], "Blue");
blueBrush.Properties.Color.SetCurrentValue(SKColors.Blue);
blue.General.BlendMode.SetCurrentValue(SKBlendMode.Plus);
Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)], "Red");
Harness.AddEffect<GlowTestEffect>(folder);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Magenta);
SKColor s = frame.GetColor(Keyboard, LedId.Keyboard_S);
((int) s.Red).ShouldBeInRange(s.Blue - 2, s.Blue + 2);
s.Red.ShouldBeGreaterThan((byte) 32);
}
[Fact]
public void LayerGlowInAGlowingFolder_IsStillClippedBeforeTheFolderGrowsIt()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)]);
GlowTestEffect layerGlow = Harness.AddEffect<GlowTestEffect>(layer);
layerGlow.Properties.Radius.SetCurrentValue(40);
Harness.AddEffect<GlowTestEffect>(folder);
using RenderFrame frame = Render(profile);
// A large layer glow would reach F, but it is clipped to A, the folder only grows A by its own radius
frame.GetColor(Keyboard, LedId.Keyboard_S).Red.ShouldBeGreaterThan((byte) 32);
frame.GetColor(Keyboard, LedId.Keyboard_F).ShouldBeColor(SKColors.Black);
}
}
@@ -0,0 +1,89 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Rendering;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Profiles;
public class LayerBrushTests : RenderTestBase
{
[Fact]
public void SolidBrush_DefaultsToItsDefaultValue()
{
Profile profile = Harness.CreateProfile();
Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
[Fact]
public void GradientBrush_SpansTheLayerBounds()
{
Profile profile = Harness.CreateProfile();
Harness.AddLayer<GradientTestBrush>(profile.GetRootFolder(), TopLetterRow);
using RenderFrame frame = Render(profile);
byte[] brightness = TopLetterRow.Select(l => frame.LedColors[l].Red).ToArray();
brightness.ShouldBeInOrder(SortDirection.Ascending);
brightness.First().ShouldBeLessThan((byte) 32);
brightness.Last().ShouldBeGreaterThan((byte) 223);
}
[Fact]
public void PerLedBrush_ColorsEachLedIndividually()
{
Profile profile = Harness.CreateProfile();
(_, PerLedTestBrush brush) = Harness.AddLayer<PerLedTestBrush>(profile.GetRootFolder(), Leds(LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D));
brush.ColorSelector = (led, _) => led.RgbLed.Id switch
{
LedId.Keyboard_A => SKColors.Red,
LedId.Keyboard_S => SKColors.Lime,
_ => SKColors.Blue
};
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(SKColors.Lime);
frame.GetColor(Keyboard, LedId.Keyboard_D).ShouldBeColor(SKColors.Blue);
frame.GetColor(Keyboard, LedId.Keyboard_F).ShouldBeColor(SKColors.Black);
}
[Fact]
public void PerLedBrush_ReceivesPointsRelativeToTheLayer()
{
Profile profile = Harness.CreateProfile();
(Layer layer, PerLedTestBrush brush) = Harness.AddLayer<PerLedTestBrush>(profile.GetRootFolder(), TopLetterRow);
Dictionary<LedId, SKPoint> points = [];
brush.ColorSelector = (led, point) =>
{
points[led.RgbLed.Id] = point;
return SKColors.White;
};
using RenderFrame frame = Render(profile);
// The point is the top-left of the LED relative to the top-left of the layer
points[LedId.Keyboard_Q].ShouldBe(new SKPoint(Led(LedId.Keyboard_Q).AbsoluteRectangle.Left - layer.Bounds.Left, Led(LedId.Keyboard_Q).AbsoluteRectangle.Top - layer.Bounds.Top));
points[LedId.Keyboard_P].X.ShouldBeGreaterThan(points[LedId.Keyboard_Q].X);
}
[Fact]
public void ChangeLayerBrush_ReplacesTheBrush()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
PerLedTestBrush replacement = Harness.ChangeBrush<PerLedTestBrush>(layer);
replacement.ColorSelector = (_, _) => SKColors.Blue;
using RenderFrame frame = Render(profile);
layer.LayerBrush.ShouldBe(replacement);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Blue);
}
}
@@ -0,0 +1,157 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
using Artemis.Core.Tests.Infrastructure.Rendering;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Profiles;
public class LayerEffectTests : RenderTestBase
{
[Fact]
public void PreProcessEffect_ModifiesThePaintTheLayerIsCompositedWith()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
Harness.AddEffect<OpacityTestEffect>(layer);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
}
[Fact]
public void PreProcessEffect_StacksWithTheLayerOpacity()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Transform.Opacity.SetCurrentValue(50);
Harness.AddEffect<OpacityTestEffect>(layer);
using RenderFrame frame = Render(profile);
// 50% of 50%
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(63, 0, 0));
}
[Fact]
public void PreProcessColorFilter_IsAppliedTwiceOnLayers()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
Harness.AddEffect<ChannelSwapTestEffect>(layer);
using RenderFrame frame = Render(profile);
// Pinned as-is, likely unintended: Layer.DelegateRendering composites with the pre-processed paint and also hands
// that same paint to the brush, so for any brush drawing with that paint (regular and per-LED alike) the filter
// applies to the brush output and again when compositing.
// Swapping red and blue twice cancels out, a hue shift effect would shift twice. Folders apply it once.
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
[Fact]
public void PostProcessEffect_DrawsOverTheBrushOutput()
{
Profile profile = Harness.CreateProfile();
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), Leds(LedId.Keyboard_A, LedId.Keyboard_S));
brush.Properties.Color.SetCurrentValue(SKColors.White);
Harness.AddEffect<TintTestEffect>(layer);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(SKColors.Red);
}
[Fact]
public void PostProcessEffect_IsClippedToTheLayer()
{
Profile profile = Harness.CreateProfile();
(Layer layer, SolidColorTestBrush tinted) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "Tinted");
(_, SolidColorTestBrush neighbour) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_S)], "Neighbour");
tinted.Properties.Color.SetCurrentValue(SKColors.White);
neighbour.Properties.Color.SetCurrentValue(SKColors.White);
Harness.AddEffect<TintTestEffect>(layer);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(SKColors.White);
}
[Fact]
public void PostProcessEffect_RunsBeforeThePreProcessedPaintIsApplied()
{
Profile profile = Harness.CreateProfile();
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
brush.Properties.Color.SetCurrentValue(SKColors.White);
Harness.AddEffect<TintTestEffect>(layer);
Harness.AddEffect<ChannelSwapTestEffect>(layer);
using RenderFrame frame = Render(profile);
// The tint turns white into red inside the layer, the filter of the composited layer then turns red into blue
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Blue);
}
[Fact]
public void DisabledEffect_IsSkipped()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
ChannelSwapTestEffect effect = Harness.AddEffect<ChannelSwapTestEffect>(layer);
effect.Properties.IsEnabled.SetCurrentValue(false);
using RenderFrame frame = Render(profile);
effect.Suspended.ShouldBeTrue();
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
[Fact]
public void RemovedEffect_IsNoLongerApplied()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
ChannelSwapTestEffect effect = Harness.AddEffect<ChannelSwapTestEffect>(layer);
Harness.Render(profile, TimeSpan.FromSeconds(0.1)).Dispose();
layer.RemoveLayerEffect(effect);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
[Fact]
public void EffectProperties_CanBeAnimatedWithKeyframes()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
OpacityTestEffect effect = Harness.AddEffect<OpacityTestEffect>(layer);
FloatLayerProperty opacity = effect.Properties.Opacity;
opacity.AddKeyframe(new LayerPropertyKeyframe<float>(100, TimeSpan.Zero, Easings.Functions.Linear, opacity));
opacity.AddKeyframe(new LayerPropertyKeyframe<float>(0, TimeSpan.FromSeconds(1), Easings.Functions.Linear, opacity));
profile.Reset();
using RenderTimeline timeline = RenderTimeline(profile, 0.25, 0.75);
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(191, 0, 0));
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(63, 0, 0));
}
[Fact]
public void AddLayerEffect_InsertsNewEffectsFirst()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
OpacityTestEffect first = Harness.AddEffect<OpacityTestEffect>(layer);
TintTestEffect second = Harness.AddEffect<TintTestEffect>(layer);
// New effects have an order of 0 so OrderEffects sorts them before the existing ones, the editor adds them the same way
layer.LayerEffects.ShouldBe([second, first]);
layer.LayerEffects.Select(e => e.Order).ShouldBe([1, 2]);
}
}
@@ -0,0 +1,127 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Rendering;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Profiles;
public class LayerTransformTests : RenderTestBase
{
[Fact]
public void Opacity_IsAppliedWhenCompositingTheLayer()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Transform.Opacity.SetCurrentValue(50);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
}
[Fact]
public void Opacity_AppliesToPerLedBrushes()
{
Profile profile = Harness.CreateProfile();
(Layer layer, PerLedTestBrush brush) = Harness.AddLayer<PerLedTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
brush.ColorSelector = (_, _) => SKColors.Red;
layer.Transform.Opacity.SetCurrentValue(50);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
}
[Fact]
public void Rotation_RotatesAroundTheAnchor()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<GradientTestBrush>(profile.GetRootFolder(), TopLetterRow);
layer.Transform.Rotation.SetCurrentValue(180);
using RenderFrame frame = Render(profile);
// Rotating the left to right gradient half a turn around the center makes it run right to left
TopLetterRow.Select(l => frame.LedColors[l].Red).ToArray().ShouldBeInOrder(SortDirection.Descending);
}
[Fact]
public void Scale_ShrinksTowardsTheAnchor()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), TopLetterRow);
layer.Transform.Scale.SetCurrentValue(new SKSize(50, 100));
using RenderFrame frame = Render(profile);
// Half the width, centered: the outer keys fall outside of the shape
frame.GetColor(Keyboard, LedId.Keyboard_Q).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.Keyboard_W).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.Keyboard_T).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_Y).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_O).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.Keyboard_P).ShouldBeColor(SKColors.Black);
}
[Fact]
public void Position_MovesTheShapeRelativeToTheLayerSize()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), TopLetterRow);
// The anchor sits at the center, moving the position to the right edge moves the shape right by half its width
layer.Transform.Position.SetCurrentValue(new SKPoint(1f, 0.5f));
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_Q).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.Keyboard_R).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.Keyboard_I).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_P).ShouldBeColor(SKColors.Red);
}
[Fact]
public void Position_DoesNotMoveTheLedsTheLayerIsClippedTo()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), TopLetterRow);
layer.Transform.Position.SetCurrentValue(new SKPoint(1f, 0.5f));
using RenderFrame frame = Render(profile);
// The shape moved beyond P but the layer remains clipped to its own LEDs
frame.GetColor(Keyboard, LedId.Keyboard_BracketLeft).ShouldBeColor(SKColors.Black);
}
[Fact]
public void EllipseShape_LeavesTheCornersOut()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), Keyboard.Leds);
layer.General.ShapeType.SetCurrentValue(LayerShapeType.Ellipse);
using RenderFrame frame = Render(profile);
SKPoint center = new(layer.Bounds.MidX, layer.Bounds.MidY);
ArtemisLed centerLed = Keyboard.Leds.MinBy(l => SKPoint.Distance(new SKPoint(l.AbsoluteRectangle.MidX, l.AbsoluteRectangle.MidY), center))!;
frame.LedColors[centerLed].ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_Escape).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.Keyboard_LeftCtrl).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.LedStripe1).ShouldBeColor(SKColors.Black);
}
[Fact]
public void BlendMode_IsUsedToCompositeTheLayer()
{
Profile profile = Harness.CreateProfile();
(Layer top, SolidColorTestBrush topBrush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "Top");
(_, SolidColorTestBrush bottomBrush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "Bottom");
topBrush.Properties.Color.SetCurrentValue(SKColors.Blue);
bottomBrush.Properties.Color.SetCurrentValue(SKColors.Red);
top.General.BlendMode.SetCurrentValue(SKBlendMode.Plus);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Magenta);
}
}
@@ -0,0 +1,210 @@
using Artemis.Core.LayerEffects;
using Artemis.Core.LayerEffects.Placeholder;
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
using Artemis.Core.Tests.Infrastructure.Rendering;
using Artemis.Core.Tests.Infrastructure.VisualScripting;
using Artemis.Storage.Entities.Profile;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Profiles;
public class ProfileSerializationTests : RenderTestBase
{
[Fact]
public void RoundTrip_RendersIdentically()
{
Profile profile = CreateComplexProfile();
Profile reloaded = Harness.SaveAndReload(profile);
// New layers start at the end of their timeline while loaded layers start at the beginning, reset both
profile.Reset();
reloaded.Reset();
using RenderTimeline original = Harness.RenderTimeline(profile, TimeSpan.FromSeconds(0.25), TimeSpan.FromSeconds(0.75), TimeSpan.FromSeconds(1.5));
using RenderTimeline loaded = RenderTimeline(reloaded, 0.25, 0.75, 1.5);
for (int i = 0; i < original.Frames.Count; i++)
{
foreach ((ArtemisLed led, SKColor color) in original[i].LedColors)
loaded[i].LedColors[led].ShouldBeColor(color);
}
}
[Fact]
public void RoundTrip_PreservesTheTree()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder(), "Folder");
Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)], "Nested layer");
(Layer suspended, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_S)], "Suspended layer");
Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), Leds(LedId.Keyboard_D, LedId.Keyboard_F), "Last layer");
suspended.Suspended = true;
folder.Suspended = true;
Profile reloaded = Harness.SaveAndReload(profile);
Folder root = reloaded.GetRootFolder();
root.Children.Select(c => c.Name).ShouldBe(["Folder", "Suspended layer", "Last layer"]);
root.Children.Select(c => c.Order).ShouldBe([1, 2, 3]);
root.Children.Select(c => c.Suspended).ShouldBe([true, true, false]);
root.Children[0].Children.Single().Name.ShouldBe("Nested layer");
((Layer) root.Children[2]).Leds.Select(l => l.RgbLed.Id).ShouldBe([LedId.Keyboard_D, LedId.Keyboard_F], ignoreOrder: true);
}
[Fact]
public void RoundTrip_PreservesPropertiesAndKeyframes()
{
Profile profile = Harness.CreateProfile();
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Transform.Rotation.SetCurrentValue(45);
layer.General.ShapeType.SetCurrentValue(LayerShapeType.Ellipse);
SKColorLayerProperty color = brush.Properties.Color;
color.AddKeyframe(new LayerPropertyKeyframe<SKColor>(SKColors.Red, TimeSpan.Zero, Easings.Functions.CubicEaseIn, color));
color.AddKeyframe(new LayerPropertyKeyframe<SKColor>(SKColors.Blue, TimeSpan.FromSeconds(1), Easings.Functions.Linear, color));
Profile reloaded = Harness.SaveAndReload(profile);
Layer reloadedLayer = reloaded.GetAllLayers().Single();
reloadedLayer.Transform.Rotation.CurrentValue.ShouldBe(45);
reloadedLayer.General.ShapeType.CurrentValue.ShouldBe(LayerShapeType.Ellipse);
SKColorLayerProperty reloadedColor = reloadedLayer.LayerBrush.ShouldBeOfType<SolidColorTestBrush>().Properties.Color;
reloadedColor.KeyframesEnabled.ShouldBeTrue();
reloadedColor.Keyframes.Select(k => (k.Value, k.Position, k.EasingFunction)).ShouldBe([
(SKColors.Red, TimeSpan.Zero, Easings.Functions.CubicEaseIn),
(SKColors.Blue, TimeSpan.FromSeconds(1), Easings.Functions.Linear)
]);
}
[Fact]
public void RoundTrip_PreservesTimelinesAndDisplayConditions()
{
Profile profile = Harness.CreateProfile();
(Layer playOnce, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "Play once");
(Layer staticCondition, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_S)], "Static");
playOnce.Timeline.StartSegmentLength = TimeSpan.FromSeconds(1);
playOnce.Timeline.MainSegmentLength = TimeSpan.FromSeconds(2);
playOnce.Timeline.EndSegmentLength = TimeSpan.FromSeconds(3);
TestConditions.UsePlayOnceCondition(playOnce);
staticCondition.DisplayCondition = new StaticCondition(staticCondition) {PlayMode = StaticPlayMode.Once, StopMode = StaticStopMode.SkipToEnd};
Profile reloaded = Harness.SaveAndReload(profile);
Layer reloadedPlayOnce = reloaded.GetAllLayers().Single(l => l.Name == "Play once");
reloadedPlayOnce.Timeline.StartSegmentLength.ShouldBe(TimeSpan.FromSeconds(1));
reloadedPlayOnce.Timeline.MainSegmentLength.ShouldBe(TimeSpan.FromSeconds(2));
reloadedPlayOnce.Timeline.EndSegmentLength.ShouldBe(TimeSpan.FromSeconds(3));
reloadedPlayOnce.DisplayCondition.ShouldBeOfType<PlayOnceCondition>();
StaticCondition reloadedStatic = reloaded.GetAllLayers().Single(l => l.Name == "Static").DisplayCondition.ShouldBeOfType<StaticCondition>();
reloadedStatic.PlayMode.ShouldBe(StaticPlayMode.Once);
reloadedStatic.StopMode.ShouldBe(StaticStopMode.SkipToEnd);
}
[Fact]
public void RoundTrip_PreservesEffects()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)]);
OpacityTestEffect opacity = Harness.AddEffect<OpacityTestEffect>(layer);
TintTestEffect tint = Harness.AddEffect<TintTestEffect>(layer);
Harness.AddEffect<ChannelSwapTestEffect>(folder);
opacity.Properties.Opacity.SetCurrentValue(25);
tint.Properties.IsEnabled.SetCurrentValue(false);
Profile reloaded = Harness.SaveAndReload(profile);
Layer reloadedLayer = reloaded.GetAllLayers().Single();
reloadedLayer.LayerEffects.Select(e => e.GetType()).ShouldBe([typeof(TintTestEffect), typeof(OpacityTestEffect)]);
reloadedLayer.LayerEffects.OfType<OpacityTestEffect>().Single().Properties.Opacity.CurrentValue.ShouldBe(25);
reloadedLayer.LayerEffects.OfType<TintTestEffect>().Single().Suspended.ShouldBeTrue();
reloaded.GetAllFolders().Single(f => !f.IsRootFolder).LayerEffects.Single().ShouldBeOfType<ChannelSwapTestEffect>();
}
[Fact]
public void RoundTrip_LosesTheNameOfRenamedEffects()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
TintTestEffect tint = Harness.AddEffect<TintTestEffect>(layer);
tint.Name = "Renamed tint";
tint.HasBeenRenamed = true;
Profile reloaded = Harness.SaveAndReload(profile);
// Pinned as-is, a bug: BaseLayerEffect.Load checks HasBeenRenamed before loading it from the entity, so it is
// always false at that point and the name falls back to the display name of the descriptor
BaseLayerEffect reloadedTint = reloaded.GetAllLayers().Single().LayerEffects.Single();
reloadedTint.HasBeenRenamed.ShouldBeTrue();
reloadedTint.Name.ShouldBe("Tint");
}
[Fact]
public void MissingBrushPlugin_BreaksTheLayerWithoutBreakingTheProfile()
{
Profile profile = Harness.CreateProfile();
Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "Broken");
ProfileEntity entity = Harness.Save(profile);
// Disposing the harness unregisters its test plugin, the same plugin is not available in the next harness
Harness.Dispose();
using RenderHarness other = new();
ArtemisDevice keyboard = other.AddDevice(TestLayouts.KeyboardAnsi);
Profile reloaded = other.Load(entity);
using RenderFrame frame = other.Render(reloaded, TimeSpan.FromSeconds(0.1));
SaveOutput(frame, TestName);
Layer layer = reloaded.GetAllLayers().Single();
layer.LayerBrush.ShouldBeNull();
layer.BrokenState.ShouldNotBeNull();
frame.GetColor(keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
}
[Fact]
public void MissingEffectPlugin_KeepsTheEffectAsAPlaceholder()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<PerLedTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
Harness.AddEffect<ChannelSwapTestEffect>(layer);
ProfileEntity entity = Harness.Save(profile);
string providerId = entity.Layers.Single().LayerEffects.Single().ProviderId;
Harness.Dispose();
using RenderHarness other = new();
other.AddDevice(TestLayouts.KeyboardAnsi);
Profile reloaded = other.Load(entity);
BaseLayerEffect placeholder = reloaded.GetAllLayers().Single().LayerEffects.Single();
placeholder.ShouldBeOfType<PlaceholderLayerEffect>();
// Saving again keeps the original effect so it comes back once the plugin is available again
other.Save(reloaded).Layers.Single().LayerEffects.Single().ProviderId.ShouldBe(providerId);
}
private Profile CreateComplexProfile()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder(), "Folder");
Harness.AddEffect<ChannelSwapTestEffect>(folder);
(Layer gradient, GradientTestBrush gradientBrush) = Harness.AddLayer<GradientTestBrush>(folder, TopLetterRow, "Gradient");
gradientBrush.Properties.Left.SetCurrentValue(SKColors.Red);
gradientBrush.Properties.Right.SetCurrentValue(SKColors.Lime);
gradient.Transform.Rotation.SetCurrentValue(180);
FloatLayerProperty opacity = gradient.Transform.Opacity;
opacity.AddKeyframe(new LayerPropertyKeyframe<float>(20, TimeSpan.Zero, Easings.Functions.QuadraticEaseOut, opacity));
opacity.AddKeyframe(new LayerPropertyKeyframe<float>(100, TimeSpan.FromSeconds(1), Easings.Functions.Linear, opacity));
(Layer tinted, SolidColorTestBrush tintedBrush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), Leds(LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D), "Tinted");
tintedBrush.Properties.Color.SetCurrentValue(SKColors.White);
tinted.General.ShapeType.SetCurrentValue(LayerShapeType.Ellipse);
TintTestEffect tint = Harness.AddEffect<TintTestEffect>(tinted);
tint.Properties.Color.SetCurrentValue(new SKColor(0, 128, 255));
(Layer suspended, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), Keyboard.Leds, "Suspended");
suspended.Suspended = true;
return profile;
}
}
@@ -0,0 +1,225 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Rendering;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Profiles;
public class ProfileStructureTests : RenderTestBase
{
[Fact]
public void EmptyProfile_RendersAllLedsBlack()
{
Profile profile = Harness.CreateProfile();
using RenderFrame frame = Render(profile);
frame.LedColors.Values.ShouldAllBe(c => c == SKColors.Black);
}
[Fact]
public void Layer_OnlyColorsItsOwnLeds()
{
Profile profile = Harness.CreateProfile();
(_, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), Leds(LedId.Keyboard_W, LedId.Keyboard_A, LedId.Keyboard_S, LedId.Keyboard_D));
brush.Properties.Color.SetCurrentValue(SKColors.Lime);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_W).ShouldBeColor(SKColors.Lime);
frame.GetColor(Keyboard, LedId.Keyboard_D).ShouldBeColor(SKColors.Lime);
frame.GetColor(Keyboard, LedId.Keyboard_Q).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.Keyboard_E).ShouldBeColor(SKColors.Black);
}
[Fact]
public void LayerWithoutLeds_RendersNothing()
{
Profile profile = Harness.CreateProfile();
Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), []);
using RenderFrame frame = Render(profile);
frame.LedColors.Values.ShouldAllBe(c => c == SKColors.Black);
}
[Fact]
public void FirstChild_RendersOnTop()
{
Profile profile = Harness.CreateProfile();
(_, SolidColorTestBrush top) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), Leds(LedId.Keyboard_A, LedId.Keyboard_S), "Top");
(_, SolidColorTestBrush bottom) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), Leds(LedId.Keyboard_S, LedId.Keyboard_D), "Bottom");
top.Properties.Color.SetCurrentValue(SKColors.Red);
bottom.Properties.Color.SetCurrentValue(SKColors.Blue);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_D).ShouldBeColor(SKColors.Blue);
}
[Fact]
public void ChildAddedAtOrderZero_RendersOnTop()
{
Profile profile = Harness.CreateProfile();
(_, SolidColorTestBrush first) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "First");
(Layer insertedLayer, SolidColorTestBrush inserted) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "Inserted", order: 0);
first.Properties.Color.SetCurrentValue(SKColors.Red);
inserted.Properties.Color.SetCurrentValue(SKColors.Blue);
using RenderFrame frame = Render(profile);
profile.GetRootFolder().Children[0].ShouldBe(insertedLayer);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Blue);
}
[Fact]
public void ChildOrder_IsStreamlinedWhenInserting()
{
Profile profile = Harness.CreateProfile();
Folder root = profile.GetRootFolder();
(Layer a, _) = Harness.AddLayer<SolidColorTestBrush>(root, [], "A");
(Layer b, _) = Harness.AddLayer<SolidColorTestBrush>(root, [], "B");
(Layer c, _) = Harness.AddLayer<SolidColorTestBrush>(root, [], "C", order: 1);
root.Children.ShouldBe([a, c, b]);
root.Children.Select(child => child.Order).ShouldBe([1, 2, 3]);
}
[Fact]
public void SuspendedLayer_IsNotRendered()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Suspended = true;
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
}
[Fact]
public void BusyFolder_RendersEveryChild()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
using RenderFrame frame = Render(profile);
foreach (ArtemisLed led in busy.Leds.Concat(busy.Outside.Leds))
frame.LedColors[led].ShouldNotBe(SKColors.Black, $"{led.RgbLed.Id} should be lit");
frame.LedColors.Where(c => !busy.Leds.Contains(c.Key) && !busy.Outside.Leds.Contains(c.Key)).ShouldAllBe(c => c.Value == SKColors.Black);
}
[Fact]
public void SuspendedFolder_HidesAllOfItsChildren()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
busy.Folder.Suspended = true;
using RenderFrame frame = Render(profile);
foreach (ArtemisLed led in busy.Leds)
frame.LedColors[led].ShouldBeColor(SKColors.Black, 2, led.RgbLed.Id.ToString());
foreach (ArtemisLed led in busy.Outside.Leds)
frame.LedColors[led].ShouldNotBe(SKColors.Black);
}
[Fact]
public void SuspendedNestedFolder_OnlyHidesTheNestedChildren()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
busy.Nested.Suspended = true;
using RenderFrame frame = Render(profile);
foreach (ArtemisLed led in busy.Leds)
{
if (busy.NestedLeds.Contains(led))
frame.LedColors[led].ShouldBeColor(SKColors.Black, 2, led.RgbLed.Id.ToString());
else
frame.LedColors[led].ShouldNotBe(SKColors.Black, led.RgbLed.Id.ToString());
}
}
[Fact]
public void SuspendingARenderingFolder_KeepsRenderingItsChildren()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
using RenderFrame before = Harness.Render(profile, TimeSpan.FromSeconds(0.1));
busy.Folder.Suspended = true;
Harness.Render(profile, TimeSpan.FromSeconds(10)).Dispose();
using RenderFrame after = Render(profile);
// Pinned as-is, an inconsistency: Layer.Update disables a suspended layer, Folder.Update only disables a folder
// once its timeline finishes, which never happens with the default always-on condition. The editor hides this by
// disabling suspended elements itself, so it only affects code suspending folders outside of the editor
busy.Folder.Enabled.ShouldBeTrue();
foreach ((ArtemisLed led, SKColor color) in before.LedColors)
after.LedColors[led].ShouldBeColor(color, 2, led.RgbLed.Id.ToString());
}
[Fact]
public void UnsuspendedFolder_StartsRenderingItsChildren()
{
Profile profile = Harness.CreateProfile();
BusyFolder busy = BusyFolder.Create(Harness, Keyboard, profile);
busy.Folder.Suspended = true;
using RenderFrame before = Harness.Render(profile, TimeSpan.FromSeconds(0.1));
busy.Folder.Suspended = false;
using RenderFrame after = Render(profile);
foreach (ArtemisLed led in busy.Leds)
{
before.LedColors[led].ShouldBeColor(SKColors.Black, 2, led.RgbLed.Id.ToString());
after.LedColors[led].ShouldNotBe(SKColors.Black, led.RgbLed.Id.ToString());
}
}
[Fact]
public void RemovedLayer_StopsRendering()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
using (RenderFrame before = Harness.Render(profile, TimeSpan.FromSeconds(0.1)))
before.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
profile.GetRootFolder().RemoveChild(layer);
using RenderFrame after = Render(profile);
after.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
}
[Fact]
public void ProfileWithoutFade_IsFullyVisibleOnFirstFrame()
{
Profile profile = Harness.CreateProfile();
Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
using RenderFrame frame = Render(profile);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
[Fact]
public void ProfileWithFade_FadesInOverOneSecond()
{
Profile profile = Harness.CreateProfile();
profile.Configuration.FadeInAndOut = true;
Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
using RenderTimeline timeline = RenderTimeline(profile, 0.5, 1, 1.5);
// The fade uses a cubic ease-in-out, which is at exactly half at the halfway point
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
timeline[2].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
}
@@ -0,0 +1,390 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Rendering;
using Artemis.Core.Tests.Infrastructure.VisualScripting;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Profiles;
public class TimelineTests : RenderTestBase
{
private static readonly SKColor Purple = new(128, 0, 128);
#region Keyframes
[Fact]
public void Keyframes_InterpolateLinearly()
{
Profile profile = Harness.CreateProfile();
(_, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
AddKeyframes(brush.Properties.Color, (0, SKColors.Red), (1, SKColors.Blue));
using RenderTimeline timeline = RenderTimeline(profile, 0.25, 0.5, 1);
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(191, 0, 64));
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(Purple);
timeline[2].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Blue);
}
[Fact]
public void Keyframes_UseTheEasingOfTheEarlierKeyframe()
{
Profile profile = Harness.CreateProfile();
(_, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
SKColorLayerProperty color = brush.Properties.Color;
color.AddKeyframe(new LayerPropertyKeyframe<SKColor>(SKColors.Black, TimeSpan.Zero, Easings.Functions.CubicEaseIn, color));
color.AddKeyframe(new LayerPropertyKeyframe<SKColor>(SKColors.White, TimeSpan.FromSeconds(1), Easings.Functions.Linear, color));
using RenderFrame frame = Render(profile, 0.5);
// Cubic ease-in at the halfway point is 0.5^3 = 0.125
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(31, 31, 31));
}
[Fact]
public void Keyframes_HoldTheirValueBeforeTheFirstAndAfterTheLast()
{
Profile profile = Harness.CreateProfile();
(_, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
AddKeyframes(brush.Properties.Color, (1, SKColors.Red), (2, SKColors.Blue));
using RenderTimeline timeline = RenderTimeline(profile, 0.5, 3);
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Blue);
}
[Fact]
public void Keyframes_AddedOutOfOrder_AreSortedByPosition()
{
Profile profile = Harness.CreateProfile();
(_, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
AddKeyframes(brush.Properties.Color, (1, SKColors.Blue), (0, SKColors.Red));
using RenderFrame frame = Render(profile, 0.5);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(Purple);
}
[Fact]
public void Keyframes_AreIgnoredWhenDisabled()
{
Profile profile = Harness.CreateProfile();
(_, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
AddKeyframes(brush.Properties.Color, (0, SKColors.Red), (1, SKColors.Blue));
brush.Properties.Color.KeyframesEnabled = false;
brush.Properties.Color.SetCurrentValue(SKColors.Lime);
using RenderFrame frame = Render(profile, 0.5);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Lime);
}
[Fact]
public void Keyframes_AnimateLayerProperties()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
FloatLayerProperty opacity = layer.Transform.Opacity;
opacity.AddKeyframe(new LayerPropertyKeyframe<float>(0, TimeSpan.Zero, Easings.Functions.Linear, opacity));
opacity.AddKeyframe(new LayerPropertyKeyframe<float>(100, TimeSpan.FromSeconds(1), Easings.Functions.Linear, opacity));
using RenderFrame frame = Render(profile, 0.5);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
}
#endregion
#region Segments
[Fact]
public void AlwaysOn_RepeatsTheMainSegment()
{
Profile profile = Harness.CreateProfile();
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(2);
AddKeyframes(brush.Properties.Color, (0, SKColors.Red), (2, SKColors.Blue));
// Small steps so wrapping around does not depend on the frame size, see below
using RenderTimeline timeline = RenderTimeline(profile, 1, 1.9, 2, 2.1, 3);
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(Purple);
timeline[3].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(242, 0, 13));
timeline[4].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(Purple);
layer.Timeline.IsFinished.ShouldBeFalse();
}
[Fact]
public void AlwaysOn_WrapsAroundUsingTheFrameDeltaRatherThanTheOvershoot()
{
Profile profile = Harness.CreateProfile();
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(2);
AddKeyframes(brush.Properties.Color, (0, SKColors.Red), (2, SKColors.Blue));
// Going from 1.5s to 2.5s overshoots the 2s main segment by 0.5s, the timeline lands on 1s instead of 0.5s.
// Pinned as-is: with real frame times (~33ms) the difference is at most a single frame.
using RenderTimeline timeline = RenderTimeline(profile, 1.5, 2.5);
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(1));
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(Purple);
}
[Fact]
public void NewLayer_StartsWithAFinishedTimeline()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
// Layer.Initialize calls Reset while the display condition is not met yet, which jumps to the end
layer.Timeline.Position.ShouldBe(layer.Timeline.Length);
}
[Fact]
public void NewFolder_StartsAtTheBeginningOfItsTimeline()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
// Unlike layers, new folders are not reset on creation
folder.Timeline.Position.ShouldBe(TimeSpan.Zero);
profile.GetRootFolder().Timeline.Position.ShouldBe(TimeSpan.Zero);
}
[Fact]
public void LoadedLayers_StartAtTheBeginningOfTheirTimeline()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)]);
Profile reloaded = Harness.SaveAndReload(profile);
// Folder.Load resets the folder before loading its children, so the parent condition is met when the layer resets
reloaded.GetAllLayers().Single().Timeline.Position.ShouldBe(TimeSpan.Zero);
}
[Fact]
public void LoadedFolders_StartWithAFinishedTimeline()
{
Profile profile = Harness.CreateProfile();
Harness.AddFolder(profile.GetRootFolder());
Profile reloaded = Harness.SaveAndReload(profile);
// Folder.Load replaces the timeline after resetting, and Timeline.Load jumps to the end
foreach (Folder folder in reloaded.GetAllFolders())
folder.Timeline.Position.ShouldBe(folder.Timeline.Length);
}
[Fact]
public void AlwaysOn_NewLayer_SkipsTheStartSegment()
{
Profile profile = Harness.CreateProfile();
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Timeline.StartSegmentLength = TimeSpan.FromSeconds(1);
layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(1);
AddKeyframes(brush.Properties.Color, (0, SKColors.Black), (1, SKColors.Red));
using RenderFrame frame = Render(profile, 0.5);
// The timeline starts finished and wraps straight into the main segment, skipping the start segment.
// Only applies to layers created in code, the editor resets the profile and loaded layers start at zero
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(1.5));
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
[Fact]
public void AlwaysOn_AfterReset_PlaysTheStartSegmentOnce()
{
Profile profile = Harness.CreateProfile();
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Timeline.StartSegmentLength = TimeSpan.FromSeconds(1);
layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(1);
// Fades in from black during the start segment, then holds red during the main segment
AddKeyframes(brush.Properties.Color, (0, SKColors.Black), (1, SKColors.Red));
profile.Reset();
using RenderTimeline timeline = RenderTimeline(profile, 0.5, 1.5, 2.1, 2.5, 3.5);
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
timeline[2].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
timeline[3].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
timeline[4].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
layer.Timeline.Position.ShouldBeGreaterThanOrEqualTo(layer.Timeline.MainSegmentStartPosition);
}
#endregion
#region Display conditions
[Fact]
public void PlayOnce_NewLayer_NeverPlays()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(1);
TestConditions.UsePlayOnceCondition(layer);
using RenderFrame frame = Render(profile, 0.5);
// The timeline starts finished and play once never restarts it.
// Only applies to layers created in code, the editor resets the profile and loaded layers start at zero
layer.Timeline.IsFinished.ShouldBeTrue();
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
}
[Fact]
public void PlayOnce_LoadedLayer_PlaysOnceThenStops()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(1);
TestConditions.UsePlayOnceCondition(layer);
Profile reloaded = Harness.SaveAndReload(profile);
using RenderTimeline timeline = RenderTimeline(reloaded, 0.5, 1.5);
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
}
[Fact]
public void PlayOnce_AfterReset_StopsRenderingAfterTheTimeline()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(1);
TestConditions.UsePlayOnceCondition(layer);
profile.Reset();
using RenderTimeline timeline = RenderTimeline(profile, 0.5, 1.5);
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
layer.Timeline.IsFinished.ShouldBeTrue();
}
[Fact]
public void StaticCondition_RendersOnlyWhileMet()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
TestBooleanNode condition = TestConditions.UseStaticCondition(layer, false, stopMode: StaticStopMode.SkipToEnd);
using (RenderFrame notMet = Harness.Render(profile, TimeSpan.FromSeconds(0.1)))
notMet.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
condition.Value = true;
using (RenderFrame met = Harness.Render(profile, TimeSpan.FromSeconds(0.1)))
met.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
condition.Value = false;
using RenderFrame metAgain = Render(profile);
metAgain.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
}
[Fact]
public void StaticCondition_StopModeFinish_FinishesTheMainSegmentFirst()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(2);
TestBooleanNode condition = TestConditions.UseStaticCondition(layer, true, stopMode: StaticStopMode.Finish);
using (RenderFrame met = Harness.Render(profile, TimeSpan.FromSeconds(0.5)))
met.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
condition.Value = false;
using RenderTimeline timeline = RenderTimeline(profile, 1, 2.5);
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
}
[Fact]
public void StaticCondition_StopModeSkipToEnd_PlaysTheEndSegment()
{
Profile profile = Harness.CreateProfile();
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(2);
layer.Timeline.EndSegmentLength = TimeSpan.FromSeconds(1);
// Red during the main segment, fading to black during the end segment
AddKeyframes(brush.Properties.Color, (2, SKColors.Red), (3, SKColors.Black));
TestBooleanNode condition = TestConditions.UseStaticCondition(layer, true, stopMode: StaticStopMode.SkipToEnd);
using (RenderFrame met = Harness.Render(profile, TimeSpan.FromSeconds(0.5)))
met.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
condition.Value = false;
using RenderTimeline timeline = RenderTimeline(profile, 1, 1.6);
// Jumped to the start of the end segment (2s) and advanced 0.5s into it
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(127, 0, 0));
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
layer.Timeline.IsFinished.ShouldBeTrue();
}
[Fact]
public void StaticCondition_PlayModeOnce_StopsAfterTheMainSegmentWhileStillMet()
{
Profile profile = Harness.CreateProfile();
(Layer layer, _) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(1);
TestConditions.UseStaticCondition(layer, true, StaticPlayMode.Once);
using RenderTimeline timeline = RenderTimeline(profile, 0.5, 1.5);
timeline[0].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
}
[Fact]
public void StaticCondition_RestartsTheTimelineWhenMetAgainAfterFinishing()
{
Profile profile = Harness.CreateProfile();
(Layer layer, SolidColorTestBrush brush) = Harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]);
layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(2);
AddKeyframes(brush.Properties.Color, (0, SKColors.Red), (2, SKColors.Blue));
TestBooleanNode condition = TestConditions.UseStaticCondition(layer, true, stopMode: StaticStopMode.SkipToEnd);
RenderTimeline(profile, 1).Dispose();
condition.Value = false;
RenderTimeline(profile, 1.1).Dispose();
layer.Timeline.IsFinished.ShouldBeTrue();
condition.Value = true;
using RenderTimeline timeline = RenderTimeline(profile, 1.2, 2.2);
// Starts over from the beginning of the timeline, 1.1s in after the second frame
layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(1.1));
timeline[1].GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(115, 0, 140));
}
[Fact]
public void ParentFolderCondition_AppliesToItsChildren()
{
Profile profile = Harness.CreateProfile();
Folder folder = Harness.AddFolder(profile.GetRootFolder());
Harness.AddLayer<SolidColorTestBrush>(folder, [Led(LedId.Keyboard_A)]);
TestBooleanNode condition = TestConditions.UseStaticCondition(folder, false);
using (RenderFrame notMet = Harness.Render(profile, TimeSpan.FromSeconds(0.1)))
notMet.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
condition.Value = true;
using RenderFrame met = Render(profile);
met.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
#endregion
private static void AddKeyframes(SKColorLayerProperty property, params (double Seconds, SKColor Color)[] keyframes)
{
foreach ((double seconds, SKColor color) in keyframes)
property.AddKeyframe(new LayerPropertyKeyframe<SKColor>(color, TimeSpan.FromSeconds(seconds), Easings.Functions.Linear, property));
}
}
@@ -0,0 +1,98 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
using Artemis.Core.Tests.Infrastructure.Rendering;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Rendering;
/// <summary>
/// The render scale sets the size of the canvas relative to the surface. Each test creates its own harness since the
/// scale is fixed per harness.
/// </summary>
public class RenderScaleTests
{
public static TheoryData<float, int, int> CanvasSizes => new()
{
// The 441x144 keyboard is first rounded up to even dimensions, then scaled and rounded to whole pixels
{1f, 442, 144},
{0.5f, 221, 72},
{0.25f, 111, 36}
};
public static TheoryData<float> RenderScales => new(1f, 0.5f, 0.25f);
[Theory]
[MemberData(nameof(CanvasSizes))]
public void CanvasSize_FollowsTheRenderScale(float renderScale, int width, int height)
{
using RenderHarness harness = new(renderScale);
harness.AddDevice(TestLayouts.KeyboardAnsi);
using RenderFrame frame = harness.Render(_ => { });
frame.Canvas.Width.ShouldBe(width);
frame.Canvas.Height.ShouldBe(height);
}
[Theory]
[MemberData(nameof(RenderScales))]
public void LedSizedShapes_RenderTheSameAtEveryScale(float renderScale)
{
using RenderHarness harness = new(renderScale);
ArtemisDevice keyboard = harness.AddDevice(TestLayouts.KeyboardAnsi);
(LedId Led, SKColor Color)[] fills = [(LedId.Keyboard_A, SKColors.Red), (LedId.Keyboard_S, SKColors.Lime), (LedId.Keyboard_Enter, SKColors.Blue), (LedId.Keyboard_Space, SKColors.Yellow)];
using RenderFrame frame = harness.Render(canvas =>
{
foreach ((LedId led, SKColor color) in fills)
{
using SKPaint paint = new() {Color = color};
canvas.DrawRect(keyboard.GetLed(led, false)!.AbsoluteRectangle, paint);
}
});
foreach ((LedId led, SKColor color) in fills)
frame.GetColor(keyboard, led).ShouldBeColor(color, 2, led.ToString());
frame.GetColor(keyboard, LedId.Keyboard_D).ShouldBeColor(SKColors.Black);
}
[Theory]
[MemberData(nameof(RenderScales))]
public void LedRectangles_AreSnappedToTheRenderScale(float renderScale)
{
using RenderHarness harness = new(renderScale);
ArtemisDevice keyboard = harness.AddDevice(TestLayouts.KeyboardAnsi);
int multiplier = (int) (1 / renderScale);
keyboard.Leds.ShouldAllBe(l => l.AbsoluteRectangle.Left % multiplier == 0 && l.AbsoluteRectangle.Top % multiplier == 0 &&
l.AbsoluteRectangle.Width % multiplier == 0 && l.AbsoluteRectangle.Height % multiplier == 0);
}
[Fact]
public void LowRenderScale_RoundsThinDetailUpToWholeCanvasPixels()
{
// A two unit wide line through A, at a render scale of 0.25 that is half a canvas pixel
using RenderHarness full = new(1f);
SKColor fullColor = RenderLine(full);
using RenderHarness quarter = new(0.25f);
SKColor quarterColor = RenderLine(quarter);
// At full scale the line covers about a tenth of the LED. At a quarter A is only 4x4 canvas pixels and the line,
// drawn without anti-aliasing, fills a whole column of them: a quarter of the LED
fullColor.Red.ShouldBeInRange((byte) 15, (byte) 40);
quarterColor.ShouldBeColor(new SKColor(64, 0, 0), 3);
}
private static SKColor RenderLine(RenderHarness harness)
{
ArtemisDevice keyboard = harness.AddDevice(TestLayouts.KeyboardAnsi);
SKRect a = keyboard.GetLed(LedId.Keyboard_A, false)!.AbsoluteRectangle;
using RenderFrame frame = harness.Render(canvas =>
{
using SKPaint paint = new() {Color = SKColors.Red};
canvas.DrawRect(SKRect.Create(a.MidX, a.Top, 2, a.Height), paint);
});
return frame.GetColor(keyboard, LedId.Keyboard_A);
}
}
@@ -0,0 +1,53 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Rendering;
using SkiaSharp;
namespace Artemis.Core.Tests.Rendering;
/// <summary>
/// LEDs smaller than a canvas pixel, like those of LED strips at low render scales, must remain individually
/// addressable. Each test creates its own harness since the render scale is fixed per harness.
/// </summary>
public class SmallLedTests
{
public static TheoryData<float> RenderScales => new(1f, 0.5f, 0.25f);
[Theory]
[MemberData(nameof(RenderScales))]
public void SmallLeds_AreIndividuallyAddressableThroughALayer(float renderScale)
{
using RenderHarness harness = new(renderScale);
ArtemisDevice strip = harness.AddDevice(TestLayouts.SmallLedStrip);
Profile profile = harness.CreateProfile();
(_, PerLedTestBrush brush) = harness.AddLayer<PerLedTestBrush>(profile.GetRootFolder(), strip.Leds);
brush.ColorSelector = (led, _) => ColorFor(strip, led);
using RenderFrame frame = harness.Render(profile, TimeSpan.FromSeconds(0.1));
foreach (ArtemisLed led in strip.Leds)
frame.LedColors[led].ShouldBeColor(ColorFor(strip, led), 2, $"{led.RgbLed.Id} {led.AbsoluteRectangle}");
}
[Theory]
[MemberData(nameof(RenderScales))]
public void SmallLeds_ReceiveASolidLayer(float renderScale)
{
using RenderHarness harness = new(renderScale);
ArtemisDevice strip = harness.AddDevice(TestLayouts.SmallLedStrip);
Profile profile = harness.CreateProfile();
harness.AddLayer<SolidColorTestBrush>(profile.GetRootFolder(), strip.Leds);
using RenderFrame frame = harness.Render(profile, TimeSpan.FromSeconds(0.1));
foreach (ArtemisLed led in strip.Leds)
frame.LedColors[led].ShouldBeColor(SKColors.Red, 2, $"{led.RgbLed.Id} {led.AbsoluteRectangle}");
}
private static SKColor ColorFor(ArtemisDevice strip, ArtemisLed led)
{
// Alternate between two colors so neighbouring LEDs can't pass by sampling each other
return strip.Leds.IndexOf(led) % 2 == 0 ? SKColors.Red : SKColors.Blue;
}
}
@@ -0,0 +1,231 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
using Artemis.Core.Tests.Infrastructure.Rendering;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Rendering;
/// <summary>
/// How SurfaceManager keeps the texture in sync with the devices on the surface, and how device placement and color
/// correction affect what each LED receives.
/// </summary>
public class SurfaceTests : RenderTestBase
{
#region Placement
[Fact]
public void DevicePosition_MovesWhereItsLedsSample()
{
Keyboard.X = 200;
Keyboard.Y = 100;
SKRect a = Led(LedId.Keyboard_A).AbsoluteRectangle;
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, a, SKColors.Red));
SaveOutput(frame);
a.Left.ShouldBe(240);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
// The surface always starts at the origin, so the canvas grows with the offset
frame.Canvas.Width.ShouldBe(321);
}
[Fact]
public void MovingADevice_RecreatesTheTexture()
{
SKRect originalA = Led(LedId.Keyboard_A).AbsoluteRectangle;
using (RenderFrame before = Harness.Render(canvas => Fill(canvas, originalA, SKColors.Red)))
before.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
Keyboard.X = 100;
using RenderFrame after = Harness.Render(canvas => Fill(canvas, originalA, SKColors.Red));
SaveOutput(after);
// A moved away from the red rectangle, if the texture still used the old LED positions A would remain red
after.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
after.Canvas.Width.ShouldBe(271);
}
[Fact]
public void AddingADevice_GrowsTheTextureAndSamplesBothDevices()
{
ArtemisDevice second = Harness.AddDevice(TestLayouts.KeyboardIso, 500, 0);
using RenderFrame frame = Harness.Render(canvas =>
{
Fill(canvas, Led(LedId.Keyboard_A).AbsoluteRectangle, SKColors.Red);
Fill(canvas, second.GetLed(LedId.Keyboard_A, false)!.AbsoluteRectangle, SKColors.Blue);
});
SaveOutput(frame);
frame.Canvas.Width.ShouldBeGreaterThan(450);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
frame.GetColor(second, LedId.Keyboard_A).ShouldBeColor(SKColors.Blue);
}
[Fact]
public void RemovedDevice_KeepsShowingItsLastColors()
{
ArtemisDevice second = Harness.AddDevice(TestLayouts.KeyboardIso, 500, 0);
SKRect secondA = second.GetLed(LedId.Keyboard_A, false)!.AbsoluteRectangle;
Harness.Render(canvas => Fill(canvas, secondA, SKColors.Blue)).Dispose();
Harness.RemoveDevice(second);
using RenderFrame frame = Harness.Render(_ => { });
SaveOutput(frame);
// Nothing sends the device new colors, like real hardware it keeps whatever it received last
frame.LedColors.Keys.ShouldNotContain(second.Leds[0]);
frame.Canvas.Width.ShouldBe(221);
}
[Fact]
public void OverlappingDevices_BothSampleTheSameCanvasRegardlessOfZIndex()
{
ArtemisDevice overlapping = Harness.AddDevice(TestLayouts.KeyboardAnsi);
overlapping.ZIndex = 10;
Keyboard.ZIndex = 1;
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, Led(LedId.Keyboard_A).AbsoluteRectangle, SKColors.Red));
SaveOutput(frame);
// The Z-index only orders devices in the device list, rendering doesn't use it
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
frame.GetColor(overlapping, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
[Fact]
public void RotatingADevice_DoesNotUpdateItsLedsUntilItIsMoved()
{
ArtemisLed a = Led(LedId.Keyboard_A);
SKRect beforeRotation = a.AbsoluteRectangle;
Keyboard.Rotation = 90;
// Pinned as-is: the Rotation and Scale setters only update the RGB.NET device. The Artemis LEDs, and with them the
// texture, keep their old positions until X or Y is set. The device properties dialog gets away with this because
// it reloads the device layout after saving
a.AbsoluteRectangle.ShouldBe(beforeRotation);
Math.Abs(a.RgbLed.AbsoluteBoundary.Location.X - beforeRotation.Left).ShouldBeGreaterThan(1);
Keyboard.X = Keyboard.X;
a.AbsoluteRectangle.ShouldNotBe(beforeRotation);
}
[Fact]
public void RotatedDevice_SamplesFromItsRotatedLedPositions()
{
Keyboard.Rotation = 90;
Keyboard.X = Keyboard.X;
SKRect rotatedA = Led(LedId.Keyboard_A).AbsoluteRectangle;
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, rotatedA, SKColors.Red));
SaveOutput(frame);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(SKColors.Black);
}
[Fact]
public void ScaledDevice_SamplesFromItsScaledLedPositions()
{
Keyboard.Scale = 2;
Keyboard.X = Keyboard.X;
SKRect scaledA = Led(LedId.Keyboard_A).AbsoluteRectangle;
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, scaledA, SKColors.Red));
SaveOutput(frame);
scaledA.Width.ShouldBeInRange(36, 40);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(SKColors.Black);
}
[Fact]
public void DeviceAtANegativePosition_LedsOutsideTheTextureReceiveNothing()
{
ArtemisDevice outside = Harness.AddDevice(TestLayouts.KeyboardIso, -500, 0);
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, SKRect.Create(-1000, -1000, 3000, 3000), SKColors.Red));
SaveOutput(frame);
// The texture starts at the origin, sample rectangles are clamped to it so LEDs beyond it get no color (#906).
// Before that they made SKTexture throw, which broke the frame for every device
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
outside.Leds.Where(l => l.AbsoluteRectangle.Right <= 0).ShouldAllBe(l => frame.LedColors[l] == SKColors.Transparent);
}
[Fact]
public void SurfaceBeyondTheMaximumTextureSize_LedsOutsideTheTextureReceiveNothing()
{
// At a render scale of 0.5 the 4096 pixel texture limit is reached at 8192 surface units
ArtemisDevice outside = Harness.AddDevice(TestLayouts.KeyboardIso, 9000, 0);
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, SKRect.Create(0, 0, 20000, 1000), SKColors.Red));
frame.Canvas.Width.ShouldBe(4096);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
outside.Leds.ShouldAllBe(l => frame.LedColors[l] == SKColors.Transparent);
}
#endregion
#region Color correction
[Theory]
[InlineData(0.5f, 1f, 1f, 127, 255, 255)]
[InlineData(1f, 0f, 1f, 255, 0, 255)]
[InlineData(1f, 1f, 0.25f, 255, 255, 63)]
public void ColorScales_AreAppliedToWhatTheDeviceReceives(float red, float green, float blue, byte expectedRed, byte expectedGreen, byte expectedBlue)
{
Keyboard.RedScale = red;
Keyboard.GreenScale = green;
Keyboard.BlueScale = blue;
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, Led(LedId.Keyboard_A).AbsoluteRectangle, SKColors.White));
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(expectedRed, expectedGreen, expectedBlue));
// The canvas itself is not corrected
SKRectI pixel = SKRectI.Round(Led(LedId.Keyboard_A).AbsoluteRectangle);
frame.Canvas.GetPixel(pixel.MidX / 2, pixel.MidY / 2).ShouldBe(SKColors.White);
}
[Fact]
public void ColorScalesAboveOne_AreClampedToFullBrightness()
{
Keyboard.RedScale = 2;
using RenderFrame frame = Harness.Render(canvas =>
{
Fill(canvas, Led(LedId.Keyboard_A).AbsoluteRectangle, new SKColor(100, 0, 0));
Fill(canvas, Led(LedId.Keyboard_S).AbsoluteRectangle, new SKColor(200, 0, 0));
});
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(200, 0, 0));
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(new SKColor(255, 0, 0));
}
[Fact]
public void ColorScales_OnlyAffectTheirOwnDevice()
{
ArtemisDevice second = Harness.AddDevice(TestLayouts.KeyboardIso, 500, 0);
Keyboard.RedScale = 0;
using RenderFrame frame = Harness.Render(canvas =>
{
Fill(canvas, Led(LedId.Keyboard_A).AbsoluteRectangle, SKColors.Red);
Fill(canvas, second.GetLed(LedId.Keyboard_A, false)!.AbsoluteRectangle, SKColors.Red);
});
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
frame.GetColor(second, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
#endregion
private static void Fill(SKCanvas canvas, SKRect rect, SKColor color)
{
using SKPaint paint = new() {Color = color};
canvas.DrawRect(rect, paint);
}
}
@@ -0,0 +1,90 @@
using Artemis.Core.Tests.Infrastructure;
using Artemis.Core.Tests.Infrastructure.Rendering;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Rendering;
/// <summary>
/// How SKTexture turns the canvas into LED colors: every LED gets the average color of the canvas pixels under its
/// rectangle.
/// </summary>
public class TextureSamplingTests : RenderTestBase
{
[Fact]
public void LedColor_IsTheAverageOfTheCanvasUnderTheLed()
{
// A is 18 units wide, at the default render scale that is 9 canvas pixels. Fill the left 4 of them
SKRect a = Led(LedId.Keyboard_A).AbsoluteRectangle;
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, new SKRect(a.Left, a.Top, a.Left + 8, a.Bottom), SKColors.Red));
SaveOutput(frame);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(255 * 4 / 9, 0, 0));
}
[Fact]
public void FillingTheLedRectangle_DoesNotBleedIntoNeighbours()
{
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, Led(LedId.Keyboard_A).AbsoluteRectangle, SKColors.Red));
SaveOutput(frame);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
frame.GetColor(Keyboard, LedId.Keyboard_S).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.Keyboard_CapsLock).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.Keyboard_Q).ShouldBeColor(SKColors.Black);
frame.GetColor(Keyboard, LedId.Keyboard_Z).ShouldBeColor(SKColors.Black);
}
[Fact]
public void Canvas_IsClearedToBlackEveryFrame()
{
Harness.Render(canvas => Fill(canvas, Led(LedId.Keyboard_A).AbsoluteRectangle, SKColors.Red)).Dispose();
using RenderFrame frame = Harness.Render(_ => { });
SaveOutput(frame);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Black);
}
[Fact]
public void TransparentCanvasPixels_AreNotSentToTheDevice()
{
Harness.Render(canvas => Fill(canvas, Led(LedId.Keyboard_A).AbsoluteRectangle, SKColors.Red)).Dispose();
using RenderFrame frame = Harness.Render(canvas => canvas.Clear(SKColors.Transparent));
SaveOutput(frame);
// RGB.NET only sends LEDs whose color has some alpha, so the device keeps showing red. Profiles can't cause this,
// the root folder is composited onto the canvas, which is cleared to opaque black every frame
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red);
}
[Fact]
public void SemiTransparentDrawing_IsBlendedOntoBlack()
{
using RenderFrame frame = Harness.Render(canvas => Fill(canvas, Led(LedId.Keyboard_A).AbsoluteRectangle, new SKColor(255, 0, 0, 128)));
SaveOutput(frame);
frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(new SKColor(128, 0, 0));
}
[Fact]
public void LedRectangles_AreSnappedToWholeCanvasPixels()
{
// At the default render scale of 0.5 a canvas pixel covers two surface units, LED rectangles are snapped to even
// values so they line up with the pixels. The RGB.NET boundary of A is (40.25, 77) 19x19
ArtemisLed a = Led(LedId.Keyboard_A);
a.RgbLed.AbsoluteBoundary.Location.X.ShouldBe(40.25f, 0.01f);
a.AbsoluteRectangle.ShouldBe(new SKRect(40, 76, 58, 94));
Keyboard.Leds.ShouldAllBe(l => l.AbsoluteRectangle.Left % 2 == 0 && l.AbsoluteRectangle.Top % 2 == 0 &&
l.AbsoluteRectangle.Width % 2 == 0 && l.AbsoluteRectangle.Height % 2 == 0);
}
private static void Fill(SKCanvas canvas, SKRect rect, SKColor color)
{
using SKPaint paint = new() {Color = color};
canvas.DrawRect(rect, paint);
}
}
@@ -0,0 +1,400 @@
using Artemis.Core.Services;
using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
using Artemis.Core.Tests.Infrastructure.Services;
using Artemis.Storage.Entities.Surface;
using RGB.NET.Core;
using RGB.NET.Layout;
namespace Artemis.Core.Tests.Services;
public class DeviceServiceTests : ServiceTestBase
{
private static readonly string KeyboardTkl = Path.Combine(AppContext.BaseDirectory, "DefaultLayouts", "Artemis", "Keyboard", "Artemis TKL keyboard-ANSI.xml");
private static readonly string Headset = Path.Combine(AppContext.BaseDirectory, "DefaultLayouts", "Artemis", "Headset", "Artemis 4 LEDs headset.xml");
private IDeviceService Devices => Resolve<IDeviceService>();
[Fact]
public void AddDeviceProvider_AddsItsDevices()
{
List<ArtemisDevice> added = [];
List<DeviceProviderEventArgs> providersAdded = [];
int ledsChanged = 0;
Devices.DeviceAdded += (_, e) => added.Add(e.Device);
Devices.DeviceProviderAdded += (_, e) => providersAdded.Add(e);
Devices.LedsChanged += (_, _) => ledsChanged++;
TestDeviceProvider provider = AddDeviceProvider(TestLayouts.KeyboardAnsi, TestLayouts.SmallLedStrip);
Devices.Devices.Count.ShouldBe(2);
Devices.EnabledDevices.ShouldBe(Devices.Devices, ignoreOrder: true);
Devices.Devices.ShouldAllBe(d => d.DeviceProvider == provider);
added.ShouldBe(Devices.Devices, ignoreOrder: true);
providersAdded.Single().DeviceProvider.ShouldBeSameAs(provider);
providersAdded.Single().Devices.ShouldBe(Devices.Devices, ignoreOrder: true);
// Once for the layout of each device and once when all devices are in
ledsChanged.ShouldBe(3);
}
[Fact]
public void AddDeviceProvider_StoresNewDevices()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi);
using var context = Services.CreateDbContext();
DeviceEntity stored = context.Devices.Single();
stored.Id.ShouldBe(Devices.Devices.Single().Identifier);
stored.IsEnabled.ShouldBeTrue();
}
[Fact]
public void AddDeviceProvider_GivesDevicesTheDefaultLayout()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi);
// The test plugin ships no layouts, so the layout comes from the default layouts in Artemis.Core
ArtemisDevice keyboard = Devices.Devices.Single();
keyboard.LayoutSelection.Type.ShouldBe("Default");
keyboard.Layout.ShouldNotBeNull();
keyboard.Layout.IsDefaultLayout.ShouldBeTrue();
}
[Theory]
[InlineData("Artemis 1 LED headset.xml", true)]
[InlineData("Artemis 2 LEDs headset.xml", false)]
[InlineData("Artemis 4 LEDs headset.xml", false)]
public void AddDeviceProvider_GivesHeadsetsTheDefaultLayoutForTheirNumberOfLeds(string layoutFile, bool getsLayout)
{
AddDeviceProvider(Path.Combine(AppContext.BaseDirectory, "DefaultLayouts", "Artemis", "Headset", layoutFile));
// Pinned as-is: the default headset layouts are looked up as "Artemis 2 LED headset.xml" and "Artemis 4 LED
// headset.xml", but the files are named "... LEDs headset.xml", so headsets with 2 or more LEDs get no default
// layout (the device service logs it as an invalid layout)
ArtemisDevice headset = Devices.Devices.Single();
if (getsLayout)
headset.Layout!.IsDefaultLayout.ShouldBeTrue();
else
headset.Layout.ShouldBeNull();
}
[Fact]
public void AddDeviceProvider_WithoutDevices_AddsNothing()
{
int providersAdded = 0;
Devices.DeviceProviderAdded += (_, _) => providersAdded++;
AddDeviceProvider();
Devices.Devices.ShouldBeEmpty();
providersAdded.ShouldBe(0);
}
[Fact]
public void AddDeviceProvider_ASecondTime_ReplacesItsDevices()
{
TestDeviceProvider provider = AddDeviceProvider(TestLayouts.KeyboardAnsi);
ArtemisDevice before = Devices.Devices.Single();
List<ArtemisDevice> removed = [];
Devices.DeviceRemoved += (_, e) => removed.Add(e.Device);
Devices.AddDeviceProvider(provider);
removed.ShouldBe([before]);
Devices.Devices.Single().ShouldNotBeSameAs(before);
Devices.Devices.Single().Identifier.ShouldBe(before.Identifier);
}
[Fact]
public void AddDeviceProvider_RestoresStoredDeviceSettings()
{
TestDeviceProvider provider = AddDeviceProvider(TestLayouts.KeyboardAnsi);
ArtemisDevice keyboard = Devices.Devices.Single();
keyboard.X = 100;
keyboard.Y = 50;
keyboard.Scale = 2;
keyboard.RedScale = 0.5f;
Devices.SaveDevice(keyboard);
Devices.RemoveDeviceProvider(provider);
Devices.AddDeviceProvider(provider);
ArtemisDevice restored = Devices.Devices.Single();
restored.ShouldNotBeSameAs(keyboard);
restored.X.ShouldBe(100);
restored.Y.ShouldBe(50);
restored.Scale.ShouldBe(2);
restored.RedScale.ShouldBe(0.5f);
}
[Fact]
public void AddDeviceProvider_KeepsDisabledDevicesDisabled()
{
TestDeviceProvider provider = AddDeviceProvider(TestLayouts.KeyboardAnsi);
Devices.DisableDevice(Devices.Devices.Single());
Devices.RemoveDeviceProvider(provider);
Devices.AddDeviceProvider(provider);
Devices.Devices.Single().IsEnabled.ShouldBeFalse();
Devices.EnabledDevices.ShouldBeEmpty();
}
[Fact]
public void Devices_AreSortedByZIndex()
{
TestDeviceProvider provider = AddDeviceProvider(TestLayouts.KeyboardAnsi, TestLayouts.SmallLedStrip);
ArtemisDevice strip = Devices.Devices.Single(d => d.DeviceType == RGBDeviceType.LedStripe);
ArtemisDevice keyboard = Devices.Devices.Single(d => d.DeviceType == RGBDeviceType.Keyboard);
strip.ZIndex = 1;
keyboard.ZIndex = 2;
Devices.SaveDevices();
// Sorting happens when devices are added
Devices.AddDeviceProvider(provider);
Devices.Devices.Select(d => d.DeviceType).ShouldBe([RGBDeviceType.LedStripe, RGBDeviceType.Keyboard]);
Devices.EnabledDevices.Select(d => d.DeviceType).ShouldBe([RGBDeviceType.LedStripe, RGBDeviceType.Keyboard]);
}
[Fact]
public void RemoveDeviceProvider_RemovesItsDevices()
{
TestDeviceProvider keyboardProvider = AddDeviceProvider(TestLayouts.KeyboardAnsi);
AddDeviceProvider(new SuspendableTestDeviceProvider(), TestLayouts.SmallLedStrip);
ArtemisDevice keyboard = Devices.Devices.Single(d => d.DeviceProvider == keyboardProvider);
List<ArtemisDevice> removed = [];
List<DeviceProviderEventArgs> providersRemoved = [];
int ledsChanged = 0;
Devices.DeviceRemoved += (_, e) => removed.Add(e.Device);
Devices.DeviceProviderRemoved += (_, e) => providersRemoved.Add(e);
Devices.LedsChanged += (_, _) => ledsChanged++;
Devices.RemoveDeviceProvider(keyboardProvider);
Devices.Devices.ShouldNotContain(keyboard);
Devices.EnabledDevices.ShouldNotContain(keyboard);
Devices.Devices.Count.ShouldBe(1);
removed.ShouldBe([keyboard]);
providersRemoved.Single().Devices.ShouldBe([keyboard]);
ledsChanged.ShouldBe(1);
}
[Fact]
public void DisableAndEnableDevice_UpdateTheEnabledDevicesAndStoreIt()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi);
ArtemisDevice keyboard = Devices.Devices.Single();
List<string> events = [];
Devices.DeviceDisabled += (_, _) => events.Add("disabled");
Devices.DeviceEnabled += (_, _) => events.Add("enabled");
Devices.LedsChanged += (_, _) => events.Add("leds changed");
Devices.DisableDevice(keyboard);
Devices.DisableDevice(keyboard);
keyboard.IsEnabled.ShouldBeFalse();
Devices.EnabledDevices.ShouldBeEmpty();
Devices.Devices.ShouldBe([keyboard]);
StoredDevice().IsEnabled.ShouldBeFalse();
Devices.EnableDevice(keyboard);
Devices.EnableDevice(keyboard);
keyboard.IsEnabled.ShouldBeTrue();
Devices.EnabledDevices.ShouldBe([keyboard]);
StoredDevice().IsEnabled.ShouldBeTrue();
// Doing it a second time does nothing
events.ShouldBe(["disabled", "leds changed", "enabled", "leds changed"]);
}
[Fact]
public void SaveDevice_StoresTheDevice()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi);
ArtemisDevice keyboard = Devices.Devices.Single();
int ledsChanged = 0;
Devices.LedsChanged += (_, _) => ledsChanged++;
keyboard.X = 25;
keyboard.Rotation = 90;
Devices.SaveDevice(keyboard);
StoredDevice().X.ShouldBe(25);
StoredDevice().Rotation.ShouldBe(90);
ledsChanged.ShouldBe(1);
}
[Fact]
public void LoadDeviceLayout_WithTheNoneLayoutType_RemovesTheLayout()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi);
ArtemisDevice keyboard = Devices.Devices.Single();
keyboard.LayoutSelection.Type = "None";
Devices.LoadDeviceLayout(keyboard);
keyboard.Layout.ShouldBeNull();
keyboard.LayoutSelection.ErrorState.ShouldBeNull();
}
[Fact]
public void LoadDeviceLayout_WithAnUnknownLayoutType_RemovesTheLayout()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi);
ArtemisDevice keyboard = Devices.Devices.Single();
keyboard.LayoutSelection.Type = "Something unknown";
Devices.LoadDeviceLayout(keyboard);
keyboard.Layout.ShouldBeNull();
}
[Fact]
public void LoadDeviceLayout_BackToTheDefaultType_RestoresTheLayout()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi);
ArtemisDevice keyboard = Devices.Devices.Single();
keyboard.LayoutSelection.Type = "None";
Devices.LoadDeviceLayout(keyboard);
keyboard.LayoutSelection.Type = "Default";
Devices.LoadDeviceLayout(keyboard);
keyboard.Layout.ShouldNotBeNull();
keyboard.Layout.IsDefaultLayout.ShouldBeTrue();
}
[Fact]
public void ChangingTheRenderScale_RaisesLedsChanged()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi);
int ledsChanged = 0;
Devices.LedsChanged += (_, _) => ledsChanged++;
// Keep the current value, other tests rely on it
RenderScale.SetRenderScaleMultiplier(RenderScale.RenderScaleMultiplier);
ledsChanged.ShouldBe(1);
}
[Fact]
public void AutoArrangeDevices_PlacesDevicesAroundTheKeyboard()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi, TestLayouts.SmallLedStrip, Headset);
using RGBSurface surface = AttachToSurface();
ArtemisDevice keyboard = Devices.Devices.Single(d => d.DeviceType == RGBDeviceType.Keyboard);
ArtemisDevice headset = Devices.Devices.Single(d => d.DeviceType == RGBDeviceType.Headset);
ArtemisDevice strip = Devices.Devices.Single(d => d.DeviceType == RGBDeviceType.LedStripe);
Devices.AutoArrangeDevices(false);
// There's no keypad to place the keyboard next to, so it goes 10 to the right of where arranging started
(keyboard.X, keyboard.Y).ShouldBe((10f, 0f));
// The headset is centered 100 below the keyboard (the keyboard's own 10 margin included)
(headset.X, headset.Y).ShouldBe((10 + 441 / 2f - 242 / 2f, 144 + 10 + 100f));
// There's no fan to place the LED strip next to, so it goes 10 to the right of everything else
(strip.X, strip.Y).ShouldBe((10 + 441 + 10f, 0f));
// Pinned as-is: only devices placed by one of their type's own rules get the z-index of their type. LED strips
// normally get 2, but placed by the fallback (no fan to place it next to) it keeps the 1 every new device starts with
strip.ZIndex.ShouldBe(1);
}
[Fact]
public void AutoArrangeDevices_StacksWideDevicesOfTheSameTypeVertically()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi, KeyboardTkl);
using RGBSurface surface = AttachToSurface();
Devices.AutoArrangeDevices(false);
List<ArtemisDevice> keyboards = Devices.Devices.OrderBy(d => d.Y).ToList();
keyboards.ShouldAllBe(d => d.X == 10);
keyboards[0].Y.ShouldBe(0);
// Half the margin of 10 between them
keyboards[1].Y.ShouldBe(keyboards[0].RgbDevice.Size.Height + 5);
}
[Fact]
public void AutoArrangeDevices_LeftHanded_PlacesTheHeadsetTheSameWay()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi, Headset);
using RGBSurface surface = AttachToSurface();
ArtemisDevice headset = Devices.Devices.Single(d => d.DeviceType == RGBDeviceType.Headset);
Devices.AutoArrangeDevices(true);
// Without a mouse or mousepad the left-handed arrangement places the keyboard and headset like the right-handed one
(headset.X, headset.Y).ShouldBe((10 + 441 / 2f - 242 / 2f, 144 + 10 + 100f));
}
[Fact]
public void AutoArrangeDevices_AppliesDefaultCategoriesAndStoresTheDevices()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi, TestLayouts.SmallLedStrip);
using RGBSurface surface = AttachToSurface();
Devices.AutoArrangeDevices(false);
Devices.Devices.Single(d => d.DeviceType == RGBDeviceType.Keyboard).Categories.ShouldBe([DeviceCategory.Peripherals]);
Devices.Devices.Single(d => d.DeviceType == RGBDeviceType.LedStripe).Categories.ShouldBe([DeviceCategory.Case]);
string stripId = Devices.Devices.Single(d => d.DeviceType == RGBDeviceType.LedStripe).Identifier;
using var context = Services.CreateDbContext();
context.Devices.Single(d => d.Id == stripId).X.ShouldBe(461);
}
[Fact]
public void AutoArrangeDevices_WithoutASurface_UsesTheOldPositions()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi, TestLayouts.SmallLedStrip);
ArtemisDevice strip = Devices.Devices.Single(d => d.DeviceType == RGBDeviceType.LedStripe);
Devices.AutoArrangeDevices(false);
// Arranging measures where devices are on the RGB.NET surface, which only follows the Artemis position while the
// device is attached to one. Artemis attaches devices when it starts rendering, here the keyboard is still at 0
// on the surface, so the strip ends up 10 closer than with a surface (461)
strip.X.ShouldBe(451);
}
[Fact]
public void SuspendAndResumeDeviceProviders_OnlyAffectProvidersThatSupportIt()
{
// The render service, which the device service pauses while suspending, needs the module service
ResolveModuleService();
SuspendableTestDeviceProvider suspendable = EnableFeature(new SuspendableTestDeviceProvider());
TestDeviceProvider regular = AddDeviceProvider(TestLayouts.KeyboardAnsi);
Devices.SuspendDeviceProviders();
Devices.SuspendedDeviceProviders.ShouldBe([suspendable]);
suspendable.Suspended.ShouldBe(1);
suspendable.IsEnabled.ShouldBeFalse();
regular.IsEnabled.ShouldBeTrue();
// Suspending again doesn't suspend it twice
Devices.SuspendDeviceProviders();
suspendable.Suspended.ShouldBe(1);
Devices.ResumeDeviceProviders();
Devices.SuspendedDeviceProviders.ShouldBeEmpty();
suspendable.IsEnabled.ShouldBeTrue();
Resolve<IRenderService>().IsPaused.ShouldBeTrue();
}
private DeviceEntity StoredDevice()
{
using var context = Services.CreateDbContext();
return context.Devices.Single();
}
/// <summary>
/// Attaches the devices to an RGB.NET surface, like the render service does when Artemis starts rendering
/// </summary>
private RGBSurface AttachToSurface()
{
RGBSurface surface = new();
surface.Attach(Devices.Devices.Select(d => d.RgbDevice));
return surface;
}
}
@@ -0,0 +1,285 @@
using Artemis.Core.Modules;
using Artemis.Core.Services;
using Artemis.Core.Tests.Infrastructure.Plugins.Modules;
using Artemis.Core.Tests.Infrastructure.Services;
namespace Artemis.Core.Tests.Services;
public class ModuleServiceTests : ProfileServiceTestBase
{
private readonly List<Module> _activated = [];
private readonly List<Module> _deactivated = [];
private IModuleService Modules
{
get
{
IModuleService moduleService = ResolveModuleService();
moduleService.ModuleActivated -= OnModuleActivated;
moduleService.ModuleActivated += OnModuleActivated;
moduleService.ModuleDeactivated -= OnModuleDeactivated;
moduleService.ModuleDeactivated += OnModuleDeactivated;
return moduleService;
}
}
[Fact]
public void UpdateModuleActivation_ActivatesModulesWithoutRequirementsQuietly()
{
TestModule module = EnableFeature(new TestModule());
Modules.UpdateModuleActivation();
module.IsActivated.ShouldBeTrue();
module.IsActivatedOverride.ShouldBeFalse();
// Unlike modules with requirements, no ModuleActivated event is raised for them
_activated.ShouldBeEmpty();
}
[Fact]
public void UpdateModuleActivation_FollowsTheRequirements()
{
BooleanActivationRequirement requirement = new();
TestModule module = EnableFeature(new TestModule {Requirements = [requirement]});
Modules.UpdateModuleActivation();
module.IsActivated.ShouldBeFalse();
requirement.ActivationMet = true;
Modules.UpdateModuleActivation();
module.IsActivated.ShouldBeTrue();
_activated.ShouldBe([module]);
requirement.ActivationMet = false;
Modules.UpdateModuleActivation();
module.IsActivated.ShouldBeFalse();
_deactivated.ShouldBe([module]);
}
[Theory]
[InlineData(ActivationRequirementType.Any, true, false, true)]
[InlineData(ActivationRequirementType.Any, false, false, false)]
[InlineData(ActivationRequirementType.All, true, false, false)]
[InlineData(ActivationRequirementType.All, true, true, true)]
public void UpdateModuleActivation_CombinesRequirementsByTheirMode(ActivationRequirementType mode, bool first, bool second, bool activated)
{
TestModule module = EnableFeature(new TestModule
{
Requirements = [new BooleanActivationRequirement {ActivationMet = first}, new BooleanActivationRequirement {ActivationMet = second}],
ActivationRequirementMode = mode
});
Modules.UpdateModuleActivation();
module.IsActivated.ShouldBe(activated);
}
[Fact]
public void ModulesEnabledLater_ArePickedUp()
{
IModuleService modules = Modules;
TestModule module = EnableFeature(new TestModule {Requirements = [new BooleanActivationRequirement {ActivationMet = true}]});
modules.UpdateModuleActivation();
module.IsActivated.ShouldBeTrue();
}
[Fact]
public void DisablingAModule_DeactivatesItAndStopsUpdatingIt()
{
BooleanActivationRequirement requirement = new() {ActivationMet = true};
TestModule module = EnableFeature(new TestModule {Requirements = [requirement]});
Modules.UpdateModuleActivation();
DisableFeature(module);
Modules.UpdateModuleActivation();
Modules.UpdateActiveModules(1);
module.IsActivated.ShouldBeFalse();
module.UpdatedFor.ShouldBe(0);
}
[Fact]
public void UpdateActiveModules_OnlyUpdatesActivatedModules()
{
TestModule active = EnableFeature(new TestModule {Requirements = [new BooleanActivationRequirement {ActivationMet = true}]});
TestModule inactive = EnableFeature(new TestModule {Requirements = [new BooleanActivationRequirement {ActivationMet = false}]});
Modules.UpdateModuleActivation();
Modules.UpdateActiveModules(0.5);
Modules.UpdateActiveModules(0.25);
active.UpdatedFor.ShouldBe(0.75);
inactive.UpdatedFor.ShouldBe(0);
}
[Fact]
public void SetActivationOverride_ActivatesAModuleWhoseRequirementsAreNotMet()
{
TestModule module = EnableFeature(new TestModule {Requirements = [new BooleanActivationRequirement()]});
// The profile editor does this to preview a profile of a module that isn't running
Modules.SetActivationOverride(module);
module.IsActivated.ShouldBeTrue();
module.IsActivatedOverride.ShouldBeTrue();
_activated.ShouldBeEmpty();
// The regular activation update leaves it alone
Modules.UpdateModuleActivation();
module.IsActivated.ShouldBeTrue();
Modules.SetActivationOverride(null);
module.IsActivated.ShouldBeFalse();
}
[Fact]
public void SetActivationOverride_ModulesActivatedByAnOverride_DoNotUpdate()
{
TestModule module = EnableFeature(new TestModule {Requirements = [new BooleanActivationRequirement()]});
Modules.SetActivationOverride(module);
Modules.UpdateActiveModules(1);
module.IsUpdateAllowed.ShouldBeFalse();
module.UpdatedFor.ShouldBe(0);
}
[Fact]
public void SetActivationOverride_OfAModuleWhoseRequirementsAreMet_IsARegularActivation()
{
TestModule module = EnableFeature(new TestModule {Requirements = [new BooleanActivationRequirement {ActivationMet = true}]});
Modules.SetActivationOverride(module);
module.IsActivated.ShouldBeTrue();
module.IsActivatedOverride.ShouldBeFalse();
// Clearing the override leaves it active since it should be active anyway
Modules.SetActivationOverride(null);
module.IsActivated.ShouldBeTrue();
}
[Fact]
public void SetActivationOverride_ToAnotherModule_DeactivatesThePreviousOne()
{
TestModule first = EnableFeature(new TestModule {Requirements = [new BooleanActivationRequirement()]});
TestModule second = EnableFeature(new TestModule {Requirements = [new BooleanActivationRequirement()]});
Modules.SetActivationOverride(first);
Modules.SetActivationOverride(second);
first.IsActivated.ShouldBeFalse();
second.IsActivated.ShouldBeTrue();
second.IsActivatedOverride.ShouldBeTrue();
}
[Fact]
public async Task DefaultProfiles_OfModulesEnabledLater_AreImported()
{
string profilePath = await ExportSavedProfile("Default profile");
IModuleService _ = Modules;
EnableFeature(new DefaultProfileTestModule(DefaultCategoryName.Games, profilePath));
ProfileConfiguration imported = await WaitForProfile("Games", "Default profile");
imported.Entity.Profile.IsFreshImport.ShouldBeTrue();
}
[Fact]
public async Task DefaultProfiles_OfModulesEnabledBeforeTheServiceStarts_AreImported()
{
string profilePath = await ExportSavedProfile("Default profile");
EnableFeature(new DefaultProfileTestModule(DefaultCategoryName.Applications, profilePath));
IModuleService _ = Modules;
await WaitForProfile("Applications", "Default profile");
}
[Fact]
public async Task DefaultProfiles_RecreateTheirCategoryWhenItWasDeleted()
{
string profilePath = await ExportSavedProfile("Default profile");
ProfileService.DeleteProfileCategory(ProfileService.ProfileCategories.Single(c => c.Name == "Games"));
IModuleService _ = Modules;
EnableFeature(new DefaultProfileTestModule(DefaultCategoryName.Games, profilePath));
await WaitForProfile("Games", "Default profile");
}
[Fact]
public async Task DefaultProfiles_AreNotImportedTwice()
{
string profilePath = await ExportSavedProfile("Default profile");
IModuleService _ = Modules;
DefaultProfileTestModule module = EnableFeature(new DefaultProfileTestModule(DefaultCategoryName.Games, profilePath));
await WaitForProfile("Games", "Default profile");
// Enabling the module again imports the profile again, which finds the existing profile
DisableFeature(module);
PluginManagement.EnablePluginFeature(module, false, false);
await Task.Delay(500, TestContext.Current.CancellationToken);
ProfileService.ProfileCategories.Single(c => c.Name == "Games").ProfileConfigurations.Count.ShouldBe(1);
}
[Fact]
public void DefaultProfiles_ThatDoNotExist_FailWhenTheModuleIsEnabled()
{
string missing = Path.Combine(Services.DataFolder, "missing.zip");
DefaultProfileTestModule module = new(DefaultCategoryName.Games, missing);
// Features are enabled on a background task, so the error arrives wrapped
AggregateException exception = Should.Throw<AggregateException>(() => EnableFeature(module));
exception.InnerException.ShouldBeOfType<ArtemisPluginFeatureException>();
module.IsEnabled.ShouldBeFalse();
}
/// <summary>
/// Exports a profile into a file in the temporary data folder and removes the profile, the way a module ships one
/// </summary>
private async Task<string> ExportSavedProfile(string name)
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, name, "TestTube");
// Saving the profile stamps it with the latest migration version, see ProfileImportExportTests
ProfileService.SaveProfile(ProfileService.ActivateProfile(configuration), true);
string path = Path.Combine(Services.DataFolder, $"{name}.zip");
await using (Stream export = await ProfileService.ExportProfile(configuration))
await using (FileStream file = File.Create(path))
{
await export.CopyToAsync(file);
}
ProfileService.RemoveProfileConfiguration(configuration);
return path;
}
/// <summary>
/// Default profiles are imported in the background, waits for the profile to show up
/// </summary>
private async Task<ProfileConfiguration> WaitForProfile(string categoryName, string profileName)
{
for (int i = 0; i < 1000; i++)
{
ProfileConfiguration? profile = ProfileService.ProfileCategories.FirstOrDefault(c => c.Name == categoryName)?.ProfileConfigurations.FirstOrDefault(p => p.Name == profileName);
if (profile != null)
return profile;
await Task.Delay(5, TestContext.Current.CancellationToken);
}
throw new TimeoutException($"Profile {profileName} was not imported into {categoryName}");
}
private void OnModuleActivated(object? sender, ModuleEventArgs e)
{
_activated.Add(e.Module);
}
private void OnModuleDeactivated(object? sender, ModuleEventArgs e)
{
_deactivated.Add(e.Module);
}
}
@@ -0,0 +1,152 @@
using Artemis.Core.Internal;
using Artemis.Core.Services;
using Artemis.Core.Tests.Infrastructure.Plugins.Nodes;
using Artemis.Core.Tests.Infrastructure.Services;
using Artemis.Core.Tests.Infrastructure.VisualScripting;
using SkiaSharp;
namespace Artemis.Core.Tests.Services;
public class NodeServiceTests : ServiceTestBase
{
private INodeService Nodes => Resolve<INodeService>();
[Fact]
public void AvailableNodes_ContainsTheNodesOfEnabledProviders()
{
TestNodeProvider provider = EnableFeature(new TestNodeProvider());
List<NodeData> nodes = Nodes.AvailableNodes.Where(n => n.Provider == provider).ToList();
nodes.Select(n => n.Type).ShouldBe([typeof(TestBooleanNode), typeof(TestNumericNode)], ignoreOrder: true);
NodeData booleanNode = nodes.Single(n => n.Type == typeof(TestBooleanNode));
booleanNode.Name.ShouldBe("Test boolean");
booleanNode.Category.ShouldBe("Tests");
booleanNode.OutputType.ShouldBe(typeof(bool));
}
[Fact]
public void AvailableNodes_NoLongerContainsTheNodesOfADisabledProvider()
{
TestNodeProvider provider = EnableFeature(new TestNodeProvider());
DisableFeature(provider);
Nodes.AvailableNodes.ShouldNotContain(n => n.Provider == provider);
}
[Fact]
public void GetRegisteredTypes_ContainsTypesThatWereGivenAColor()
{
EnableFeature(new TestNodeProvider());
EnableFeature(new TypeColorTestNodeProvider());
// Only types with a registered color count, the pin types of registered nodes don't
List<Type> types = Nodes.GetRegisteredTypes();
types.ShouldContain(typeof(Numeric));
types.ShouldContain(typeof(string));
types.ShouldNotContain(typeof(bool));
}
[Fact]
public void GetTypeColorRegistration_ForARegisteredType_ReturnsItsColor()
{
TypeColorTestNodeProvider provider = EnableFeature(new TypeColorTestNodeProvider());
TypeColorRegistration registration = Nodes.GetTypeColorRegistration(typeof(string));
registration.Type.ShouldBe(typeof(string));
registration.Color.ShouldBe(TypeColorTestNodeProvider.TextColor);
registration.PluginFeature.ShouldBeSameAs(provider);
}
[Fact]
public void GetTypeColorRegistration_ForATypeThatConvertsToARegisteredType_StillMakesUpAColor()
{
EnableFeature(new TypeColorTestNodeProvider());
// Numeric can be created from an int, but only conversions declared on the requested type itself are considered
TypeColorRegistration registration = Nodes.GetTypeColorRegistration(typeof(int));
registration.Type.ShouldBe(typeof(int));
registration.Color.ShouldNotBe(TypeColorTestNodeProvider.NumberColor);
registration.PluginFeature.ShouldBeSameAs(Constants.CorePluginFeature);
}
[Fact]
public void GetTypeColorRegistration_NoLongerUsesTheColorsOfADisabledProvider()
{
TypeColorTestNodeProvider provider = EnableFeature(new TypeColorTestNodeProvider());
DisableFeature(provider);
Nodes.GetRegisteredTypes().ShouldNotContain(typeof(string));
Nodes.GetTypeColorRegistration(typeof(string)).PluginFeature.ShouldBeSameAs(Constants.CorePluginFeature);
}
[Fact]
public void GetTypeColorRegistration_ForObject_IsWhite()
{
TypeColorRegistration registration = Nodes.GetTypeColorRegistration(typeof(object));
registration.Color.ShouldBe(SKColors.White);
registration.PluginFeature.ShouldBeSameAs(Constants.CorePluginFeature);
}
[Fact]
public void GetTypeColorRegistration_ForAnUnregisteredType_MakesUpTheSameColorEveryTime()
{
TypeColorRegistration first = Nodes.GetTypeColorRegistration(typeof(NodeServiceTests));
TypeColorRegistration second = Nodes.GetTypeColorRegistration(typeof(NodeServiceTests));
first.ShouldNotBeSameAs(second);
first.Type.ShouldBe(typeof(NodeServiceTests));
first.Color.ShouldBe(second.Color);
first.PluginFeature.ShouldBeSameAs(Constants.CorePluginFeature);
}
[Fact]
public void ExportScript_ThenImportScript_RestoresTheScript()
{
TestNodeProvider provider = EnableFeature(new TestNodeProvider());
NodeScript<bool> script = new("Test", "A test script");
TestBooleanNode node = provider.AddNode<TestBooleanNode>(script);
node.Value = true;
node.X = 12;
script.Nodes.OfType<ExitNode<bool>>().Single().Input.ConnectTo(node.Output);
string json = Nodes.ExportScript(script);
NodeScript<bool> target = new("Target", "Another script");
Nodes.ImportScript(json, target);
target.Run();
TestBooleanNode imported = target.Nodes.OfType<TestBooleanNode>().Single();
imported.Id.ShouldBe(node.Id);
imported.X.ShouldBe(12);
target.ExitNodeConnected.ShouldBeTrue();
target.Result.ShouldBeTrue();
}
[Fact]
public void ImportScript_ReplacesWhatWasInTheTarget()
{
TestNodeProvider provider = EnableFeature(new TestNodeProvider());
NodeScript<bool> source = new("Source", "Script with a node");
provider.AddNode<TestNumericNode>(source);
NodeScript<bool> target = new("Target", "Script with another node");
provider.AddNode<TestBooleanNode>(target);
Nodes.ImportScript(Nodes.ExportScript(source), target);
target.Nodes.OfType<TestBooleanNode>().ShouldBeEmpty();
target.Nodes.OfType<TestNumericNode>().Count().ShouldBe(1);
}
[Fact]
public void ImportScript_WithNull_Throws()
{
NodeScript<bool> target = new("Target", "A script");
Should.Throw<ArtemisCoreException>(() => Nodes.ImportScript("null", target));
}
}
@@ -0,0 +1,462 @@
using Artemis.Core.Modules;
using Artemis.Core.Services;
using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Plugins.Modules;
using Artemis.Core.Tests.Infrastructure.Plugins.Nodes;
using Artemis.Core.Tests.Infrastructure.Services;
using Artemis.Core.Tests.Infrastructure.VisualScripting;
using RGB.NET.Core;
using SkiaSharp;
namespace Artemis.Core.Tests.Services;
/// <summary>
/// Activating, deactivating, updating and rendering profiles through the profile service
/// </summary>
public class ProfileActivationTests : ProfileServiceTestBase
{
[Fact]
public void ActivateProfile_LoadsTheProfileOntoTheEnabledDevices()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi);
ArtemisDevice keyboard = Resolve<IDeviceService>().Devices.Single();
TestLayerBrushProvider brushes = EnableFeature(new TestLayerBrushProvider());
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
Profile profile = ProfileService.ActivateProfile(configuration);
AddLayer<SolidColorTestBrush>(profile, brushes, [keyboard.GetLed(LedId.Keyboard_A, false)!]);
ProfileService.SaveProfile(profile, true);
ProfileService.DeactivateProfile(configuration);
List<ProfileConfiguration> activated = [];
ProfileService.ProfileActivated += (_, e) => activated.Add(e.ProfileConfiguration);
Profile reactivated = ProfileService.ActivateProfile(configuration);
reactivated.ShouldNotBeSameAs(profile);
configuration.Profile.ShouldBeSameAs(reactivated);
activated.ShouldBe([configuration]);
Layer layer = reactivated.GetAllLayers().Single();
layer.Leds.Select(l => l.RgbLed.Id).ShouldBe([LedId.Keyboard_A]);
layer.LayerBrush.ShouldBeOfType<SolidColorTestBrush>();
}
[Fact]
public void ActivateProfile_WhenAlreadyActive_ReturnsTheActiveProfile()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
Profile profile = ProfileService.ActivateProfile(configuration);
int activated = 0;
ProfileService.ProfileActivated += (_, _) => activated++;
ProfileService.ActivateProfile(configuration).ShouldBeSameAs(profile);
activated.ShouldBe(0);
}
[Fact]
public void DeactivateProfile_DisposesTheProfile()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
Profile profile = ProfileService.ActivateProfile(configuration);
List<ProfileConfiguration> deactivated = [];
ProfileService.ProfileDeactivated += (_, e) => deactivated.Add(e.ProfileConfiguration);
ProfileService.DeactivateProfile(configuration);
ProfileService.DeactivateProfile(configuration);
configuration.Profile.ShouldBeNull();
profile.Disposed.ShouldBeTrue();
deactivated.ShouldBe([configuration]);
}
[Fact]
public void DeactivateProfile_OfTheFocusProfile_Throws()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
ProfileService.ActivateProfile(configuration);
ProfileService.FocusProfile = configuration;
Should.Throw<ArtemisCoreException>(() => ProfileService.DeactivateProfile(configuration));
configuration.Profile.ShouldNotBeNull();
}
[Fact]
public void UpdateProfiles_ActivatesProfilesThatShouldBeActive()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
ProfileService.UpdateProfiles(0.1);
// Without an activation condition the condition counts as met
configuration.ActivationConditionMet.ShouldBeTrue();
configuration.Profile.ShouldNotBeNull();
}
[Fact]
public void UpdateProfiles_DeactivatesSuspendedProfiles()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
ProfileService.UpdateProfiles(0.1);
configuration.IsSuspended = true;
ProfileService.UpdateProfiles(0.1);
configuration.Profile.ShouldBeNull();
}
[Fact]
public void UpdateProfiles_DoesNotActivateProfilesInASuspendedCategory()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
General.IsSuspended = true;
ProfileService.UpdateProfiles(0.1);
configuration.Profile.ShouldBeNull();
}
[Fact]
public void UpdateProfiles_FollowsTheActivationCondition()
{
TestNodeProvider nodes = EnableFeature(new TestNodeProvider());
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
TestBooleanNode condition = UseActivationCondition(configuration, nodes, false);
ProfileService.UpdateProfiles(0.1);
configuration.ActivationConditionMet.ShouldBeFalse();
configuration.Profile.ShouldBeNull();
condition.Value = true;
ProfileService.UpdateProfiles(0.1);
configuration.ActivationConditionMet.ShouldBeTrue();
configuration.Profile.ShouldNotBeNull();
condition.Value = false;
ProfileService.UpdateProfiles(0.1);
configuration.Profile.ShouldBeNull();
}
[Fact]
public void UpdateProfiles_WithFadeInAndOut_KeepsTheProfileUntilItFadedOut()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.FadeInAndOut = true;
ProfileService.UpdateProfiles(0.5);
Profile profile = configuration.Profile!;
profile.Opacity.ShouldBe(0.5);
configuration.IsSuspended = true;
ProfileService.UpdateProfiles(0.25);
// Fading out at 1 opacity per second
configuration.Profile.ShouldBeSameAs(profile);
profile.ShouldDisplay.ShouldBeFalse();
profile.Opacity.ShouldBe(0.25);
ProfileService.UpdateProfiles(0.25);
configuration.Profile.ShouldBeSameAs(profile);
profile.Opacity.ShouldBe(0);
ProfileService.UpdateProfiles(0.25);
configuration.Profile.ShouldBeNull();
}
[Fact]
public void UpdateProfiles_WhileFadingOut_CanFadeBackIn()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.FadeInAndOut = true;
ProfileService.UpdateProfiles(1);
Profile profile = configuration.Profile!;
configuration.IsSuspended = true;
ProfileService.UpdateProfiles(0.5);
configuration.IsSuspended = false;
ProfileService.UpdateProfiles(0.25);
configuration.Profile.ShouldBeSameAs(profile);
profile.ShouldDisplay.ShouldBeTrue();
profile.Opacity.ShouldBe(0.75);
}
[Fact]
public void UpdateProfiles_WithAModule_OnlyActivatesWhileTheModuleIsActivated()
{
IModuleService moduleService = ResolveModuleService();
BooleanActivationRequirement requirement = new();
TestModule module = EnableFeature(new TestModule {Requirements = [requirement]});
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.Module = module;
moduleService.UpdateModuleActivation();
ProfileService.UpdateProfiles(0.1);
configuration.Profile.ShouldBeNull();
requirement.ActivationMet = true;
moduleService.UpdateModuleActivation();
ProfileService.UpdateProfiles(0.1);
configuration.Profile.ShouldNotBeNull();
}
[Fact]
public void AProfileOfAModuleThatIsDisabled_IsMissingItsModule()
{
IModuleService moduleService = ResolveModuleService();
TestModule module = EnableFeature(new TestModule());
moduleService.UpdateModuleActivation();
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.Module = module;
ProfileService.SaveProfileCategory(General);
ProfileService.UpdateProfiles(0.1);
configuration.Profile.ShouldNotBeNull();
DisableFeature(module);
ProfileService.UpdateProfiles(0.1);
configuration.Module.ShouldBeNull();
configuration.IsMissingModule.ShouldBeTrue();
configuration.Profile.ShouldBeNull();
// The module is remembered while it is missing
ProfileService.SaveProfileCategory(General);
configuration.Entity.ProfileConfiguration.ModuleId.ShouldBe(module.Id);
PluginManagement.EnablePluginFeature(module, false, false);
configuration.Module.ShouldBeSameAs(module);
configuration.IsMissingModule.ShouldBeFalse();
}
[Fact]
public void Restarting_WithoutTheModuleOfAProfile_LeavesItMissing()
{
TestModule module = EnableFeature(new TestModule());
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.Module = module;
ProfileService.SaveProfileCategory(General);
DisableFeature(module);
ProfileConfiguration reloaded = CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Single();
reloaded.IsMissingModule.ShouldBeTrue();
reloaded.ShouldBeActive(false).ShouldBeFalse();
}
[Fact]
public void UpdateProfiles_WithAFocusProfile_OnlyUpdatesTheFocusProfile()
{
ProfileConfiguration focus = ProfileService.CreateProfileConfiguration(General, "Focus", "TestTube");
ProfileConfiguration other = ProfileService.CreateProfileConfiguration(General, "Other", "TestTube");
focus.FadeInAndOut = true;
Profile focusProfile = ProfileService.ActivateProfile(focus);
ProfileService.FocusProfile = focus;
ProfileService.UpdateProfiles(0.5);
other.Profile.ShouldBeNull();
focusProfile.Opacity.ShouldBe(0);
ProfileService.UpdateFocusProfile = true;
ProfileService.UpdateProfiles(0.5);
focusProfile.Opacity.ShouldBe(0.5);
other.Profile.ShouldBeNull();
}
[Fact]
public void RenderProfiles_DrawsActiveProfiles()
{
(ProfileConfiguration configuration, ArtemisLed led) = CreateProfileWithARedLayer();
ProfileService.UpdateProfiles(0.1);
ColorAt(led).ShouldBe(SKColors.Red);
configuration.IsSuspended = true;
ColorAt(led).Alpha.ShouldBe((byte) 0);
}
[Fact]
public void RenderProfiles_WithAFocusProfile_OnlyDrawsTheFocusProfile()
{
(ProfileConfiguration configuration, ArtemisLed led) = CreateProfileWithARedLayer();
ProfileConfiguration focus = ProfileService.CreateProfileConfiguration(General, "Focus", "TestTube");
ProfileService.UpdateProfiles(0.1);
ProfileService.ActivateProfile(focus);
ProfileService.FocusProfile = focus;
ColorAt(led).Alpha.ShouldBe((byte) 0);
ProfileService.FocusProfile = configuration;
ColorAt(led).ShouldBe(SKColors.Red);
}
[Fact]
public void ProfileRenderingDisabled_IsOnlyAFlag()
{
(_, ArtemisLed led) = CreateProfileWithARedLayer();
ProfileService.UpdateProfiles(0.1);
ProfileService.ProfileRenderingDisabled = true;
// The renderer checks the flag before calling RenderProfiles, the profile service itself ignores it
ColorAt(led).ShouldBe(SKColors.Red);
}
[Fact]
public void ChangingTheDevices_UpdatesTheLedsOfActiveProfiles()
{
TestDeviceProvider deviceProvider = AddDeviceProvider(TestLayouts.KeyboardAnsi);
IDeviceService deviceService = Resolve<IDeviceService>();
TestLayerBrushProvider brushes = EnableFeature(new TestLayerBrushProvider());
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
Profile profile = ProfileService.ActivateProfile(configuration);
Layer layer = AddLayer<SolidColorTestBrush>(profile, brushes, deviceService.Devices.Single().Leds.Take(3));
layer.Save();
deviceService.RemoveDeviceProvider(deviceProvider);
layer.Leds.ShouldBeEmpty();
deviceService.AddDeviceProvider(deviceProvider);
layer.Leds.Count.ShouldBe(3);
layer.Leds.First().Device.ShouldBeSameAs(deviceService.Devices.Single());
}
[Fact]
public void SaveProfile_StoresTheProfileAndReloadsOtherActiveInstances()
{
TestLayerBrushProvider brushes = EnableFeature(new TestLayerBrushProvider());
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
Profile active = ProfileService.ActivateProfile(configuration);
// Like the editor, work on a separate instance of the same profile
ProfileConfiguration clone = ProfileService.CloneProfileConfiguration(configuration);
Profile edited = ProfileService.ActivateProfile(clone);
AddLayer<SolidColorTestBrush>(edited, brushes, [], "Added in the editor");
ProfileService.SaveProfile(edited, true);
// The active instance is replaced by a fresh one, loaded from what was just saved
active.Disposed.ShouldBeTrue();
configuration.Profile.ShouldNotBeSameAs(active);
configuration.Profile!.GetAllLayers().Single().Name.ShouldBe("Added in the editor");
CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Single()
.Entity.Profile.Layers.Single().Name.ShouldBe("Added in the editor");
}
[Fact]
public void Hotkeys_InToggleMode_SuspendAndResumeTheProfile()
{
TestInputProvider input = new();
Resolve<IInputService>().AddInputProvider(input);
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.HotkeyMode = ProfileConfigurationHotkeyMode.Toggle;
configuration.EnableHotkey = new Hotkey(KeyboardKey.F1, null);
// Key presses are handled on the next update
input.Press(KeyboardKey.F1);
configuration.IsSuspended.ShouldBeFalse();
ProfileService.UpdateProfiles(0.1);
configuration.IsSuspended.ShouldBeTrue();
configuration.Profile.ShouldBeNull();
input.Press(KeyboardKey.F1);
ProfileService.UpdateProfiles(0.1);
configuration.IsSuspended.ShouldBeFalse();
configuration.Profile.ShouldNotBeNull();
}
[Fact]
public void Hotkeys_InEnableDisableMode_UseSeparateKeys()
{
TestInputProvider input = new();
Resolve<IInputService>().AddInputProvider(input);
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.HotkeyMode = ProfileConfigurationHotkeyMode.EnableDisable;
configuration.EnableHotkey = new Hotkey(KeyboardKey.F1, null);
configuration.DisableHotkey = new Hotkey(KeyboardKey.F2, null);
input.Press(KeyboardKey.F1);
ProfileService.UpdateProfiles(0.1);
configuration.IsSuspended.ShouldBeFalse();
input.Press(KeyboardKey.F2);
ProfileService.UpdateProfiles(0.1);
configuration.IsSuspended.ShouldBeTrue();
input.Press(KeyboardKey.F2);
ProfileService.UpdateProfiles(0.1);
configuration.IsSuspended.ShouldBeTrue();
input.Press(KeyboardKey.F1);
ProfileService.UpdateProfiles(0.1);
configuration.IsSuspended.ShouldBeFalse();
}
[Fact]
public void Hotkeys_AreIgnoredWithoutAHotkeyMode()
{
TestInputProvider input = new();
Resolve<IInputService>().AddInputProvider(input);
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.EnableHotkey = new Hotkey(KeyboardKey.F1, null);
input.Press(KeyboardKey.F1);
ProfileService.UpdateProfiles(0.1);
configuration.IsSuspended.ShouldBeFalse();
}
[Fact]
public void Hotkeys_WithModifiers_OnlyMatchWithThoseModifiers()
{
TestInputProvider input = new();
Resolve<IInputService>().AddInputProvider(input);
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.HotkeyMode = ProfileConfigurationHotkeyMode.Toggle;
configuration.EnableHotkey = new Hotkey(KeyboardKey.F1, KeyboardModifierKey.Shift);
input.Press(KeyboardKey.F1);
ProfileService.UpdateProfiles(0.1);
configuration.IsSuspended.ShouldBeFalse();
}
[Fact]
public void Hotkeys_StoreTheSuspension()
{
TestInputProvider input = new();
Resolve<IInputService>().AddInputProvider(input);
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.HotkeyMode = ProfileConfigurationHotkeyMode.Toggle;
configuration.EnableHotkey = new Hotkey(KeyboardKey.F1, null);
input.Press(KeyboardKey.F1);
ProfileService.UpdateProfiles(0.1);
CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Single().IsSuspended.ShouldBeTrue();
}
private (ProfileConfiguration Configuration, ArtemisLed Led) CreateProfileWithARedLayer()
{
AddDeviceProvider(TestLayouts.KeyboardAnsi);
ArtemisLed led = Resolve<IDeviceService>().Devices.Single().GetLed(LedId.Keyboard_A, false)!;
TestLayerBrushProvider brushes = EnableFeature(new TestLayerBrushProvider());
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Red", "TestTube");
Profile profile = ProfileService.ActivateProfile(configuration);
// The solid test brush is red by default
AddLayer<SolidColorTestBrush>(profile, brushes, [led]);
profile.Reset();
return (configuration, led);
}
/// <summary>
/// Renders the profiles onto a canvas covering the whole surface and returns the color in the middle of the LED
/// </summary>
private SKColor ColorAt(ArtemisLed led)
{
using SKBitmap bitmap = new(2000, 1000);
using (SKCanvas canvas = new(bitmap))
{
canvas.Clear(SKColors.Transparent);
ProfileService.RenderProfiles(canvas);
}
SKRect rectangle = led.AbsoluteRectangle;
return bitmap.GetPixel((int) rectangle.MidX, (int) rectangle.MidY);
}
}
@@ -0,0 +1,288 @@
using Artemis.Core.Services;
using Artemis.Core.Tests.Infrastructure.Services;
using Artemis.Storage.Entities.Profile;
namespace Artemis.Core.Tests.Services;
/// <summary>
/// Profile categories and the profile configurations inside them, as managed by the profile service
/// </summary>
public class ProfileCategoryTests : ProfileServiceTestBase
{
[Fact]
public void ANewDatabase_GetsTheDefaultCategories()
{
ProfileService.ProfileCategories.Select(c => (c.Name, c.Order)).ShouldBe([("Games", 1), ("Applications", 2), ("General", 3)]);
GetStoredCategories().Select(c => c.Name).ShouldBe(["Games", "Applications", "General"]);
}
[Fact]
public void Restarting_LoadsTheStoredCategoriesInOrder()
{
ProfileCategory games = ProfileService.ProfileCategories.Single(c => c.Name == "Games");
games.Order = 10;
ProfileService.SaveProfileCategory(games);
IProfileService restarted = CreateNewProfileService();
// The default categories are not created again
restarted.ProfileCategories.Select(c => c.Name).ShouldBe(["Applications", "General", "Games"]);
}
[Fact]
public void Restarting_WithoutAnyCategories_CreatesTheDefaultCategoriesAgain()
{
foreach (ProfileCategory category in ProfileService.ProfileCategories.ToList())
ProfileService.DeleteProfileCategory(category);
IProfileService restarted = CreateNewProfileService();
restarted.ProfileCategories.Select(c => c.Name).ShouldBe(["Games", "Applications", "General"]);
}
[Fact]
public void CreateProfileCategory_AddsItAtTheBottom()
{
List<ProfileCategory> added = [];
ProfileService.ProfileCategoryAdded += (_, e) => added.Add(e.ProfileCategory);
ProfileCategory category = ProfileService.CreateProfileCategory("Tests");
category.Order.ShouldBe(4);
ProfileService.ProfileCategories.Last().ShouldBeSameAs(category);
added.ShouldBe([category]);
GetStoredCategories().Last().Name.ShouldBe("Tests");
}
[Fact]
public void CreateProfileCategory_AtTheTop_MovesTheOtherCategoriesDown()
{
ProfileCategory category = ProfileService.CreateProfileCategory("Tests", true);
category.Order.ShouldBe(1);
ProfileService.ProfileCategories.Where(c => c != category).Select(c => c.Order).ShouldBe([2, 3, 4]);
GetStoredCategories().Select(c => c.Name).ShouldBe(["Tests", "Games", "Applications", "General"]);
}
[Fact]
public void CreateProfileCategory_AtTheTop_IsListedLastUntilTheCategoriesAreSortedAgain()
{
ProfileCategory category = ProfileService.CreateProfileCategory("Tests", true);
// Pinned as-is: the category is created with order 1 but appended to the end of ProfileCategories. Profiles are
// updated and rendered in list order (the first category ends up on top), so until something saves a category,
// which sorts the list, this category's profiles are drawn underneath the others rather than on top
ProfileService.ProfileCategories.Last().ShouldBeSameAs(category);
ProfileService.SaveProfileCategory(General);
ProfileService.ProfileCategories.First().ShouldBeSameAs(category);
}
[Fact]
public void SaveProfileCategory_StoresItsChanges()
{
ProfileCategory category = ProfileService.CreateProfileCategory("Tests");
category.Name = "Renamed";
category.IsCollapsed = true;
category.IsSuspended = true;
ProfileService.SaveProfileCategory(category);
ProfileCategory reloaded = CreateNewProfileService().ProfileCategories.Single(c => c.EntityId == category.EntityId);
reloaded.Name.ShouldBe("Renamed");
reloaded.IsCollapsed.ShouldBeTrue();
reloaded.IsSuspended.ShouldBeTrue();
}
[Fact]
public void ChangingTheOrder_LikeTheSidebarDoes_ReordersTheCategories()
{
// Moving General up one place, the sidebar gives every category its new order and saves the ones that changed
List<ProfileCategory> categories = ProfileService.ProfileCategories.ToList();
categories.Remove(General);
categories.Insert(1, General);
for (int i = 0; i < categories.Count; i++)
{
if (categories[i].Order == i + 1)
continue;
categories[i].Order = i + 1;
ProfileService.SaveProfileCategory(categories[i]);
}
ProfileService.ProfileCategories.Select(c => c.Name).ShouldBe(["Games", "General", "Applications"]);
CreateNewProfileService().ProfileCategories.Select(c => c.Name).ShouldBe(["Games", "General", "Applications"]);
}
[Fact]
public void DeleteProfileCategory_RemovesTheCategoryAndItsProfiles()
{
ProfileCategory category = ProfileService.CreateProfileCategory("Tests");
ProfileConfiguration first = ProfileService.CreateProfileConfiguration(category, "First", "TestTube");
ProfileConfiguration second = ProfileService.CreateProfileConfiguration(category, "Second", "TestTube");
ProfileService.ActivateProfile(first);
List<ProfileConfiguration> removedProfiles = [];
List<ProfileCategory> removedCategories = [];
ProfileService.ProfileRemoved += (_, e) => removedProfiles.Add(e.ProfileConfiguration);
ProfileService.ProfileCategoryRemoved += (_, e) => removedCategories.Add(e.ProfileCategory);
ProfileService.DeleteProfileCategory(category);
ProfileService.ProfileCategories.ShouldNotContain(category);
removedProfiles.ShouldBe([second, first], ignoreOrder: true);
removedCategories.ShouldBe([category]);
first.Profile.ShouldBeNull();
GetStoredCategories().ShouldNotContain(c => c.Name == "Tests");
GetStoredProfiles().ShouldBeEmpty();
}
[Fact]
public void CreateProfileConfiguration_AddsItToTheTopOfTheCategory()
{
List<ProfileConfiguration> added = [];
ProfileService.ProfileAdded += (_, e) => added.Add(e.ProfileConfiguration);
ProfileConfiguration first = ProfileService.CreateProfileConfiguration(General, "First", "TestTube");
ProfileConfiguration second = ProfileService.CreateProfileConfiguration(General, "Second", "Abacus");
General.ProfileConfigurations.ShouldBe([second, first]);
second.Order.ShouldBe(0);
first.Order.ShouldBe(1);
added.ShouldBe([first, second]);
}
[Fact]
public void CreateProfileConfiguration_IsStoredButNotActivated()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.Profile.ShouldBeNull();
configuration.Category.ShouldBeSameAs(General);
configuration.ProfileId.ShouldNotBe(Guid.Empty);
configuration.Icon.IconName.ShouldBe("TestTube");
ProfileConfiguration reloaded = CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Single();
reloaded.Name.ShouldBe("Test profile");
reloaded.ProfileId.ShouldBe(configuration.ProfileId);
reloaded.Icon.IconName.ShouldBe("TestTube");
}
[Fact]
public void SaveProfileCategory_StoresTheSettingsOfItsProfiles()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.Name = "Renamed";
configuration.IsSuspended = true;
configuration.FadeInAndOut = true;
configuration.ActivationBehaviour = ActivationBehaviour.DisableOthersBelow;
configuration.HotkeyMode = ProfileConfigurationHotkeyMode.EnableDisable;
configuration.EnableHotkey = new Hotkey(KeyboardKey.F1, KeyboardModifierKey.Shift);
configuration.DisableHotkey = new Hotkey(KeyboardKey.F2, null);
configuration.Icon.SetIconByStream(new MemoryStream([1, 2, 3]));
ProfileService.SaveProfileCategory(General);
ProfileConfiguration reloaded = CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Single();
reloaded.Name.ShouldBe("Renamed");
reloaded.IsSuspended.ShouldBeTrue();
reloaded.FadeInAndOut.ShouldBeTrue();
reloaded.ActivationBehaviour.ShouldBe(ActivationBehaviour.DisableOthersBelow);
reloaded.HotkeyMode.ShouldBe(ProfileConfigurationHotkeyMode.EnableDisable);
reloaded.EnableHotkey!.Key.ShouldBe(KeyboardKey.F1);
reloaded.EnableHotkey.Modifiers.ShouldBe(KeyboardModifierKey.Shift);
reloaded.DisableHotkey!.Key.ShouldBe(KeyboardKey.F2);
reloaded.DisableHotkey.Modifiers.ShouldBeNull();
reloaded.Icon.IconType.ShouldBe(ProfileConfigurationIconType.BitmapImage);
reloaded.Icon.IconBytes.ShouldBe([1, 2, 3]);
}
[Fact]
public void AddProfileConfiguration_WithATarget_PutsItInFrontOfTheTarget()
{
ProfileConfiguration first = ProfileService.CreateProfileConfiguration(General, "First", "TestTube");
ProfileConfiguration second = ProfileService.CreateProfileConfiguration(General, "Second", "TestTube");
ProfileConfiguration third = ProfileService.CreateProfileConfiguration(General, "Third", "TestTube");
// Listed as third, second, first
General.AddProfileConfiguration(first, second);
ProfileService.SaveProfileCategory(General);
General.ProfileConfigurations.ShouldBe([third, first, second]);
General.ProfileConfigurations.Select(c => c.Order).ShouldBe([0, 1, 2]);
CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Select(c => c.Name).ShouldBe(["Third", "First", "Second"]);
}
[Fact]
public void MovingAProfileToAnotherCategory_LikeTheSidebarDoes_StoresTheMove()
{
ProfileCategory games = ProfileService.ProfileCategories.Single(c => c.Name == "Games");
ProfileConfiguration staying = ProfileService.CreateProfileConfiguration(General, "Staying", "TestTube");
ProfileConfiguration moving = ProfileService.CreateProfileConfiguration(General, "Moving", "TestTube");
List<ProfileConfiguration> removedFromGeneral = [];
General.ProfileConfigurationRemoved += (_, e) => removedFromGeneral.Add(e.ProfileConfiguration);
// The sidebar adds it to the new category and saves both categories
games.AddProfileConfiguration(moving, null);
ProfileService.SaveProfileCategory(games);
ProfileService.SaveProfileCategory(General);
moving.Category.ShouldBeSameAs(games);
General.ProfileConfigurations.ShouldBe([staying]);
staying.Order.ShouldBe(0);
removedFromGeneral.ShouldBe([moving]);
IProfileService restarted = CreateNewProfileService();
restarted.ProfileCategories.Single(c => c.Name == "Games").ProfileConfigurations.Select(c => c.Name).ShouldBe(["Moving"]);
restarted.ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Select(c => c.Name).ShouldBe(["Staying"]);
}
[Fact]
public void RemoveProfileConfiguration_DeactivatesAndRemovesIt()
{
ProfileConfiguration first = ProfileService.CreateProfileConfiguration(General, "First", "TestTube");
ProfileConfiguration second = ProfileService.CreateProfileConfiguration(General, "Second", "TestTube");
ProfileService.ActivateProfile(second);
List<ProfileConfiguration> removed = [];
ProfileService.ProfileRemoved += (_, e) => removed.Add(e.ProfileConfiguration);
ProfileService.RemoveProfileConfiguration(second);
second.Profile.ShouldBeNull();
General.ProfileConfigurations.ShouldBe([first]);
first.Order.ShouldBe(0);
removed.ShouldBe([second]);
GetStoredProfiles().Select(p => p.ProfileConfiguration.Name).ShouldBe(["First"]);
}
[Fact]
public void CloneProfileConfiguration_SharesTheStoredDataWithTheOriginal()
{
ProfileConfiguration original = ProfileService.CreateProfileConfiguration(General, "Original", "TestTube");
ProfileConfiguration clone = ProfileService.CloneProfileConfiguration(original);
clone.ShouldNotBeSameAs(original);
clone.Name.ShouldBe("Original");
clone.Category.ShouldBeSameAs(General);
clone.ProfileId.ShouldBe(original.ProfileId);
// The clone is not added to the category
General.ProfileConfigurations.ShouldBe([original]);
// Pinned as-is: documented as creating a copy, but the clone uses the same entity as the original, so saving
// changes made to the clone changes the original's stored data too
clone.Entity.ShouldBeSameAs(original.Entity);
clone.Name = "Changed through the clone";
clone.Save();
original.Entity.ProfileConfiguration.Name.ShouldBe("Changed through the clone");
}
[Fact]
public void ProfileCategories_AreStoredWithTheirProfiles()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
ProfileCategoryEntity stored = GetStoredCategories().Single(c => c.Name == "General");
stored.ProfileConfigurations.Single().ProfileConfiguration.ProfileId.ShouldBe(configuration.ProfileId);
stored.ProfileConfigurations.Single().ProfileConfiguration.ProfileCategoryId.ShouldBe(General.EntityId);
}
}
@@ -0,0 +1,371 @@
using System.IO.Compression;
using System.Text;
using Artemis.Core.LayerEffects;
using Artemis.Core.Services;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
using Artemis.Core.Tests.Infrastructure.Plugins.Modules;
using Artemis.Core.Tests.Infrastructure.Plugins.Nodes;
using Artemis.Core.Tests.Infrastructure.Services;
using Artemis.Core.Tests.Infrastructure.VisualScripting;
using Artemis.Storage.Repositories.Interfaces;
namespace Artemis.Core.Tests.Services;
/// <summary>
/// Exporting profiles to a file and importing them again through the profile service
/// </summary>
public class ProfileImportExportTests : ProfileServiceTestBase
{
[Fact]
public async Task ExportProfile_CreatesAnArchiveWithTheConfigurationAndProfile()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
await using Stream export = await ProfileService.ExportProfile(configuration);
using ZipArchive archive = new(export, ZipArchiveMode.Read);
archive.Entries.Select(e => e.Name).ShouldBe(["configuration.json", "profile.json"]);
ReadEntry(archive, "configuration.json").ShouldContain("\"Test profile\"");
}
[Fact]
public async Task ExportProfile_WithAnImageAsIcon_IncludesTheIcon()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
configuration.Icon.SetIconByStream(new MemoryStream([1, 2, 3]));
await using Stream export = await ProfileService.ExportProfile(configuration);
using ZipArchive archive = new(export, ZipArchiveMode.Read);
archive.Entries.Select(e => e.Name).ShouldBe(["configuration.json", "profile.json", "icon.png"]);
}
[Fact]
public async Task ExportProfile_ExportsWhatWasLastSaved()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Saved name", "TestTube");
configuration.Name = "Unsaved name";
await using Stream export = await ProfileService.ExportProfile(configuration);
using ZipArchive archive = new(export, ZipArchiveMode.Read);
ReadEntry(archive, "configuration.json").ShouldContain("\"Saved name\"");
}
[Fact]
public async Task ImportProfile_MadeUnique_AddsACopyWithANewId()
{
ProfileConfiguration original = CreateSavedProfile("Test profile");
ProfileCategory games = ProfileService.ProfileCategories.Single(c => c.Name == "Games");
ProfileConfiguration imported = await ImportExport(original, games, true);
imported.ShouldNotBeSameAs(original);
imported.ProfileId.ShouldNotBe(original.ProfileId);
imported.Name.ShouldBe("Test profile");
imported.Category.ShouldBeSameAs(games);
games.ProfileConfigurations.ShouldBe([imported]);
imported.Profile.ShouldBeNull();
ProfileConfiguration stored = CreateNewProfileService().ProfileCategories.Single(c => c.Name == "Games").ProfileConfigurations.Single();
stored.ProfileId.ShouldBe(imported.ProfileId);
}
[Fact]
public async Task ImportProfile_MadeUnique_KeepsTheContentsOfTheProfile()
{
TestLayerBrushProvider brushes = EnableFeature(new TestLayerBrushProvider());
ProfileConfiguration original = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
Profile profile = ProfileService.ActivateProfile(original);
AddLayer<SolidColorTestBrush>(profile, brushes, [], "Exported layer");
ProfileService.SaveProfile(profile, true);
ProfileConfiguration imported = await ImportExport(original, General, true);
Profile importedProfile = ProfileService.ActivateProfile(imported);
Layer layer = importedProfile.GetAllLayers().Single();
layer.Name.ShouldBe("Exported layer");
layer.LayerBrush.ShouldBeOfType<SolidColorTestBrush>();
// The root folder moved along with the new ID
importedProfile.GetRootFolder().ShouldNotBeNull();
}
[Fact]
public async Task ImportProfile_WithANameAffix_AddsItToTheName()
{
ProfileConfiguration original = CreateSavedProfile("Test profile");
// Like the editor's duplicate option
ProfileConfiguration imported = await ImportExport(original, General, true, nameAffix: "copy");
imported.Name.ShouldBe("Test profile - copy");
imported.Entity.Profile.Name.ShouldBe("Test profile - copy");
}
[Fact]
public async Task ImportProfile_ByDefault_GoesAtTheBottomOfTheCategory()
{
ProfileConfiguration original = CreateSavedProfile("First");
CreateSavedProfile("Second");
ProfileConfiguration imported = await ImportExport(original, General, true);
General.ProfileConfigurations.Last().ShouldBeSameAs(imported);
imported.Order.ShouldBe(2);
}
[Fact]
public async Task ImportProfile_WithATarget_GoesInFrontOfTheTarget()
{
ProfileConfiguration original = CreateSavedProfile("First");
ProfileConfiguration second = CreateSavedProfile("Second");
// Listed as second, first
ProfileConfiguration imported = await ImportExport(original, General, true, target: original);
General.ProfileConfigurations.ShouldBe([second, imported, original]);
}
[Fact]
public async Task ImportProfile_DoesNotRaiseProfileAdded()
{
ProfileConfiguration original = CreateSavedProfile("Test profile");
int profileAdded = 0;
int addedToCategory = 0;
ProfileService.ProfileAdded += (_, _) => profileAdded++;
General.ProfileConfigurationAdded += (_, _) => addedToCategory++;
await ImportExport(original, General, true);
// Pinned as-is: creating a profile raises ProfileAdded but importing one doesn't, only the category's own
// ProfileConfigurationAdded event tells listeners about an imported profile
profileAdded.ShouldBe(0);
addedToCategory.ShouldBe(1);
}
[Fact]
public async Task ImportProfile_NotMadeUnique_ReturnsTheExistingProfile()
{
ProfileConfiguration original = CreateSavedProfile("Test profile");
ProfileConfiguration imported = await ImportExport(original, General, false);
imported.ShouldBeSameAs(original);
General.ProfileConfigurations.ShouldBe([original]);
}
[Fact]
public async Task ImportProfile_NotMadeUnique_KeepsTheIdOfANewProfile()
{
ProfileConfiguration original = CreateSavedProfile("Test profile");
await using Stream export = await ProfileService.ExportProfile(original);
ProfileService.RemoveProfileConfiguration(original);
ProfileConfiguration imported = await ProfileService.ImportProfile(export, General, false, false, null);
imported.ProfileId.ShouldBe(original.ProfileId);
}
[Fact]
public async Task ImportProfile_MarkedAsFreshImport_StaysFreshUntilSaved()
{
ProfileConfiguration original = CreateSavedProfile("Test profile");
ProfileConfiguration imported = await ImportExport(original, General, true, markAsFreshImport: true);
Profile profile = ProfileService.ActivateProfile(imported);
// A fresh import is adapted to the devices when it is activated
profile.IsFreshImport.ShouldBeTrue();
ProfileService.SaveProfile(profile, true);
profile.IsFreshImport.ShouldBeFalse();
CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations
.Single(c => c.ProfileId == imported.ProfileId).Entity.Profile.IsFreshImport.ShouldBeFalse();
}
[Fact]
public async Task ImportProfile_WithAnImageAsIcon_ImportsTheIcon()
{
ProfileConfiguration original = CreateSavedProfile("Test profile");
original.Icon.SetIconByStream(new MemoryStream([1, 2, 3]));
ProfileService.SaveProfileCategory(General);
ProfileConfiguration imported = await ImportExport(original, General, true);
imported.Icon.IconType.ShouldBe(ProfileConfigurationIconType.BitmapImage);
imported.Icon.IconBytes.ShouldBe([1, 2, 3]);
}
[Fact]
public async Task ImportProfile_WithoutAConfiguration_Throws()
{
using MemoryStream archive = CreateArchive(("profile.json", "{}"));
ArtemisCoreException exception = await Should.ThrowAsync<ArtemisCoreException>(() => ProfileService.ImportProfile(archive, General, true, false, null));
exception.Message.ShouldContain("configuration.json missing");
}
[Fact]
public async Task ImportProfile_WithoutAProfile_Throws()
{
using MemoryStream archive = CreateArchive(("configuration.json", "{}"));
ArtemisCoreException exception = await Should.ThrowAsync<ArtemisCoreException>(() => ProfileService.ImportProfile(archive, General, true, false, null));
exception.Message.ShouldContain("profile.json missing");
}
[Fact]
public async Task ImportProfile_WithUnavailableBrushesAndEffects_KeepsThemForWhenTheyReturn()
{
TestLayerBrushProvider brushes = EnableFeature(new TestLayerBrushProvider());
TestLayerEffectProvider effects = EnableFeature(new TestLayerEffectProvider());
ProfileConfiguration original = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
Profile profile = ProfileService.ActivateProfile(original);
Layer layer = AddLayer<SolidColorTestBrush>(profile, brushes, []);
AddEffect<TintTestEffect>(layer, effects);
ProfileService.SaveProfile(profile, true);
await using Stream export = await ProfileService.ExportProfile(original);
DisableFeature(brushes);
DisableFeature(effects);
ProfileConfiguration imported = await ProfileService.ImportProfile(export, General, true, false, null);
Profile importedProfile = ProfileService.ActivateProfile(imported);
Layer importedLayer = importedProfile.GetAllLayers().Single();
// Without the plugins the layer has no brush and a placeholder effect
importedLayer.LayerBrush.ShouldBeNull();
importedLayer.BrokenState.ShouldNotBeNull();
importedLayer.LayerEffects.Single().Descriptor.IsPlaceholder.ShouldBeTrue();
// Saving in the meantime doesn't lose them
ProfileService.SaveProfile(importedProfile, true);
EnableFeature(new TestLayerBrushProvider());
EnableFeature(new TestLayerEffectProvider());
ProfileService.DeactivateProfile(imported);
Layer reloadedLayer = ProfileService.ActivateProfile(imported).GetAllLayers().Single();
reloadedLayer.LayerBrush.ShouldBeOfType<SolidColorTestBrush>();
reloadedLayer.LayerEffects.Single().ShouldBeOfType<TintTestEffect>();
}
[Fact]
public async Task ImportProfile_WithUnavailableNodes_DropsThemFromTheActivationCondition()
{
TestNodeProvider nodes = EnableFeature(new TestNodeProvider());
ProfileConfiguration original = CreateSavedProfile("Test profile");
UseActivationCondition(original, nodes, false);
ProfileService.SaveProfileCategory(General);
await using Stream export = await ProfileService.ExportProfile(original);
DisableFeature(nodes);
ProfileConfiguration imported = await ProfileService.ImportProfile(export, General, true, false, null);
// Pinned as-is: the node can't be loaded without its plugin, and because importing saves the category right
// away, the node and its connection are removed from the stored activation condition for good. Unlike missing
// brushes and effects, enabling the plugin afterwards doesn't bring it back, and the imported profile now
// activates unconditionally
imported.ActivationCondition.Nodes.OfType<TestBooleanNode>().ShouldBeEmpty();
imported.Entity.ProfileConfiguration.ActivationCondition!.Nodes.ShouldNotContain(n => n.Type == nameof(TestBooleanNode));
EnableFeature(new TestNodeProvider());
ProfileConfiguration reloaded = CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations
.Single(c => c.ProfileId == imported.ProfileId);
reloaded.ActivationCondition.Nodes.OfType<TestBooleanNode>().ShouldBeEmpty();
reloaded.ActivationCondition.ExitNodeConnected.ShouldBeFalse();
}
[Fact]
public async Task ImportProfile_WithAnUnavailableModule_IsMissingItsModuleUntilItIsEnabled()
{
TestModule module = EnableFeature(new TestModule());
ProfileConfiguration original = CreateSavedProfile("Test profile");
original.Module = module;
ProfileService.SaveProfileCategory(General);
await using Stream export = await ProfileService.ExportProfile(original);
DisableFeature(module);
ProfileConfiguration imported = await ProfileService.ImportProfile(export, General, true, false, null);
imported.IsMissingModule.ShouldBeTrue();
imported.Entity.ProfileConfiguration.ModuleId.ShouldBe(module.Id);
PluginManagement.EnablePluginFeature(module, false, false);
imported.Module.ShouldBeSameAs(module);
}
[Fact]
public void ProfilesThatWereNeverSavedInTheEditor_AreStoredAsVersionZero()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
GetStoredProfiles().Single().ProfileConfiguration.Version.ShouldBe(0);
ProfileService.SaveProfile(ProfileService.ActivateProfile(configuration), true);
GetStoredProfiles().Single().ProfileConfiguration.Version.ShouldBeGreaterThan(0);
}
[Fact]
public void ProfilesThatWereNeverSavedInTheEditor_FailToMigrateOnStartupButStillLoad()
{
ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
// Artemis migrates stored profiles once on startup, a failure is logged and the profile is left as it is
Resolve<IProfileRepository>().MigrateProfiles();
GetStoredProfiles().Single().ProfileConfiguration.Version.ShouldBe(0);
CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Single().Name.ShouldBe("Test profile");
}
[Fact]
public async Task ImportProfile_OfAProfileThatWasNeverSavedInTheEditor_Fails()
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube");
await using Stream export = await ProfileService.ExportProfile(configuration);
// Pinned as-is: saving the category (which is all creating a profile does) stores the profile without setting its
// migration version, only SaveProfile sets it. The export says version 0, so importing runs every migration on
// JSON that is already up to date and the first one fails on it. Duplicating a freshly created profile from the
// sidebar or the editor (an export followed by an import) runs into this. Restarting Artemis runs into it too, but
// there the failed migration is only logged and the profile loads normally, see the test above
InvalidOperationException exception = await Should.ThrowAsync<InvalidOperationException>(() => ProfileService.ImportProfile(export, General, true, false, "copy"));
exception.StackTrace!.ShouldContain("M0001NodeProviders");
General.ProfileConfigurations.ShouldBe([configuration]);
}
/// <summary>
/// Creates a profile and saves it the way the editor does, which also stamps it with the latest migration version
/// </summary>
private ProfileConfiguration CreateSavedProfile(string name)
{
ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, name, "TestTube");
ProfileService.SaveProfile(ProfileService.ActivateProfile(configuration), true);
ProfileService.DeactivateProfile(configuration);
return configuration;
}
private async Task<ProfileConfiguration> ImportExport(ProfileConfiguration configuration, ProfileCategory category, bool makeUnique,
bool markAsFreshImport = false, string? nameAffix = null, ProfileConfiguration? target = null)
{
await using Stream export = await ProfileService.ExportProfile(configuration);
return await ProfileService.ImportProfile(export, category, makeUnique, markAsFreshImport, nameAffix, target);
}
private static string ReadEntry(ZipArchive archive, string name)
{
using StreamReader reader = new(archive.GetEntry(name)!.Open());
return reader.ReadToEnd();
}
private static MemoryStream CreateArchive(params (string Name, string Content)[] entries)
{
MemoryStream stream = new();
using (ZipArchive archive = new(stream, ZipArchiveMode.Create, true))
{
foreach ((string name, string content) in entries)
{
using Stream entryStream = archive.CreateEntry(name).Open();
entryStream.Write(Encoding.UTF8.GetBytes(content));
}
}
stream.Seek(0, SeekOrigin.Begin);
return stream;
}
}
@@ -0,0 +1,283 @@
using Artemis.Core.LayerBrushes;
using Artemis.Core.LayerEffects;
using Artemis.Core.Modules;
using Artemis.Core.Services;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes;
using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects;
using Artemis.Core.Tests.Infrastructure.Plugins.Modules;
using Artemis.Core.Tests.Infrastructure.Services;
namespace Artemis.Core.Tests.Services;
public class DataModelServiceTests : ServiceTestBase
{
[Fact]
public void ModulesEnabledBeforeTheServiceStarts_HaveTheirDataModelRegistered()
{
TestModule module = EnableFeature(new TestModule());
IDataModelService dataModels = Resolve<IDataModelService>();
dataModels.GetDataModels().ShouldContain(module.DataModel);
dataModels.GetPluginDataModel(module).ShouldBeSameAs(module.DataModel);
}
[Fact]
public void ModulesEnabledLater_HaveTheirDataModelRegistered()
{
IDataModelService dataModels = Resolve<IDataModelService>();
TestModule module = EnableFeature(new TestModule());
dataModels.GetDataModels().ShouldContain(module.DataModel);
dataModels.GetDataModel<TestDataModel>().ShouldBeSameAs(module.DataModel);
}
[Fact]
public void DisablingAModule_RemovesItsDataModel()
{
IDataModelService dataModels = Resolve<IDataModelService>();
TestModule module = EnableFeature(new TestModule());
TestDataModel dataModel = module.DataModel;
DisableFeature(module);
dataModels.GetDataModels().ShouldNotContain(dataModel);
dataModels.GetPluginDataModel(module).ShouldBeNull();
dataModels.GetDataModel<TestDataModel>().ShouldBeNull();
}
[Fact]
public void AModuleWithoutRequirements_IsAnExpansion()
{
Resolve<IDataModelService>();
TestModule alwaysAvailable = EnableFeature(new TestModule());
TestModule withRequirements = EnableFeature(new TestModule {Requirements = [new BooleanActivationRequirement()]});
// Expansions extend the data model of every profile rather than only of profiles targeting the module
alwaysAvailable.DataModel.IsExpansion.ShouldBeTrue();
withRequirements.DataModel.IsExpansion.ShouldBeFalse();
}
[Fact]
public void DataModel_DescribesTheModule()
{
Resolve<IDataModelService>();
TestModule module = EnableFeature(new TestModule());
module.DataModel.DataModelDescription.ShouldNotBeNull();
module.DataModel.DataModelDescription.Name.ShouldBe(module.Info.Name);
module.DataModel.Module.ShouldBeSameAs(module);
}
[Fact]
public void RegisterDataModel_TheSameDataModelTwice_Throws()
{
IDataModelService dataModels = Resolve<IDataModelService>();
TestModule module = EnableFeature(new TestModule());
Should.Throw<ArtemisCoreException>(() => dataModels.RegisterDataModel(module.DataModel));
}
[Fact]
public void RemoveDataModel_RemovesTheRegistration()
{
IDataModelService dataModels = Resolve<IDataModelService>();
TestModule module = EnableFeature(new TestModule());
DataModelRegistration registration = DataModelStore.GetAll().Single(r => r.DataModel == module.DataModel);
dataModels.RemoveDataModel(registration);
registration.IsInStore.ShouldBeFalse();
dataModels.GetDataModels().ShouldNotContain(module.DataModel);
// Removing it twice is not allowed
Should.Throw<ArtemisCoreException>(() => dataModels.RemoveDataModel(registration));
}
[Fact]
public void NullArguments_Throw()
{
IDataModelService dataModels = Resolve<IDataModelService>();
Should.Throw<ArgumentNullException>(() => dataModels.RegisterDataModel(null!));
Should.Throw<ArgumentNullException>(() => dataModels.RemoveDataModel(null!));
}
}
public class LayerBrushServiceTests : ServiceTestBase
{
private const string DefaultBrushSetting = "ProfileEditor.DefaultLayerBrushDescriptor";
[Fact]
public void GetLayerBrushes_ContainsTheBrushesOfEnabledProviders()
{
ILayerBrushService brushes = Resolve<ILayerBrushService>();
TestLayerBrushProvider provider = EnableFeature(new TestLayerBrushProvider());
brushes.GetLayerBrushes().Where(d => d.Provider == provider).Select(d => d.LayerBrushType)
.ShouldBe([typeof(SolidColorTestBrush), typeof(GradientTestBrush), typeof(PerLedTestBrush)]);
}
[Fact]
public void GetLayerBrushes_NoLongerContainsTheBrushesOfADisabledProvider()
{
ILayerBrushService brushes = Resolve<ILayerBrushService>();
TestLayerBrushProvider provider = EnableFeature(new TestLayerBrushProvider());
DisableFeature(provider);
brushes.GetLayerBrushes().ShouldNotContain(d => d.Provider == provider);
}
[Fact]
public void RegisterAndRemoveLayerBrush_AddsAndRemovesTheBrush()
{
ILayerBrushService brushes = Resolve<ILayerBrushService>();
TestLayerBrushProvider provider = EnableFeature(new TestLayerBrushProvider());
LayerBrushDescriptor descriptor = provider.LayerBrushDescriptors.First();
LayerBrushRegistration registration = LayerBrushStore.Get(provider.Id, descriptor.LayerBrushType.Name)!;
brushes.RemoveLayerBrush(registration);
brushes.GetLayerBrushes().ShouldNotContain(descriptor);
LayerBrushRegistration added = brushes.RegisterLayerBrush(descriptor);
added.LayerBrushDescriptor.ShouldBeSameAs(descriptor);
brushes.GetLayerBrushes().ShouldContain(descriptor);
}
[Fact]
public void NullArguments_Throw()
{
ILayerBrushService brushes = Resolve<ILayerBrushService>();
Should.Throw<ArgumentNullException>(() => brushes.RegisterLayerBrush(null!));
Should.Throw<ArgumentNullException>(() => brushes.RemoveLayerBrush(null!));
}
[Fact]
public void GetDefaultLayerBrush_WithoutTheColorBrushPlugin_IsNull()
{
ILayerBrushService brushes = Resolve<ILayerBrushService>();
EnableFeature(new TestLayerBrushProvider());
// By default this is the solid brush of the Color brush plugin in the Artemis.Plugins repository
brushes.GetDefaultLayerBrush().ShouldBeNull();
LayerBrushReference reference = Resolve<ISettingsService>().GetSetting<LayerBrushReference>(DefaultBrushSetting).Value!;
reference.LayerBrushProviderId.ShouldBe("Artemis.Plugins.LayerBrushes.Color.ColorBrushProvider-92a9d6ba");
reference.BrushType.ShouldBe("SolidBrush");
}
[Fact]
public void GetDefaultLayerBrush_FollowsTheSetting()
{
ILayerBrushService brushes = Resolve<ILayerBrushService>();
TestLayerBrushProvider provider = EnableFeature(new TestLayerBrushProvider());
LayerBrushDescriptor gradient = provider.LayerBrushDescriptors.Single(d => d.LayerBrushType == typeof(GradientTestBrush));
Resolve<ISettingsService>().GetSetting<LayerBrushReference>(DefaultBrushSetting).Value = new LayerBrushReference(gradient);
brushes.GetDefaultLayerBrush().ShouldBeSameAs(gradient);
}
[Fact]
public void GetDefaultLayerBrush_FillsInAnEmptySetting()
{
ILayerBrushService brushes = Resolve<ILayerBrushService>();
PluginSetting<LayerBrushReference> setting = Resolve<ISettingsService>().GetSetting<LayerBrushReference>(DefaultBrushSetting);
setting.Value = new LayerBrushReference();
brushes.GetDefaultLayerBrush();
setting.Value!.LayerBrushProviderId.ShouldBe("Artemis.Plugins.LayerBrushes.Color.ColorBrushProvider-92a9d6ba");
setting.Value.BrushType.ShouldBe("SolidBrush");
}
[Fact]
public void ApplyDefaultBrush_GivesTheLayerTheDefaultBrush()
{
ILayerBrushService brushes = Resolve<ILayerBrushService>();
TestLayerBrushProvider provider = EnableFeature(new TestLayerBrushProvider());
LayerBrushDescriptor solid = provider.LayerBrushDescriptors.Single(d => d.LayerBrushType == typeof(SolidColorTestBrush));
Resolve<ISettingsService>().GetSetting<LayerBrushReference>(DefaultBrushSetting).Value = new LayerBrushReference(solid);
Layer layer = CreateLayer();
brushes.ApplyDefaultBrush(layer);
layer.LayerBrush.ShouldBeOfType<SolidColorTestBrush>();
}
[Fact]
public void ApplyDefaultBrush_WithoutTheDefaultBrush_LeavesTheLayerAlone()
{
ILayerBrushService brushes = Resolve<ILayerBrushService>();
Layer layer = CreateLayer();
brushes.ApplyDefaultBrush(layer);
layer.LayerBrush.ShouldBeNull();
}
private static Layer CreateLayer()
{
ProfileConfiguration configuration = new(new ProfileCategory("Tests", 0), "Test profile", "TestTube");
Profile profile = new(configuration, configuration.Entity.Profile);
Layer layer = new(profile.GetRootFolder(), "Test layer");
profile.GetRootFolder().AddChild(layer);
return layer;
}
}
public class LayerEffectServiceTests : ServiceTestBase
{
[Fact]
public void GetLayerEffects_ContainsTheEffectsOfEnabledProviders()
{
ILayerEffectService effects = Resolve<ILayerEffectService>();
TestLayerEffectProvider provider = EnableFeature(new TestLayerEffectProvider());
effects.GetLayerEffects().Where(d => d.Provider == provider).Select(d => d.LayerEffectType)
.ShouldBe([typeof(OpacityTestEffect), typeof(ChannelSwapTestEffect), typeof(TintTestEffect), typeof(GlowTestEffect)]);
}
[Fact]
public void GetLayerEffects_NoLongerContainsTheEffectsOfADisabledProvider()
{
ILayerEffectService effects = Resolve<ILayerEffectService>();
TestLayerEffectProvider provider = EnableFeature(new TestLayerEffectProvider());
DisableFeature(provider);
effects.GetLayerEffects().ShouldNotContain(d => d.Provider == provider);
}
[Fact]
public void RegisterAndRemoveLayerEffect_AddsAndRemovesTheEffect()
{
ILayerEffectService effects = Resolve<ILayerEffectService>();
TestLayerEffectProvider provider = EnableFeature(new TestLayerEffectProvider());
LayerEffectDescriptor descriptor = provider.LayerEffectDescriptors.First();
// Unlike brushes, effects are looked up by the full name of their type
LayerEffectRegistration registration = LayerEffectStore.Get(provider.Id, descriptor.LayerEffectType!.FullName!)!;
effects.RemoveLayerEffect(registration);
effects.GetLayerEffects().ShouldNotContain(descriptor);
LayerEffectRegistration added = effects.RegisterLayerEffect(descriptor);
added.LayerEffectDescriptor.ShouldBeSameAs(descriptor);
effects.GetLayerEffects().ShouldContain(descriptor);
}
[Fact]
public void NullArguments_Throw()
{
ILayerEffectService effects = Resolve<ILayerEffectService>();
Should.Throw<ArgumentNullException>(() => effects.RegisterLayerEffect(null!));
Should.Throw<ArgumentNullException>(() => effects.RemoveLayerEffect(null!));
}
}
@@ -0,0 +1,49 @@
using Artemis.Core.Services;
using Artemis.Core.Tests.Infrastructure.Services;
using Artemis.Storage.Repositories.Interfaces;
namespace Artemis.Core.Tests.Services;
public class ServiceHarnessTests : IDisposable
{
private readonly ServiceHarness _services = new();
[Fact]
public void Settings_AreStoredInTheTemporaryDatabase()
{
PluginSetting<int> setting = _services.Resolve<ISettingsService>().GetSetting("Tests.Number", 1);
setting.Value = 5;
setting.Save();
// A fresh settings instance reads the value back from the database
SettingsService reloaded = new(_services.Resolve<IPluginRepository>());
reloaded.GetSetting("Tests.Number", 1).Value.ShouldBe(5);
using var context = _services.CreateDbContext();
context.PluginSettings.ShouldContain(s => s.Name == "Tests.Number");
}
[Fact]
public void ProfileService_StoresCategoriesInTheTemporaryDatabase()
{
IProfileService profileService = _services.Resolve<IProfileService>();
profileService.CreateProfileCategory("Test category");
using var context = _services.CreateDbContext();
context.ProfileCategories.ShouldContain(c => c.Name == "Test category");
}
[Fact]
public void Harness_DoesNotTouchTheRealDataFolder()
{
_services.DataFolder.ShouldNotStartWith(Constants.DataFolder);
_services.Resolve<ISettingsService>().GetSetting("Tests.Number", 1).Save();
File.Exists(Path.Combine(_services.DataFolder, "artemis.db")).ShouldBeTrue();
}
public void Dispose()
{
_services.Dispose();
}
}
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more