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

+41
View File
@@ -0,0 +1,41 @@
name: Pull Request - Test
on:
workflow_dispatch:
pull_request:
branches:
- master
jobs:
test:
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
name: Windows
- os: ubuntu-latest
name: Linux
name: ${{ matrix.name }} Tests
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Artemis
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Run Artemis.Core tests
run: dotnet test --project src/Artemis.Core.Tests --configuration Release --results-directory TestResults --report-xunit-trx
- name: Run Artemis.UI.Shared tests
if: ${{ !cancelled() }}
run: dotnet test --project src/Artemis.UI.Shared.Tests --configuration Release --results-directory TestResults --report-xunit-trx
# The TRX report contains the render output (PNG + JSON) of failing render tests as attachments
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.name }}
path: TestResults
if-no-files-found: ignore
+3
View File
@@ -200,3 +200,6 @@ docfx/docfx_project/_site/
src/.idea/ src/.idea/
packages.lock.json packages.lock.json
docfx/docfx_project/api/ docfx/docfx_project/api/
# Render harness output (Artemis.Core.Tests)
render-output/
+124
View File
@@ -0,0 +1,124 @@
# Artemis
Artemis is an RGB lighting app: C# on .NET 10, Avalonia for the UI, SkiaSharp for drawing and RGB.NET for talking to devices.
The solution is `src/Artemis.sln`. `Artemis.Core` holds profiles, layers, brushes, effects and the render pipeline, the
`Artemis.UI.*` projects are the app itself.
## Building and testing
```bash
dotnet build src/Artemis.sln
dotnet test --project src/Artemis.Core.Tests
dotnet test --project src/Artemis.UI.Shared.Tests
```
The tests use xUnit v3 on Microsoft.Testing.Platform (switched on by `global.json` in the repo root), so use
`dotnet test --project ...` as shown above. The whole suite runs in a few seconds. To run part of it, pass a filter, for
example `dotnet test --project src/Artemis.Core.Tests --filter-class "*TimelineTests"`.
Pull requests to `master` run the tests on Windows and Linux (`.github/workflows/pr.yml`).
## Rules
- Changes to `Artemis.Core` come with tests in `Artemis.Core.Tests`: a test that fails without the change and passes
with it for bug fixes, tests covering the new behaviour for features.
- `dotnet test --project src/Artemis.Core.Tests` must pass before you consider the work done.
- Tests run one at a time on purpose (see `TestAssembly.cs`), Artemis.Core keeps brush and effect registrations in
static state that tests would otherwise share.
## The render harness
Most tests render real profiles through the same pipeline the app uses and check the colors that end up on each LED.
- `Infrastructure/Rendering/RenderHarness.cs` sets up fake devices from RGB.NET layout files, creates profiles, layers,
folders and effects, and renders frames. No services or plugins are started and the harness controls time: a frame is
only rendered when a test asks for one, after moving time forward by a given amount.
- `Infrastructure/Rendering/RenderTestBase.cs` is the base class for tests on a single ANSI keyboard. It gives you
`Harness`, `Keyboard`, `Led(LedId)`, `Leds(...)`, `TopLetterRow`, and `Render(profile)` / `RenderTimeline(profile, ...)`
which also take care of saving the render output described below. Tests that need other devices or render scales
create their own `RenderHarness` (see `Rendering/SmallLedTests.cs`).
- The test brushes and effects under `Infrastructure/Plugins` are simple, predictable stand-ins for real plugins.
- Compare colors with `ShouldBeColor` (`Infrastructure/ColorAssertions.cs`), it allows a small difference per channel
(2 by default) since drawing and sampling can be off by a little.
A typical test:
```csharp
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);
}
}
```
### Render output
When a render test fails, what it rendered is written to
`src/Artemis.Core.Tests/render-output/<namespace>/<class>/<test>/`, for example
`render-output/Profiles/LayerBrushTests/SolidBrush_DefaultsToItsDefaultValue/`. Passing tests write nothing, unless you
set the environment variable `ARTEMIS_RENDER_OUTPUT=always`. The folder is ignored by git.
A single frame produces:
- `canvas.png`: the raw canvas the profile was drawn on
- `leds.png`: every LED drawn at its position, filled with the color it received
- `leds.json`: the color of every LED by LED ID, as hex (`#RRGGBB`, or `#AARRGGBB` when not fully opaque)
A timeline produces `canvas-strip.png`, `leds-strip.png` (all frames stacked top to bottom) and `timeline.json`.
When working out why a render test failed, read the JSON first: it holds the exact color each LED received, which is
more reliable than judging colors from the PNGs. Use the PNGs to get an idea of shapes and positions.
In CI the same files are attached to the test report, which is uploaded as the `test-results-<OS>` artifact of the run.
## Testing without rendering
Not everything needs the render harness. These pieces live under `src/Artemis.Core.Tests/Infrastructure`:
- `Plugins/TestPlugin.cs` hosts test plugin features (brushes, effects, modules, node providers) without plugin
loading. `AddFeature` enables a feature the way Artemis does and disposing the plugin disables them again, which also
removes their registrations from Core's static stores.
- `Plugins/Modules/TestModule.cs` is a module with a `TestDataModel`: numbers, text, a color, an enum, a nested data
model, lists and data model events. Adding it to a `TestPlugin` registers the data model, so data model paths can find
it both directly and when loaded from storage. See `DataModels/DataModelPathTests.cs`.
- `Plugins/Nodes/TestNodeProvider.cs` registers the test nodes (`TestBooleanNode`, `TestNumericNode`), so scripts using
them can be saved and loaded. Create nodes with `AddNode<T>(script)` rather than `new`, otherwise they can't be loaded
again. See `VisualScripting/NodeScriptTests.cs`.
- `Services/ServiceHarness.cs` registers the Artemis.Core services like the app does, backed by a new database in a
temporary folder and logging to the test output. `Resolve<T>()` also works for protected services such as
`ISettingsService`. Anything working on `Constants.DataFolder` or `Constants.PluginsFolder` directly (loading plugins,
the web server) still points at your real Artemis folders, so don't call it. See `Services/ServiceHarnessTests.cs`.
## Code coverage
```bash
dotnet test --project src/Artemis.Core.Tests -- --coverage --coverage-output-format cobertura --coverage-settings "$PWD/src/Artemis.Core.Tests/coverage.settings.xml"
```
`coverage.settings.xml` leaves out the database migrations Entity Framework generates in `Artemis.Storage/Migrations`.
The path to it has to be absolute, with a relative path the test run fails without saying why. `$PWD` works in bash and
PowerShell when you run the command from the repo root.
## Profile editor tests
`src/Artemis.UI.Shared.Tests` covers the profile editor logic in `Artemis.UI.Shared/Services/ProfileEditor`: the
`ProfileEditorService` and its undo/redo commands. Changes there come with tests in this project, and it must pass too.
- It references `Artemis.Core.Tests` to reuse the render harness, test plugins and fake devices, and has the same
setup (xUnit v3, tests one at a time).
- `Infrastructure/ProfileEditorTestBase.cs` builds on `RenderTestBase` and adds a `Profile`, a `Service` backed by the
simple test services in `Infrastructure/Services` (they record what the editor asks of them), `StartEditing()`,
`Undo()`/`Redo()` and `Snapshot()`. `Snapshot()` returns the profile as Artemis would save it, compare the snapshot
from before a command with the one after undoing it to check that undo restored everything.
- No Avalonia app or dispatcher is needed: under a test runner ReactiveUI runs commands right away on the calling
thread. One thing to know: a command that throws inside `ExecuteCommand` is also passed to ReactiveUI's
`RxApp.DefaultExceptionHandler`, which ends the test run unless the test replaces it before editing starts (see
`ProfileEditorHistoryTests.ExecuteCommand_ThatFails_DoesNotEndUpInTheHistory`).
+5
View File
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
@@ -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);
}
}
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more