mirror of
https://github.com/Artemis-RGB/Artemis
synced 2026-11-09 18:36:27 +00:00
Tests - Add Artemis.Core and profile editor test suites (#907)
This commit is contained in:
1 parent
6e7fae16d2
commit
504845c7d2
168 files changed
+22914
-7
No files matched your search
@@ -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
|
||||||
@@ -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/
|
||||||
@@ -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`).
|
||||||
@@ -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})")}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -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>
|
||||||
+134
@@ -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; }
|
||||||
|
}
|
||||||
+18
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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/<test namespace>/<test class>/<name></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/<test namespace>/<test class>/<name></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/<test namespace>/<test class>/<name></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
Reference in new issue
Block a user