diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 000000000..16b335d9d --- /dev/null +++ b/.github/workflows/pr.yml @@ -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 diff --git a/.gitignore b/.gitignore index 0d5d84996..c60a3bf53 100644 --- a/.gitignore +++ b/.gitignore @@ -200,3 +200,6 @@ docfx/docfx_project/_site/ src/.idea/ packages.lock.json docfx/docfx_project/api/ + +# Render harness output (Artemis.Core.Tests) +render-output/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..dfa3eee84 --- /dev/null +++ b/CLAUDE.md @@ -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(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////`, 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-` 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(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()` 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`). diff --git a/global.json b/global.json new file mode 100644 index 000000000..3140116df --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/Artemis.Core.Tests/Artemis.Core.Tests.csproj b/src/Artemis.Core.Tests/Artemis.Core.Tests.csproj new file mode 100644 index 000000000..05a1c2b27 --- /dev/null +++ b/src/Artemis.Core.Tests/Artemis.Core.Tests.csproj @@ -0,0 +1,36 @@ + + + net10.0 + x64 + enable + enable + Exe + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Artemis.Core.Tests/ColorScience/ColorQuantizerTests.cs b/src/Artemis.Core.Tests/ColorScience/ColorQuantizerTests.cs new file mode 100644 index 000000000..929fec2a1 --- /dev/null +++ b/src/Artemis.Core.Tests/ColorScience/ColorQuantizerTests.cs @@ -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(() => 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(() => 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); + } + + /// + /// Creates a 16x16 image, the smallest image the gradient can be taken from, filled with bands of the given colors + /// + 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 +} diff --git a/src/Artemis.Core.Tests/ColorScience/ColorSorterTests.cs b/src/Artemis.Core.Tests/ColorScience/ColorSorterTests.cs new file mode 100644 index 000000000..c3f73a1a4 --- /dev/null +++ b/src/Artemis.Core.Tests/ColorScience/ColorSorterTests.cs @@ -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); + } +} diff --git a/src/Artemis.Core.Tests/Conditions/EventConditionTests.cs b/src/Artemis.Core.Tests/Conditions/EventConditionTests.cs new file mode 100644 index 000000000..a9dfc3a84 --- /dev/null +++ b/src/Artemis.Core.Tests/Conditions/EventConditionTests.cs @@ -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; + +/// +/// Event conditions on a layer on the A key, with a main segment of one second and no start or end segment +/// +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(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(); + 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(); + 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(); + + startNode.NewValue.Type.ShouldBe(typeof(Numeric)); + startNode.OldValue.Type.ShouldBe(typeof(Numeric)); + condition.Script.Nodes.ShouldContain(startNode); + condition.Script.Nodes.OfType().ShouldBeEmpty(); + } + + [Fact] + public void PathToAValue_LetsTheScriptLookAtTheNewValue() + { + (Layer layer, EventCondition condition) = AddLayer(eventPath: "Integer"); + EventConditionValueChangedStartNode startNode = (EventConditionValueChangedStartNode) condition.GetStartNode(); + TestGreaterThanNode greaterThan = _nodes.AddNode(condition.Script); + greaterThan.Threshold = 3; + startNode.NewValue.ConnectTo(greaterThan.Input); + condition.Script.Nodes.OfType>().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(); + 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().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(); + condition.Script.Nodes.ShouldContain(condition.GetStartNode()); + } + + [Fact] + public void EmptyScript_IsNotSaved() + { + (_, EventCondition condition) = AddLayer(); + + condition.Save(); + + EventConditionEntity entity = condition.Entity.ShouldBeOfType(); + entity.Script.ShouldBeNull(); + entity.EventPath.ShouldNotBeNull().Path.ShouldBe("Event"); + } + + [Fact] + public void ScriptWithOtherNodes_IsSaved() + { + (_, EventCondition condition) = AddLayer(); + _nodes.AddNode(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(); + reloadedCondition.EventPath.ShouldBeNull(); + reloadedCondition.GetStartNode().ShouldBeOfType(); + } + + #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 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(Harness.CreateProfile().GetRootFolder(), [Led(LedId.Keyboard_A)]); + if (fadeToBlue) + { + SKColorLayerProperty color = brush.Properties.Color; + color.KeyframesEnabled = true; + color.AddKeyframe(new LayerPropertyKeyframe(SKColors.Red, TimeSpan.Zero, Easings.Functions.Linear, color)); + color.AddKeyframe(new LayerPropertyKeyframe(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(condition.Script); + greaterThan.Threshold = threshold; + condition.GetStartNode().Pins.Single(p => p.Name == "Value").ConnectTo(greaterThan.Input); + condition.Script.Nodes.OfType>().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); + } +} diff --git a/src/Artemis.Core.Tests/DataBindings/DataBindingTests.cs b/src/Artemis.Core.Tests/DataBindings/DataBindingTests.cs new file mode 100644 index 000000000..b40d648a9 --- /dev/null +++ b/src/Artemis.Core.Tests/DataBindings/DataBindingTests.cs @@ -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 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 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 dataBinding = layer.Transform.Position.DataBinding; + TestNumericNode node = _nodes.AddNode(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(Harness.CreateProfile().GetRootFolder(), [Led(LedId.Keyboard_A)]); + DataBinding dataBinding = brush.Properties.Color.DataBinding; + TestColorNode node = _dataModelNodes.AddNode(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 dataBinding = layer.Transform.Opacity.DataBinding; + TestDataModelNumberNode node = _dataModelNodes.AddNode(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(new SKPoint(0, 0), TimeSpan.Zero, Easings.Functions.Linear, position)); + position.AddKeyframe(new LayerPropertyKeyframe(new SKPoint(10, 10), TimeSpan.FromSeconds(1), Easings.Functions.Linear, position)); + TestNumericNode node = _nodes.AddNode(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 dataBinding = reloadedLayer.Transform.Opacity.DataBinding; + dataBinding.IsEnabled.ShouldBeTrue(); + dataBinding.Script.Nodes.OfType().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 dataBinding = layer.Transform.Opacity.DataBinding; + TestDataModelNumberNode node = _dataModelNodes.AddNode(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().ShouldHaveSingleItem(); + frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Red); + } + + [Fact] + public void EmptyScript_IsNotSaved() + { + Layer layer = AddLayer(); + DataBinding 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 dataBinding = layer.Transform.Opacity.DataBinding; + _nodes.AddNode(dataBinding.Script); + + dataBinding.Save(); + + dataBinding.Entity.NodeScript.ShouldNotBeNull(); + } + + [Fact] + public void LoadingTheNodeScript_ReplacesTheScript() + { + Layer layer = AddLayer(); + DataBinding dataBinding = layer.Transform.Opacity.DataBinding; + _nodes.AddNode(dataBinding.Script); + dataBinding.Save(); + INodeScript before = dataBinding.Script; + + dataBinding.LoadNodeScript(); + + dataBinding.Script.ShouldNotBeSameAs(before); + dataBinding.Script.Nodes.OfType().ShouldHaveSingleItem(); + } + + #endregion + + #region The data binding itself + + [Fact] + public void EnablingAndDisabling_RaisesEvents() + { + Layer layer = AddLayer(); + DataBinding dataBinding = layer.Transform.Opacity.DataBinding; + List 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 dataBinding = layer.Transform.Opacity.DataBinding; + int registered = 0; + dataBinding.DataBindingPropertyRegistered += (_, _) => registered++; + + DataBindingProperty 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(() => layer.Transform.Opacity.DataBinding.RegisterDataBindingProperty(() => 1f, _ => { }, "Value")); + } + + [Fact] + public void ClearingProperties_RemovesTheInputsOfTheExitNode() + { + Layer layer = AddLayer(); + DataBinding 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 dataBinding = layer.Transform.Opacity.DataBinding; + dataBinding.IsEnabled = true; + + dataBinding.Dispose(); + + dataBinding.IsEnabled.ShouldBeFalse(); + Should.Throw(() => dataBinding.Update()); + Should.Throw(() => dataBinding.Apply()); + Should.Throw(() => dataBinding.Save()); + Should.Throw(() => dataBinding.Load()); + Should.Throw(() => dataBinding.ClearDataBindingProperties()); + Should.Throw(() => 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 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(() => floatValue, v => floatValue = v, "Float").SetValue(new Numeric(1.5f)); + new DataBindingProperty(() => intValue, v => intValue = v, "Int").SetValue(new Numeric(2)); + new DataBindingProperty(() => doubleValue, v => doubleValue = v, "Double").SetValue(new Numeric(3.5f)); + new DataBindingProperty(() => 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 numberProperty = new(() => 0, _ => { }, "Value"); + DataBindingProperty textProperty = new(() => "", _ => { }, "Text"); + + Should.Throw(() => numberProperty.SetValue("text")); + Should.Throw(() => textProperty.SetValue(new Numeric(1))); + } + + [Fact] + public void Property_RequiresAGetterSetterAndName() + { + Should.Throw(() => new DataBindingProperty(null!, _ => { }, "Value")); + Should.Throw(() => new DataBindingProperty(() => 0, null!, "Value")); + Should.Throw(() => new DataBindingProperty(() => 0, _ => { }, null!)); + } + + #endregion + + private Layer AddLayer() + { + (Layer layer, _) = Harness.AddLayer(Harness.CreateProfile().GetRootFolder(), [Led(LedId.Keyboard_A)]); + return layer; + } + + private TestNumericNode BindNumber(FloatLayerProperty property, float value) + { + TestNumericNode node = _nodes.AddNode(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(0, TimeSpan.Zero, Easings.Functions.Linear, opacity)); + opacity.AddKeyframe(new LayerPropertyKeyframe(100, TimeSpan.FromSeconds(1), Easings.Functions.Linear, opacity)); + } + + private static DataBindingExitNode ExitNode(DataBinding dataBinding) + { + return dataBinding.Script.Nodes.OfType>().Single(); + } +} diff --git a/src/Artemis.Core.Tests/DataModels/DataModelEventTests.cs b/src/Artemis.Core.Tests/DataModels/DataModelEventTests.cs new file mode 100644 index 000000000..54566b6e4 --- /dev/null +++ b/src/Artemis.Core.Tests/DataModels/DataModelEventTests.cs @@ -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 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 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 dataModelEvent = new(); + + Should.Throw(() => dataModelEvent.Trigger(null!)); + dataModelEvent.TriggerCount.ShouldBe(0); + } + + [Fact] + public void EventWithArguments_OnlyKeepsHistoryWhenAskedTo() + { + DataModelEvent withoutHistory = new(); + DataModelEvent 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().Select(a => a.Value).ShouldBe([1, 2]); + } + + [Fact] + public void EventWithArgumentsHistory_KeepsTheLastTwentyTriggers() + { + DataModelEvent 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 dataModelEvent = new(true); + dataModelEvent.Trigger(new TestEventArgs()); + + dataModelEvent.TrackHistory = false; + + dataModelEvent.TrackHistory.ShouldBeFalse(); + dataModelEvent.EventArgumentsHistory.ShouldBeEmpty(); + } + + [Fact] + public void ResettingAnEventWithArguments_ClearsTheCountAndHistoryButNotTheLastArguments() + { + DataModelEvent 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 +} diff --git a/src/Artemis.Core.Tests/DataModels/DataModelPathTests.cs b/src/Artemis.Core.Tests/DataModels/DataModelPathTests.cs new file mode 100644 index 000000000..44c7eb910 --- /dev/null +++ b/src/Artemis.Core.Tests/DataModels/DataModelPathTests.cs @@ -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 {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(() => new DataModelPath((Artemis.Core.Modules.DataModel) null!)); + Should.Throw(() => new DataModelPath(null!, "Integer")); + Should.Throw(() => new DataModelPath(DataModel, null!)); + Should.Throw(() => 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 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 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(() => path.GetValue()); + Should.Throw(() => path.GetPropertyType()); + Should.Throw(() => path.GetPropertyInfo()); + Should.Throw(() => 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()); + } +} diff --git a/src/Artemis.Core.Tests/DataModels/DataModelTests.cs b/src/Artemis.Core.Tests/DataModels/DataModelTests.cs new file mode 100644 index 000000000..68d002dd9 --- /dev/null +++ b/src/Artemis.Core.Tests/DataModels/DataModelTests.cs @@ -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 child = DataModel.AddDynamicChild("Extra", 5); + + DataModel.DynamicChildren.ShouldContainKey("Extra"); + DataModel.GetDynamicChild("Extra").ShouldBeSameAs(child); + DataModel.GetDynamicChild("Extra").ShouldBeSameAs(child); + DataModel.TryGetDynamicChild("Extra", out DynamicChild? untyped).ShouldBeTrue(); + untyped.ShouldBeSameAs(child); + DataModel.TryGetDynamicChild("Extra", out DynamicChild? typed).ShouldBeTrue(); + typed.ShouldBeSameAs(child); + } + + [Fact] + public void DynamicChild_KeepsItsKeyTypeAndValue() + { + DynamicChild 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 child = DataModel.AddDynamicChild("currentScore", 5); + + child.Attribute.Name.ShouldBe("Current score"); + child.Attribute.Description.ShouldBeNull(); + } + + [Fact] + public void DynamicChild_UsesTheProvidedNameAndDescription() + { + DynamicChild 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 child = DataModel.AddDynamicChild("Extra", 5, attribute); + + child.Attribute.ShouldBeSameAs(attribute); + } + + [Fact] + public void DynamicChild_ThatIsADataModel_BelongsToTheSameModule() + { + TestChildDataModel childDataModel = new(); + + DynamicChild child = DataModel.AddDynamicChild("Extra", childDataModel, "Extra data"); + + childDataModel.Module.ShouldBeSameAs(_module); + childDataModel.DataModelDescription.ShouldBeSameAs(child.Attribute); + } + + [Fact] + public void AddingADynamicChild_RaisesDynamicChildAdded() + { + List added = []; + DataModel.DynamicChildAdded += (_, e) => added.Add(e); + + DynamicChild child = DataModel.AddDynamicChild("Extra", 5); + + added.ShouldHaveSingleItem(); + added[0].Key.ShouldBe("Extra"); + added[0].DynamicChild.ShouldBeSameAs(child); + } + + [Fact] + public void DynamicChild_KeyCannotContainADot() + { + Should.Throw(() => DataModel.AddDynamicChild("Extra.Points", 5)); + } + + [Fact] + public void DynamicChild_KeyCannotBeUsedTwice() + { + DataModel.AddDynamicChild("Extra", 5); + + Should.Throw(() => DataModel.AddDynamicChild("Extra", 6)); + } + + [Fact] + public void DynamicChild_KeyCannotBeTheNameOfARegularProperty() + { + Should.Throw(() => DataModel.AddDynamicChild("Integer", 5)); + } + + [Fact] + public void DynamicChild_CannotStartOutEmpty() + { + Should.Throw(() => DataModel.AddDynamicChild("Extra", null)); + } + + [Fact] + public void GettingAMissingDynamicChild_Throws() + { + Should.Throw(() => DataModel.GetDynamicChild("Missing")); + Should.Throw(() => DataModel.GetDynamicChild("Missing")); + } + + [Fact] + public void GettingADynamicChildAsTheWrongType_Throws() + { + DataModel.AddDynamicChild("Extra", 5); + + Should.Throw(() => DataModel.GetDynamicChild("Extra")); + } + + [Fact] + public void TryingToGetAMissingOrWronglyTypedDynamicChild_ReturnsFalse() + { + DataModel.AddDynamicChild("Extra", 5); + + DataModel.TryGetDynamicChild("Missing", out DynamicChild? missing).ShouldBeFalse(); + missing.ShouldBeNull(); + DataModel.TryGetDynamicChild("Extra", out DynamicChild? wrongType).ShouldBeFalse(); + wrongType.ShouldBeNull(); + } + + [Fact] + public void RemovingADynamicChildByKey_RemovesItAndRaisesDynamicChildRemoved() + { + DynamicChild child = DataModel.AddDynamicChild("Extra", 5); + List 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 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 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 added = []; + List 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 +} diff --git a/src/Artemis.Core.Tests/Infrastructure/ColorAssertions.cs b/src/Artemis.Core.Tests/Infrastructure/ColorAssertions.cs new file mode 100644 index 000000000..7a3a2f044 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/ColorAssertions.cs @@ -0,0 +1,20 @@ +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure; + +public static class ColorAssertions +{ + /// + /// Asserts the color matches per channel within the given tolerance, rendering results vary slightly between platforms + /// due to anti-aliasing and the render scale. + /// + 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})")}"); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/Layouts/Test AZERTY keys.xml b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/Layouts/Test AZERTY keys.xml new file mode 100644 index 000000000..22e407ff0 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/Layouts/Test AZERTY keys.xml @@ -0,0 +1,56 @@ + + + Test AZERTY keys + The letter keys around the movement keys of an AZERTY keyboard, where A sits above Q and Z above S + Keyboard + Artemis + Test AZERTY keys + 80 + 60 + 1 + 1 + + + 0 + 0 + 19 + 19 + + + 19 + 0 + 19 + 19 + + + 38 + 0 + 19 + 19 + + + 5 + 19 + 19 + 19 + + + 24 + 19 + 19 + 19 + + + 43 + 19 + 19 + 19 + + + 10 + 38 + 19 + 19 + + + diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/Layouts/Test LED strip.xml b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/Layouts/Test LED strip.xml new file mode 100644 index 000000000..d53e98e09 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/Layouts/Test LED strip.xml @@ -0,0 +1,134 @@ + + + Test LED strip + A strip of 20 small 3x3 LEDs, 4 units apart, smaller than a canvas pixel at low render scales + LedStripe + Artemis + Test LED strip + 82 + 7 + 1 + 1 + + + 2 + 2 + 3 + 3 + + + 6 + 2 + 3 + 3 + + + 10 + 2 + 3 + 3 + + + 14 + 2 + 3 + 3 + + + 18 + 2 + 3 + 3 + + + 22 + 2 + 3 + 3 + + + 26 + 2 + 3 + 3 + + + 30 + 2 + 3 + 3 + + + 34 + 2 + 3 + 3 + + + 38 + 2 + 3 + 3 + + + 42 + 2 + 3 + 3 + + + 46 + 2 + 3 + 3 + + + 50 + 2 + 3 + 3 + + + 54 + 2 + 3 + 3 + + + 58 + 2 + 3 + 3 + + + 62 + 2 + 3 + 3 + + + 66 + 2 + 3 + 3 + + + 70 + 2 + 3 + 3 + + + 74 + 2 + 3 + 3 + + + 78 + 2 + 3 + 3 + + + diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/NoOpUpdateQueue.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/NoOpUpdateQueue.cs new file mode 100644 index 000000000..03d6ff538 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/NoOpUpdateQueue.cs @@ -0,0 +1,14 @@ +using RGB.NET.Core; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders; + +/// +/// An update queue that discards its data, the trigger is never started so nothing runs in the background +/// +internal sealed class NoOpUpdateQueue() : UpdateQueue(new DeviceUpdateTrigger()) +{ + protected override bool Update(ReadOnlySpan<(object key, Color color)> dataSet) + { + return true; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestDeviceProvider.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestDeviceProvider.cs new file mode 100644 index 000000000..b367592cf --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestDeviceProvider.cs @@ -0,0 +1,36 @@ +using Artemis.Core.DeviceProviders; +using RGB.NET.Core; +using RGB.NET.Layout; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders; + +/// +/// A device provider feature that provides fake devices created from layout files. +/// +public class TestDeviceProvider : DeviceProvider +{ + private readonly TestRGBDeviceProvider _rgbDeviceProvider = new(); + + public override IRGBDeviceProvider RgbDeviceProvider => _rgbDeviceProvider; + + /// + /// Creates a fake device from the provided layout. + /// + /// The layout describing the device and its LEDs + /// Called whenever the device sends colors to the "hardware", after color corrections + public IRGBDevice CreateDevice(IDeviceLayout layout, Action ledsUpdated) + { + _rgbDeviceProvider.Definitions.Add((layout, ledsUpdated)); + _rgbDeviceProvider.Initialize(throwExceptions: true); + return _rgbDeviceProvider.Devices.Last(); + } + + public override void Enable() + { + } + + public override void Disable() + { + _rgbDeviceProvider.Dispose(); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestLayouts.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestLayouts.cs new file mode 100644 index 000000000..7bb5e15f7 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestLayouts.cs @@ -0,0 +1,17 @@ +namespace Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders; + +/// +/// Paths to the device layouts shipped with Artemis.Core, and test layouts shipped with the tests +/// +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"); + + /// + /// A strip of 20 small 3x3 LEDs, 4 units apart. Smaller than a canvas pixel at a render scale of 0.25 + /// + public static string SmallLedStrip => Path.Combine(AppContext.BaseDirectory, "TestLayouts", "Test LED strip.xml"); +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestRGBDevice.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestRGBDevice.cs new file mode 100644 index 000000000..46e5dda71 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestRGBDevice.cs @@ -0,0 +1,34 @@ +using RGB.NET.Core; +using RGB.NET.Layout; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders; + +/// +/// A fake device, similar to RGB.NET's DebugRGBDevice. +/// +/// 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. +/// +/// +internal sealed class TestRGBDevice : AbstractRGBDevice, IUnknownDevice +{ + private readonly Action _ledsUpdated; + + public TestRGBDevice(IDeviceLayout layout, Action 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 ledsToUpdate) + { + List 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); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestRGBDeviceInfo.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestRGBDeviceInfo.cs new file mode 100644 index 000000000..9bcecc3d6 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestRGBDeviceInfo.cs @@ -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; } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestRGBDeviceProvider.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestRGBDeviceProvider.cs new file mode 100644 index 000000000..6da1a7a3d --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/DeviceProviders/TestRGBDeviceProvider.cs @@ -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 LedsUpdated)> Definitions { get; } = []; + + protected override void InitializeSDK() + { + } + + protected override IEnumerable LoadDevices() + { + return Definitions.Select(d => new TestRGBDevice(d.Layout, d.LedsUpdated)); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/GradientTestBrush.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/GradientTestBrush.cs new file mode 100644 index 000000000..e6fcaf691 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/GradientTestBrush.cs @@ -0,0 +1,36 @@ +using Artemis.Core.LayerBrushes; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes; + +/// +/// Fills the layer with a horizontal gradient, useful to see the direction of transformations. +/// +public class GradientTestBrush : LayerBrush +{ + 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; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/GradientTestBrushProperties.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/GradientTestBrushProperties.cs new file mode 100644 index 000000000..192bb3bee --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/GradientTestBrushProperties.cs @@ -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() + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/PerLedTestBrush.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/PerLedTestBrush.cs new file mode 100644 index 000000000..1a3f8ba73 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/PerLedTestBrush.cs @@ -0,0 +1,32 @@ +using Artemis.Core.LayerBrushes; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes; + +/// +/// A per-LED brush that lets the test decide the color of each LED. +/// +public class PerLedTestBrush : PerLedLayerBrush +{ + /// + /// Gets or sets the function that picks the color of an LED, receives the LED and its point relative to the layer + /// + public Func 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); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/PerLedTestBrushProperties.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/PerLedTestBrushProperties.cs new file mode 100644 index 000000000..1f6c2a6ae --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/PerLedTestBrushProperties.cs @@ -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() + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/SolidColorTestBrush.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/SolidColorTestBrush.cs new file mode 100644 index 000000000..504df0e3c --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/SolidColorTestBrush.cs @@ -0,0 +1,26 @@ +using Artemis.Core.LayerBrushes; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes; + +public class SolidColorTestBrush : LayerBrush +{ + 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); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/SolidColorTestBrushProperties.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/SolidColorTestBrushProperties.cs new file mode 100644 index 000000000..f4ff11fb5 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/SolidColorTestBrushProperties.cs @@ -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() + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/TestLayerBrushProvider.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/TestLayerBrushProvider.cs new file mode 100644 index 000000000..5d460ef5e --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerBrushes/TestLayerBrushProvider.cs @@ -0,0 +1,20 @@ +using Artemis.Core.LayerBrushes; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes; + +/// +/// Provides deterministic brushes for render tests. The real brushes live in the Artemis.Plugins repository. +/// +public class TestLayerBrushProvider : LayerBrushProvider +{ + public override void Enable() + { + RegisterLayerBrushDescriptor("Solid color", "Fills the layer with a single color", "Brush"); + RegisterLayerBrushDescriptor("Gradient", "Fills the layer with a left to right gradient", "Gradient"); + RegisterLayerBrushDescriptor("Per LED", "Lets the test pick a color for each LED", "Led"); + } + + public override void Disable() + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/ChannelSwapTestEffect.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/ChannelSwapTestEffect.cs new file mode 100644 index 000000000..5b4582095 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/ChannelSwapTestEffect.cs @@ -0,0 +1,40 @@ +using Artemis.Core.LayerEffects; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects; + +/// +/// Gives the paint the element is composited with a color filter that swaps the red and blue channels, in pre-processing +/// +public class ChannelSwapTestEffect : LayerEffect +{ + 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) + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/ChannelSwapTestEffectProperties.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/ChannelSwapTestEffectProperties.cs new file mode 100644 index 000000000..8f127ceb9 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/ChannelSwapTestEffectProperties.cs @@ -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() + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/GlowTestEffect.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/GlowTestEffect.cs new file mode 100644 index 000000000..8a0c1068e --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/GlowTestEffect.cs @@ -0,0 +1,33 @@ +using Artemis.Core.LayerEffects; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects; + +/// +/// 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. +/// +public class GlowTestEffect : LayerEffect +{ + 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) + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/GlowTestEffectProperties.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/GlowTestEffectProperties.cs new file mode 100644 index 000000000..5f608fd3e --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/GlowTestEffectProperties.cs @@ -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() + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/OpacityTestEffect.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/OpacityTestEffect.cs new file mode 100644 index 000000000..7bf3305a6 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/OpacityTestEffect.cs @@ -0,0 +1,31 @@ +using Artemis.Core.LayerEffects; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects; + +/// +/// Lowers the alpha of the paint the element is composited with, in pre-processing +/// +public class OpacityTestEffect : LayerEffect +{ + 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) + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/OpacityTestEffectProperties.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/OpacityTestEffectProperties.cs new file mode 100644 index 000000000..63c65ddc9 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/OpacityTestEffectProperties.cs @@ -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() + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/TestLayerEffectProvider.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/TestLayerEffectProvider.cs new file mode 100644 index 000000000..64f1a44f9 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/TestLayerEffectProvider.cs @@ -0,0 +1,21 @@ +using Artemis.Core.LayerEffects; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects; + +/// +/// Provides deterministic effects for render tests. The real effects live in the Artemis.Plugins repository. +/// +public class TestLayerEffectProvider : LayerEffectProvider +{ + public override void Enable() + { + RegisterLayerEffectDescriptor("Opacity", "Pre-processes the paint by lowering its alpha", "Opacity"); + RegisterLayerEffectDescriptor("Channel swap", "Pre-processes the paint with a filter that swaps red and blue", "SwapHorizontal"); + RegisterLayerEffectDescriptor("Tint", "Post-processes the canvas by multiplying it with a color", "Palette"); + RegisterLayerEffectDescriptor("Glow", "Pre-processes the paint with a filter that grows the content outwards", "Brightness7"); + } + + public override void Disable() + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/TintTestEffect.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/TintTestEffect.cs new file mode 100644 index 000000000..1dd9f1e67 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/TintTestEffect.cs @@ -0,0 +1,32 @@ +using Artemis.Core.LayerEffects; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects; + +/// +/// Multiplies whatever was rendered with a color, in post-processing +/// +public class TintTestEffect : LayerEffect +{ + 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); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/TintTestEffectProperties.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/TintTestEffectProperties.cs new file mode 100644 index 000000000..e5b66add7 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/LayerEffects/TintTestEffectProperties.cs @@ -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() + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/Modules/TestDataModel.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/Modules/TestDataModel.cs new file mode 100644 index 000000000..64bd46f97 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/Modules/TestDataModel.cs @@ -0,0 +1,69 @@ +using Artemis.Core.Modules; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.Modules; + +/// +/// A data model with a property of every kind data model paths, bindings and conditions deal with. +/// Tests set the values directly. +/// +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; } + + /// + /// A nested data model, paths into it look like Child.Integer + /// + public TestChildDataModel Child { get; } = new(); + + /// + /// A nested plain object rather than a data model + /// + public TestItem Item { get; set; } = new(); + + public List Integers { get; set; } = []; + public List Items { get; set; } = []; + + /// + /// An event without arguments + /// + public DataModelEvent Event { get; } = new(); + + /// + /// An event with arguments, conditions can read them through paths like EventWithArgs.Value + /// + public DataModelEvent 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 +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/Modules/TestModule.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/Modules/TestModule.cs new file mode 100644 index 000000000..dc5ddd075 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/Modules/TestModule.cs @@ -0,0 +1,35 @@ +using Artemis.Core.Modules; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.Modules; + +/// +/// A module exposing a . Add it with , which also +/// registers the data model the way the data model service does. +/// +public class TestModule : Module +{ + /// + /// Gets or sets the activation requirements, none by default + /// + public List? Requirements { get; set; } + + /// + /// Gets the total time the module was updated for + /// + public double UpdatedFor { get; private set; } + + public override List? ActivationRequirements => Requirements; + + public override void Enable() + { + } + + public override void Disable() + { + } + + public override void Update(double deltaTime) + { + UpdatedFor += deltaTime; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/Nodes/DataModelTestNodeProvider.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/Nodes/DataModelTestNodeProvider.cs new file mode 100644 index 000000000..8c0a9510b --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/Nodes/DataModelTestNodeProvider.cs @@ -0,0 +1,33 @@ +using Artemis.Core.Nodes; +using Artemis.Core.Tests.Infrastructure.VisualScripting; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.Nodes; + +/// +/// 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. +/// +public class DataModelTestNodeProvider : NodeProvider +{ + public override void Enable() + { + RegisterNodeType(); + RegisterNodeType(); + RegisterNodeType(); + } + + public override void Disable() + { + } + + /// + /// Creates a registered node, the way the editor does, and adds it to the script. + /// + public T AddNode(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; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/Nodes/TestNodeProvider.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/Nodes/TestNodeProvider.cs new file mode 100644 index 000000000..aecca92c0 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/Nodes/TestNodeProvider.cs @@ -0,0 +1,32 @@ +using Artemis.Core.Nodes; +using Artemis.Core.Tests.Infrastructure.VisualScripting; + +namespace Artemis.Core.Tests.Infrastructure.Plugins.Nodes; + +/// +/// 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. +/// +public class TestNodeProvider : NodeProvider +{ + public override void Enable() + { + RegisterNodeType(); + RegisterNodeType(); + } + + public override void Disable() + { + } + + /// + /// Creates a registered test node, the way the editor does, and adds it to the script. + /// + public T AddNode(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; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Plugins/TestPlugin.cs b/src/Artemis.Core.Tests/Infrastructure/Plugins/TestPlugin.cs new file mode 100644 index 000000000..fa85d99a8 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Plugins/TestPlugin.cs @@ -0,0 +1,75 @@ +using System.Reflection; +using Artemis.Storage.Entities.Plugins; +using DryIoc; + +namespace Artemis.Core.Tests.Infrastructure.Plugins; + +/// +/// A plugin that hosts test features without going through plugin loading. +/// +/// 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. +/// +/// +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 _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()) + }; + } + + /// + /// Gets the underlying Artemis plugin + /// + public Plugin Plugin { get; } + + /// + /// Adds the feature to the plugin and enables it. + /// + public T AddFeature(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; + } + + /// + 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(); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Rendering/FrameAssertions.cs b/src/Artemis.Core.Tests/Infrastructure/Rendering/FrameAssertions.cs new file mode 100644 index 000000000..6306add8e --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Rendering/FrameAssertions.cs @@ -0,0 +1,26 @@ +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Rendering; + +public static class FrameAssertions +{ + /// + /// Asserts that, compared to , the affected LEDs changed according to + /// and every other LED (apart from the ignored ones) stayed the same. + /// Also asserts that most affected LEDs actually changed, to guard against a vacuous pass. + /// + public static void ShouldOnlyDifferFrom(this RenderFrame after, RenderFrame before, IReadOnlyCollection affected, Func expected, + IReadOnlyCollection? 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}"); + } + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderFrame.cs b/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderFrame.cs new file mode 100644 index 000000000..86e231be6 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderFrame.cs @@ -0,0 +1,70 @@ +using RGB.NET.Core; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Rendering; + +/// +/// The result of rendering a single frame: the raw canvas and the colors that ended up on each LED. +/// +public sealed class RenderFrame : IDisposable +{ + internal RenderFrame(TimeSpan time, SKBitmap canvas, IReadOnlyDictionary ledColors) + { + Time = time; + Canvas = canvas; + LedColors = ledColors; + } + + /// + /// Gets the profile time at which this frame was rendered + /// + public TimeSpan Time { get; } + + /// + /// Gets the raw canvas the profile rendered to, at the harness' render scale + /// + public SKBitmap Canvas { get; } + + /// + /// Gets the color of each LED as sampled from the canvas, this is what the hardware would show + /// + public IReadOnlyDictionary LedColors { get; } + + /// + /// Gets the color of the given LED on the given device + /// + 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]; + } + + /// + /// Writes canvas.png, leds.png and leds.json to render-output/<test namespace>/<test class>/<name> + /// in the test project folder and attaches them to the test result. + /// + /// The folder the files were written to + 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; + } + + /// + /// Creates a copy that remains usable after this frame is disposed + /// + internal RenderFrame Copy() + { + return new RenderFrame(Time, Canvas.Copy(), new Dictionary(LedColors)); + } + + /// + public void Dispose() + { + Canvas.Dispose(); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderHarness.cs b/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderHarness.cs new file mode 100644 index 000000000..d2aec92d1 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderHarness.cs @@ -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; + +/// +/// Renders profiles onto fake RGB.NET devices through Artemis' real render pipeline (, +/// sampling and RGB.NET LED updates) without starting any services or loading plugins. +/// Time is fully controlled by the harness, frames are only rendered when requested. +/// +public sealed class RenderHarness : IRenderer, IDisposable +{ + /// + /// The render scale Artemis uses by default + /// + public const float DefaultRenderScale = 0.5f; + + private readonly TestPlugin _plugin = new(); + private readonly TestLayerBrushProvider _brushProvider; + private readonly TestLayerEffectProvider _effectProvider; + private readonly SurfaceManager _surfaceManager; + private readonly List _devices = []; + private readonly List _renderExceptions = []; + private readonly Dictionary _deviceColors = []; + private readonly Dictionary _elapsed = []; + + private readonly int _previousRenderScaleMultiplier; + + private Action? _draw; + private SKBitmap? _lastCanvas; + private readonly List _profiles = []; + private bool _disposed; + + /// The scale of the canvas relative to the surface, Artemis defaults to 0.5 + public RenderHarness(float renderScale = DefaultRenderScale) + { + // RenderService keeps the static render scale multiplier, which snaps LED rectangles to whole canvas pixels, in sync + // with the render scale setting. Set it before any devices are created and restore it afterwards + _previousRenderScaleMultiplier = RenderScale.RenderScaleMultiplier; + RenderScale.SetRenderScaleMultiplier((int) (1 / renderScale)); + + _brushProvider = _plugin.AddFeature(new TestLayerBrushProvider()); + _effectProvider = _plugin.AddFeature(new TestLayerEffectProvider()); + _surfaceManager = new SurfaceManager(this, null, 30, renderScale); + + // RGB.NET swallows exceptions thrown during a surface update, collect them so they can fail the test + _surfaceManager.Surface.Exception += args => _renderExceptions.Add(args.Exception); + } + + /// + /// Gets the devices added to the harness + /// + public IReadOnlyList Devices => _devices; + + /// + /// Adds a fake device based on an RGB.NET layout file, see for the layouts shipped with Artemis. + /// + /// The path to the layout XML file + /// The X position of the device on the surface + /// The Y position of the device on the surface + public ArtemisDevice AddDevice(string layoutPath, float x = 0, float y = 0) + { + DeviceLayout layout = DeviceLayout.Load(layoutPath) ?? throw new ArgumentException($"Failed to load layout {layoutPath}", nameof(layoutPath)); + + // One provider per device keeps devices independent of each other + TestDeviceProvider deviceProvider = _plugin.AddFeature(new TestDeviceProvider()); + IRGBDevice rgbDevice = deviceProvider.CreateDevice(layout, CaptureLedColor); + + ArtemisDevice device = new(rgbDevice, deviceProvider) {X = x, Y = y}; + _devices.Add(device); + _surfaceManager.AddDevices([device]); + return device; + } + + /// + /// Removes the device from the surface, like disabling a device in Artemis does. + /// + public void RemoveDevice(ArtemisDevice device) + { + _surfaceManager.RemoveDevices([device]); + _devices.Remove(device); + } + + /// + /// Creates a new, empty profile containing only a root folder. + /// + public Profile CreateProfile(string name = "Test profile") + { + ProfileCategory category = new("Tests", 0); + ProfileConfiguration configuration = new(category, name, "TestTube"); + Profile profile = new(configuration, configuration.Entity.Profile); + configuration.Profile = profile; + _profiles.Add(profile); + return profile; + } + + /// + /// Adds a layer to the provided parent that uses the provided test brush. + /// + /// Note: like any layer created in code, its timeline starts at the end. Call + /// on the profile (like the editor does) or use when a test depends on the timeline + /// starting at the beginning. + /// + /// + /// The folder (or profile root folder) to add the layer to + /// The LEDs the layer should cover + /// The name of the layer + /// + /// The position among the parent's children (0-based), defaults to the end. The first child renders on top. + /// + /// The type of test brush to use, must be registered by + /// The layer and its brush, the brush exposes the brush properties + public (Layer Layer, TBrush Brush) AddLayer(Folder parent, IEnumerable leds, string name = "Test layer", int? order = null) where TBrush : BaseLayerBrush + { + // 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(layer)); + } + + /// + /// Gives the layer a new instance of the provided test brush. + /// + /// The type of test brush to use, must be registered by + public TBrush ChangeBrush(Layer layer) where TBrush : BaseLayerBrush + { + LayerBrushDescriptor descriptor = _brushProvider.LayerBrushDescriptors.Single(d => d.LayerBrushType == typeof(TBrush)); + layer.ChangeLayerBrush(descriptor.CreateInstance(layer, null)); + return (TBrush) layer.LayerBrush!; + } + + /// + /// Adds an empty folder to the provided parent. + /// + /// The folder (or profile root folder) to add the folder to + /// The name of the folder + /// + /// The position among the parent's children (0-based), defaults to the end. The first child renders on top. + /// + public Folder AddFolder(Folder parent, string name = "Test folder", int? order = null) + { + // Like the Layer constructor, the Folder constructor does not add the folder to the parent + Folder folder = new(parent, name); + parent.AddChild(folder, order); + return folder; + } + + /// + /// Adds a test effect to the provided layer or folder. + /// + /// The type of test effect to use, must be registered by + public TEffect AddEffect(RenderProfileElement element) where TEffect : BaseLayerEffect + { + LayerEffectDescriptor descriptor = _effectProvider.LayerEffectDescriptors.Single(d => d.LayerEffectType == typeof(TEffect)); + TEffect effect = (TEffect) descriptor.CreateInstance(element, null); + element.AddLayerEffect(effect); + return effect; + } + + /// + /// Saves the profile and loads it back into a new profile instance, the way Artemis stores and activates profiles. + /// + /// The reloaded profile, its time starts at zero + public Profile SaveAndReload(Profile profile) + { + return Load(Save(profile)); + } + + /// + /// Saves the profile the way Artemis stores it. + /// + /// A copy of the stored entity, detached from the profile + public ProfileEntity Save(Profile profile) + { + // Mirrors ProfileService.SaveProfile + profile.Save(); + foreach (RenderProfileElement element in profile.GetAllRenderElements()) + element.Save(); + + // The database stores the profile entity as JSON + return JsonSerializer.Deserialize(JsonSerializer.Serialize(profile.ProfileEntity))!; + } + + /// + /// Loads a stored profile onto the devices of this harness, the way Artemis activates profiles. + /// + public Profile Load(ProfileEntity entity, string name = "Test profile") + { + // Mirrors ProfileService.ActivateProfile + ProfileCategory category = new("Tests", 0); + ProfileConfiguration configuration = new(category, name, "TestTube"); + configuration.Entity.Profile = entity; + Profile profile = new(configuration, entity); + profile.PopulateLeds(_devices); + configuration.Profile = profile; + _profiles.Add(profile); + return profile; + } + + /// + /// Advances the profile by and renders a single frame. + /// + public RenderFrame Render(Profile profile, TimeSpan delta) + { + // Layers skip updating their properties when their timeline did not move, Artemis itself never renders a zero delta + if (delta <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(delta), delta, "Frames must advance time, layers don't update their properties on a zero delta"); + + TimeSpan time = GetElapsed(profile) + delta; + RenderFrame frame = RenderSurface(time, canvas => + { + profile.Update(delta.TotalSeconds); + profile.Render(canvas, SKPointI.Empty, null); + }); + + if (profile.Exceptions.Count > 0) + throw new AggregateException("The profile threw exceptions while rendering", profile.Exceptions); + + _elapsed[profile] = time; + return frame; + } + + /// + /// Renders a frame by drawing directly onto the canvas instead of rendering a profile, for testing the render pipeline + /// itself. Like a profile, the drawing uses surface coordinates, the canvas is already scaled to the render scale. + /// + public RenderFrame Render(Action draw) + { + return RenderSurface(TimeSpan.Zero, draw); + } + + private RenderFrame RenderSurface(TimeSpan time, Action draw) + { + _draw = draw; + _renderExceptions.Clear(); + try + { + // Synchronously runs SurfaceManager's update which calls back into Render and PostRender below + _surfaceManager.Surface.Update(true); + } + finally + { + _draw = null; + } + + if (_renderExceptions.Count > 0) + throw new AggregateException("The surface threw exceptions while rendering", _renderExceptions); + + // LEDs that were not sent a color this frame keep showing their previous color, like real hardware + Dictionary ledColors = _devices + .SelectMany(d => d.Leds) + .ToDictionary(l => l, l => _deviceColors.TryGetValue(l.RgbLed, out SKColor color) ? color : SKColors.Transparent); + + SKBitmap canvas = _lastCanvas ?? throw new InvalidOperationException("The surface did not render a frame"); + _lastCanvas = null; + return new RenderFrame(time, canvas, ledColors); + } + + /// + /// Renders the profile at each of the provided timestamps, advancing time between them. + /// + /// Timestamps are the total time the harness rendered the profile for, each must be later than the time the + /// profile is currently at. + /// + /// + public RenderTimeline RenderTimeline(Profile profile, params TimeSpan[] timestamps) + { + List frames = []; + foreach (TimeSpan timestamp in timestamps.Order()) + frames.Add(Render(profile, timestamp - GetElapsed(profile))); + + return new RenderTimeline(frames); + } + + /// + /// Gets the total time the harness rendered the profile for + /// + public TimeSpan GetElapsed(Profile profile) + { + return _elapsed.GetValueOrDefault(profile); + } + + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + // Profiles subscribe to static events of Core's stores, disposing them unsubscribes them again. Otherwise every + // profile of every test stays in memory and receives the store events of later tests + foreach (Profile profile in _profiles) + { + if (!profile.Disposed) + profile.Dispose(); + profile.Configuration.Dispose(); + } + + _surfaceManager.Dispose(); + _plugin.Dispose(); + RenderScale.SetRenderScaleMultiplier(_previousRenderScaleMultiplier); + } + + void IRenderer.Render(SKCanvas canvas, double delta) + { + // Ignore the surface's wall-clock delta, the harness controls time + _draw?.Invoke(canvas); + } + + void IRenderer.PostRender(SKTexture texture) + { + // Ownership of the bitmap moves to the RenderFrame + using SKImage snapshot = texture.Surface.Snapshot(); + _lastCanvas = SKBitmap.FromImage(snapshot); + } + + private void CaptureLedColor(Led led, Color color) + { + // Called by the test device when the surface flushes LEDs, these are the colors the hardware would receive + _deviceColors[led] = new SKColor(color.GetR(), color.GetG(), color.GetB(), color.GetA()); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderOutput.cs b/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderOutput.cs new file mode 100644 index 000000000..77c92cd3e --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderOutput.cs @@ -0,0 +1,171 @@ +using System.Collections.Concurrent; +using System.Globalization; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Rendering; + +/// +/// Writes render results to disk and attaches them to the test result so they can be inspected by humans and LLMs. +/// +internal static class RenderOutput +{ + private const float LedViewScale = 3; + private const int StripHeaderHeight = 24; + + private static readonly ConcurrentDictionary SaveCounts = []; + + /// + /// Gets the root folder all output is written to, render-output in the test project folder + /// + public static string Root { get; } = Path.Combine(FindProjectDirectory(), "render-output"); + + /// + /// Gets a boolean indicating whether tests should write their render output even when they pass, enabled by setting + /// the ARTEMIS_RENDER_OUTPUT environment variable to always + /// + public static bool AlwaysWrite { get; } = string.Equals(Environment.GetEnvironmentVariable("ARTEMIS_RENDER_OUTPUT"), "always", StringComparison.OrdinalIgnoreCase); + + /// + /// Starts a save under render-output/<test namespace>/<test class>/<name>, replacing previous + /// output of the same name. + /// + 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, '/')); + } + + /// + /// Draws every LED at its position on the surface, filled with the color it received and labelled with its ID + /// + 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; + } + + /// + /// Stacks a bitmap per frame top to bottom, each with a header showing the frame time + /// + public static SKBitmap DrawStrip(IReadOnlyList frames, Func draw) + { + List 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; + } + + /// + /// Gets the path of the running test class relative to the root namespace, e.g. Profiles/TimelineTests + /// + 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; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderOutputWriter.cs b/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderOutputWriter.cs new file mode 100644 index 000000000..bca4eaedc --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderOutputWriter.cs @@ -0,0 +1,66 @@ +using System.Text; +using System.Text.Json; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Rendering; + +/// +/// 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. +/// +internal sealed class RenderOutputWriter : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() {WriteIndented = true}; + + private readonly string _name; + private readonly List _files = []; + + public RenderOutputWriter(string directory, string name) + { + Directory = directory; + _name = name; + } + + /// + /// Gets the folder the files are written to + /// + 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); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderTestBase.cs b/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderTestBase.cs new file mode 100644 index 000000000..91d6755aa --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderTestBase.cs @@ -0,0 +1,118 @@ +using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders; +using RGB.NET.Core; + +namespace Artemis.Core.Tests.Infrastructure.Rendering; + +/// +/// Base class for tests that render onto a single ANSI keyboard +/// +public abstract class RenderTestBase : IDisposable +{ + private readonly List _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; } + + /// + /// Gets the name of the running test, useful as the name of saved render output + /// + protected static string TestName => TestContext.Current.TestCase?.TestMethodName ?? "Unknown"; + + /// + /// Gets the keyboard LED with the given ID + /// + protected ArtemisLed Led(LedId ledId) + { + return Keyboard.GetLed(ledId, false) ?? throw new ArgumentException($"The keyboard has no LED {ledId}", nameof(ledId)); + } + + /// + /// Gets the keyboard LEDs with the given IDs + /// + protected ArtemisLed[] Leds(params LedId[] ledIds) + { + return ledIds.Select(Led).ToArray(); + } + + /// + /// Gets the top letter row, Q through P, ordered left to right + /// + 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 + ); + + /// + /// Advances the profile by the given amount of seconds and renders a frame, see . + /// + protected RenderFrame Render(Profile profile, double seconds = 0.1) + { + RenderFrame frame = Harness.Render(profile, TimeSpan.FromSeconds(seconds)); + SaveOutput(frame); + return frame; + } + + /// + /// Renders the profile at each of the given timestamps (in seconds), see . + /// + protected RenderTimeline RenderTimeline(Profile profile, params double[] seconds) + { + RenderTimeline timeline = Harness.RenderTimeline(profile, seconds.Select(TimeSpan.FromSeconds).ToArray()); + SaveOutput(timeline); + return timeline; + } + + /// + /// Saves the frame as render output once the test finished, but only if the test failed or + /// is enabled. Writing the output takes far longer than rendering. + /// + /// The frame to save, may be disposed by the test + /// The name to save the frame under, defaults to the name of the test + protected void SaveOutput(RenderFrame frame, string? name = null) + { + string outputName = name ?? TestName; + RenderFrame copy = frame.Copy(); + _pendingOutput.Add((copy, () => copy.Save(outputName))); + } + + /// + protected void SaveOutput(RenderTimeline timeline, string? name = null) + { + string outputName = name ?? TestName; + RenderTimeline copy = timeline.Copy(); + _pendingOutput.Add((copy, () => copy.Save(outputName))); + } + + /// + /// Disposes the provided object at the end of the test + /// + protected T Track(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); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderTimeline.cs b/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderTimeline.cs new file mode 100644 index 000000000..5fe592a9d --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Rendering/RenderTimeline.cs @@ -0,0 +1,50 @@ +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Rendering; + +/// +/// A sequence of frames rendered at increasing timestamps +/// +public sealed class RenderTimeline : IDisposable +{ + internal RenderTimeline(IReadOnlyList frames) + { + Frames = frames; + } + + public IReadOnlyList Frames { get; } + + public RenderFrame this[int index] => Frames[index]; + + /// + /// Writes canvas-strip.png, leds-strip.png (all frames stacked top to bottom) and timeline.json to + /// render-output/<test namespace>/<test class>/<name> in the test project folder and attaches + /// them to the test result. + /// + /// The folder the files were written to + 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; + } + + /// + /// Creates a copy that remains usable after this timeline is disposed + /// + internal RenderTimeline Copy() + { + return new RenderTimeline(Frames.Select(f => f.Copy()).ToList()); + } + + /// + public void Dispose() + { + foreach (RenderFrame frame in Frames) + frame.Dispose(); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Services/DefaultProfileTestModule.cs b/src/Artemis.Core.Tests/Infrastructure/Services/DefaultProfileTestModule.cs new file mode 100644 index 000000000..1489786c5 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Services/DefaultProfileTestModule.cs @@ -0,0 +1,17 @@ +using Artemis.Core.Tests.Infrastructure.Plugins.Modules; + +namespace Artemis.Core.Tests.Infrastructure.Services; + +/// +/// A test module that ships a default profile, like game modules do +/// +public class DefaultProfileTestModule : TestModule +{ + /// The category the default profile goes in + /// The full path of an exported profile + 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); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Services/ProfileServiceTestBase.cs b/src/Artemis.Core.Tests/Infrastructure/Services/ProfileServiceTestBase.cs new file mode 100644 index 000000000..d0429bca7 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Services/ProfileServiceTestBase.cs @@ -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; + +/// +/// Base class for tests on the profile service, with helpers to build profiles the way the editor does. +/// +public abstract class ProfileServiceTestBase : ServiceTestBase +{ + protected IProfileService ProfileService => Resolve(); + + /// + /// Gets the category the default categories end with + /// + protected ProfileCategory General => ProfileService.ProfileCategories.Single(c => c.Name == "General"); + + /// + /// Gets the categories as stored in the database, ordered by their order + /// + protected List GetStoredCategories() + { + using var context = Services.CreateDbContext(); + return context.ProfileCategories.Include(c => c.ProfileConfigurations).OrderBy(c => c.Order).ToList(); + } + + /// + /// Gets the profile configurations as stored in the database + /// + protected List GetStoredProfiles() + { + using var context = Services.CreateDbContext(); + return context.ProfileContainers.ToList(); + } + + /// + /// Adds a layer covering the provided LEDs to the profile, using the test brush of the provided type. + /// + protected static Layer AddLayer(Profile profile, TestLayerBrushProvider brushes, IEnumerable 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; + } + + /// + /// Adds the test effect of the provided type to the element. + /// + protected static TEffect AddEffect(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; + } + + /// + /// Makes the activation condition of the profile configuration follow the value of the returned node + /// + protected static TestBooleanNode UseActivationCondition(ProfileConfiguration configuration, TestNodeProvider nodes, bool isMet) + { + TestBooleanNode node = nodes.AddNode(configuration.ActivationCondition); + node.Value = isMet; + configuration.ActivationCondition.Nodes.OfType>().Single().Input.ConnectTo(node.Output); + return node; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Services/ServiceHarness.cs b/src/Artemis.Core.Tests/Infrastructure/Services/ServiceHarness.cs new file mode 100644 index 000000000..916646142 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Services/ServiceHarness.cs @@ -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; + +/// +/// 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. +/// +/// 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. +/// +/// +/// Services are created when they are first resolved, nothing is initialized or started. Avoid anything that works +/// on or directly, such as loading plugins +/// or starting the web server, those still point at your real Artemis folders. +/// +/// +public sealed class ServiceHarness : IDisposable +{ + private static readonly string RunFolder = CreateRunFolder(); + private static readonly Lazy TemplateDatabase = new(CreateTemplateDatabase); + + private readonly List _profileServices = []; + private bool _disposed; + + /// Optionally replaces or adds registrations before anything is resolved + public ServiceHarness(Action? 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(Reuse.Singleton, ifAlreadyRegistered: IfAlreadyRegistered.Replace); + + // Remember profile services so their profiles can be disposed, see Dispose + Container.RegisterInitializer((service, _) => _profileServices.Add(service)); + + configure?.Invoke(Container); + } + + /// + /// Gets the temporary data folder containing the database, removed when the test run ends + /// + public string DataFolder { get; } + + /// + /// Gets the container the services are registered in + /// + public IContainer Container { get; } + + /// + /// Resolves a service, including protected services like the settings service which the container only hands to + /// classes outside the plugins folder. + /// + public T Resolve() where T : notnull + { + return Container.New>().Service; + } + + /// + /// Creates a database context on the database of this harness, for checking what a service stored + /// + public ArtemisDbContext CreateDbContext() + { + return new ArtemisDbContext {DataFolder = DataFolder}; + } + + /// + 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 + } + } +} + +/// +/// 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. +/// +internal sealed class ServiceResolver(T service) +{ + public T Service { get; } = service; +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Services/ServiceTestBase.cs b/src/Artemis.Core.Tests/Infrastructure/Services/ServiceTestBase.cs new file mode 100644 index 000000000..f59842b23 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Services/ServiceTestBase.cs @@ -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; + +/// +/// Base class for tests on Artemis.Core services. Gives every test a fresh and a +/// that the plugin management service knows about, as if Artemis had loaded it from the +/// plugins folder. +/// +/// Features are enabled through the plugin management service with , 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. +/// +/// +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 plugins = (List) PluginsField.GetValue(PluginManagement)!; + lock (plugins) + { + plugins.Add(TestPlugin.Plugin); + } + } + + protected ServiceHarness Services { get; } + protected TestPlugin TestPlugin { get; } + + protected IPluginManagementService PluginManagement => Services.Resolve(); + + protected T Resolve() where T : notnull + { + return Services.Resolve(); + } + + /// + /// 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. + /// + protected T EnableFeature(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; + } + + /// + /// Disables the feature through the plugin management service, like a user disabling it in the settings does. + /// + protected void DisableFeature(PluginFeature feature) + { + PluginManagement.DisablePluginFeature(feature, false); + } + + /// + /// 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. + /// + /// The layouts of the devices, one device per layout, see + /// The device provider, its devices are on + protected TestDeviceProvider AddDeviceProvider(params string[] layoutPaths) + { + return AddDeviceProvider(new TestDeviceProvider(), layoutPaths); + } + + /// + /// Enables the device provider and adds devices created from layout files through the device service. + /// + /// 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. + /// + /// + protected T AddDeviceProvider(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().AddDeviceProvider(deviceProvider); + return deviceProvider; + } + + /// + /// Creates a new profile service on the same database, like starting Artemis again + /// + protected IProfileService CreateNewProfileService() + { + return Services.Container.New(); + } + + /// + /// Gets the module service with its 2 second activation timer stopped, tests call + /// themselves. Use this rather than . + /// + protected IModuleService ResolveModuleService() + { + _moduleService ??= Resolve(); + ((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(), 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 plugins = (List) PluginsField.GetValue(PluginManagement)!; + lock (plugins) + { + plugins.Remove(TestPlugin.Plugin); + } + + Services.Dispose(); + TestPlugin.Dispose(); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Services/SuspendableTestDeviceProvider.cs b/src/Artemis.Core.Tests/Infrastructure/Services/SuspendableTestDeviceProvider.cs new file mode 100644 index 000000000..7ca54436f --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Services/SuspendableTestDeviceProvider.cs @@ -0,0 +1,25 @@ +using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders; + +namespace Artemis.Core.Tests.Infrastructure.Services; + +/// +/// A test device provider that supports being suspended, like providers that need to let go of the hardware while the +/// computer sleeps +/// +public class SuspendableTestDeviceProvider : TestDeviceProvider +{ + public SuspendableTestDeviceProvider() + { + SuspendSupported = true; + } + + /// + /// Gets the number of times the provider was suspended + /// + public int Suspended { get; private set; } + + public override void Suspend() + { + Suspended++; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Services/TestInputProvider.cs b/src/Artemis.Core.Tests/Infrastructure/Services/TestInputProvider.cs new file mode 100644 index 000000000..598c9d3a1 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Services/TestInputProvider.cs @@ -0,0 +1,18 @@ +using Artemis.Core.Services; + +namespace Artemis.Core.Tests.Infrastructure.Services; + +/// +/// An input provider the test presses keys on, the real providers hook into the operating system +/// +public class TestInputProvider : InputProvider +{ + /// + /// Presses and releases the key + /// + public void Press(KeyboardKey key) + { + OnKeyboardDataReceived(null, key, true); + OnKeyboardDataReceived(null, key, false); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Services/TestLoggerFactory.cs b/src/Artemis.Core.Tests/Infrastructure/Services/TestLoggerFactory.cs new file mode 100644 index 000000000..951f8e51c --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Services/TestLoggerFactory.cs @@ -0,0 +1,31 @@ +using Artemis.Core.DryIoc.Factories; +using Serilog; +using Serilog.Core; +using Serilog.Events; + +namespace Artemis.Core.Tests.Infrastructure.Services; + +/// +/// Creates loggers that write to the output of the running test instead of to log files in the Artemis data folder. +/// +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 : "")}"); + } + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Services/TypeColorTestNodeProvider.cs b/src/Artemis.Core.Tests/Infrastructure/Services/TypeColorTestNodeProvider.cs new file mode 100644 index 000000000..d2d0024fd --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Services/TypeColorTestNodeProvider.cs @@ -0,0 +1,23 @@ +using Artemis.Core.Nodes; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.Services; + +/// +/// A node provider that only registers pin type colors, the way node plugins give their pin types a color +/// +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(NumberColor); + RegisterTypeColor(TextColor); + } + + public override void Disable() + { + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Storage/JsonTestExtensions.cs b/src/Artemis.Core.Tests/Infrastructure/Storage/JsonTestExtensions.cs new file mode 100644 index 000000000..8fb241144 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Storage/JsonTestExtensions.cs @@ -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}; + + /// + /// Checks that the JSON holds the same values as the expected JSON, the order of properties doesn't matter. + /// Shows both when they differ. + /// + 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)} + """); + } + + /// + /// Finds every node of every node script in the JSON, in old and new profiles alike + /// + public static List FindNodes(this JsonNode root) + { + List 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; + } + } + } + + /// + /// Finds the node with the given ID anywhere in the JSON + /// + public static JsonObject FindNode(this JsonNode root, string id) + { + return root.FindNodes().Single(n => n["Id"]?.GetValue() == id); + } + + /// + /// Gets the text of every $type left anywhere in the JSON + /// + public static List FindTypeNames(this JsonNode root) + { + List 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; + } + } + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Storage/OldProfileJson.cs b/src/Artemis.Core.Tests/Infrastructure/Storage/OldProfileJson.cs new file mode 100644 index 000000000..3a832f639 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Storage/OldProfileJson.cs @@ -0,0 +1,866 @@ +using System.Text.Json.Nodes; + +namespace Artemis.Core.Tests.Infrastructure.Storage; + +/// +/// Profile JSON the way older versions of Artemis stored and exported it, for testing the profile migrations. +/// +/// Before version 3 profiles were written by JSON.NET with type names turned on: every object starts with a +/// $type naming its .NET class and every list is wrapped in {"$type": ..., "$values": [...]}. Node +/// storage is a JSON string inside the JSON, written the same way. Before version 1 the general nodes lived in +/// the Artemis.VisualScripting.Nodes namespace and nodes had no ProviderId. +/// +/// +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"; + + /// + /// A profile configuration from before version 1, it has no Version yet. Its activation condition reads a + /// data model value. + /// + 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 + } + """; + + /// + /// A profile from before version 1 with nodes in every place the migrations look for them: + /// + /// the static display condition of the root folder + /// a data binding on a property of the blur effect on the root folder + /// the event display condition of the layer + /// a data binding in the transform properties of the layer + /// a data binding in a property group inside the brush properties of the layer + /// + /// The layer also has adaption hints and LEDs, whose type names have to survive the conversion. + /// + 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" + } + } + ] + } + } + """; + + /// + /// after all migrations, as stored by the current version + /// + 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 + } + """; + + /// + /// after all migrations, as stored by the current version + /// + 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" + } + } + ] + } + """; + + /// + /// Parses one of the JSON strings above into a fresh, editable object + /// + public static JsonObject Parse(string json) + { + return JsonNode.Parse(json)!.AsObject(); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Storage/RecordingLogger.cs b/src/Artemis.Core.Tests/Infrastructure/Storage/RecordingLogger.cs new file mode 100644 index 000000000..a8311b34f --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Storage/RecordingLogger.cs @@ -0,0 +1,26 @@ +using Serilog; +using Serilog.Core; +using Serilog.Events; + +namespace Artemis.Core.Tests.Infrastructure.Storage; + +/// +/// A logger that keeps everything logged to it, for checking what a class reported +/// +public sealed class RecordingLogger : ILogEventSink +{ + public RecordingLogger() + { + Logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(this).CreateLogger(); + } + + public ILogger Logger { get; } + public List Events { get; } = []; + + public IEnumerable Errors => Events.Where(e => e.Level == LogEventLevel.Error); + + public void Emit(LogEvent logEvent) + { + Events.Add(logEvent); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/Storage/StoredProfiles.cs b/src/Artemis.Core.Tests/Infrastructure/Storage/StoredProfiles.cs new file mode 100644 index 000000000..34d7d6dc8 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/Storage/StoredProfiles.cs @@ -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; + +/// +/// Helpers for putting profiles in a test database the way older versions of Artemis left them, and reading back the +/// JSON that is stored +/// +public static class StoredProfiles +{ + /// + /// Creates the profile migrations Artemis.Core registers + /// + public static List AllMigrations(ILogger logger) + { + return [new M0001NodeProviders(), new M0002NodeProvidersProfileConfig(), new M0003SystemTextJson(), new M0004NodeStorage(logger)]; + } + + /// + /// Adds a profile category to the database + /// + public static ProfileCategoryEntity AddCategory(Func createContext, string name = "Category") + { + using ArtemisDbContext context = createContext(); + ProfileCategoryEntity category = new() {Name = name}; + context.ProfileCategories.Add(category); + context.SaveChanges(); + return category; + } + + /// + /// The JSON of 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 + /// + public static string Version0ConfigurationWithVersion() + { + JsonObject configuration = OldProfileJson.Parse(OldProfileJson.Version0Configuration); + configuration["Version"] = 0; + return configuration.ToJsonString(); + } + + /// + /// Writes a profile container with the given JSON straight into the database, without going through Entity + /// Framework + /// + public static Guid InsertRaw(Func 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(), profileJson, categoryId, configurationJson); + return id; + } + + /// + /// Reads the JSON of a profile container straight from the database + /// + public static (string Configuration, string Profile) ReadRaw(Func createContext, Guid id) + { + using ArtemisDbContext context = createContext(); + RawProfileContainer container = context.Database + .SqlQueryRaw("SELECT Id, Profile, ProfileConfiguration FROM ProfileContainers WHERE Id = {0}", id) + .AsEnumerable() + .Single(); + return (container.ProfileConfiguration, container.Profile); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/ScriptTestBase.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/ScriptTestBase.cs new file mode 100644 index 000000000..b11c2ad5b --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/ScriptTestBase.cs @@ -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; + +/// +/// Base class for visual scripting tests. Registers the test nodes of both and +/// and disposes the scripts a test creates, since scripts listen to the static +/// node type store until they are disposed. +/// +public abstract class ScriptTestBase : IDisposable +{ + private readonly List _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; } + + /// + /// Creates a new script that is disposed at the end of the test + /// + protected NodeScript CreateScript(string name = "Test", List? defaultNodes = null) + { + return Track(new NodeScript(name, "A test script", null, defaultNodes)); + } + + /// + /// 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. + /// + protected NodeScript SaveAndLoad(NodeScript script, List? defaultNodes = null) + { + script.Save(); + return Load(script.Entity, script.Name, defaultNodes); + } + + /// + /// Loads a new script from a copy of the provided entity. The new script is disposed at the end of the test. + /// + protected NodeScript Load(NodeScriptEntity entity, string name = "Test", List? defaultNodes = null) + { + return Track(new NodeScript(name, "A test script", Copy(entity), null, defaultNodes)); + } + + /// + /// Copies the entity through JSON, so a loaded script never shares the entity of the script that saved it + /// + protected static NodeScriptEntity Copy(NodeScriptEntity entity) + { + return CoreJson.Deserialize(CoreJson.Serialize(entity))!; + } + + protected T Track(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 +{ + /// + /// Gets the exit node of the script, the node whose input becomes the result of the script + /// + public static ExitNode GetExitNode(this NodeScript script) + { + return script.Nodes.OfType>().Single(); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/ScriptTestNodeProvider.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/ScriptTestNodeProvider.cs new file mode 100644 index 000000000..06afae5b6 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/ScriptTestNodeProvider.cs @@ -0,0 +1,47 @@ +using Artemis.Core.Nodes; +using Artemis.Core.Tests.Infrastructure.Plugins; +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +/// +/// 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 needs before it creates pins for text +/// properties. +/// +public class ScriptTestNodeProvider : NodeProvider +{ + public override void Enable() + { + RegisterNodeType(); + RegisterNodeType(); + RegisterNodeType(); + RegisterNodeType(); + RegisterNodeType(); + RegisterTypeColor(SKColors.Orange); + } + + public override void Disable() + { + } + + /// + /// Creates a registered node, the way the editor does, and adds it to the script. + /// + public T AddNode(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; + } + + /// + /// Gets the ID a gets once it is added to the given plugin, saved nodes refer + /// to their provider by this ID. + /// + public static string IdFor(TestPlugin plugin) + { + return $"{typeof(ScriptTestNodeProvider).FullName}-{plugin.Plugin.Guid.ToString()[..8]}"; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestBooleanNode.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestBooleanNode.cs new file mode 100644 index 000000000..58fe77403 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestBooleanNode.cs @@ -0,0 +1,30 @@ +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +/// +/// 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 . +/// +[Node("Test boolean", "Outputs a boolean controlled by the test", "Tests", OutputType = typeof(bool))] +public class TestBooleanNode : Node +{ + public TestBooleanNode() + { + Output = CreateOutputPin(); + } + + public OutputPin Output { get; } + + /// + /// Gets or sets the value the node outputs + /// + public bool Value + { + get => Storage; + set => Storage = value; + } + + public override void Evaluate() + { + Output.Value = Value; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestColorNode.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestColorNode.cs new file mode 100644 index 000000000..9926a9e6a --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestColorNode.cs @@ -0,0 +1,32 @@ +using SkiaSharp; + +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +/// +/// 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 . +/// +[Node("Test color", "Outputs a color controlled by the test", "Tests", OutputType = typeof(SKColor))] +public class TestColorNode : Node +{ + public TestColorNode() + { + Output = CreateOutputPin(); + } + + public OutputPin Output { get; } + + /// + /// Gets or sets the color the node outputs + /// + public SKColor Value + { + get => Storage; + set => Storage = value; + } + + public override void Evaluate() + { + Output.Value = Value; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestConditions.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestConditions.cs new file mode 100644 index 000000000..0875e4ff3 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestConditions.cs @@ -0,0 +1,28 @@ +using Artemis.Core.Internal; + +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +public static class TestConditions +{ + /// + /// Gives the element a static display condition whose script is driven by the returned node. + /// + 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>().Single().Input.ConnectTo(node.Output); + + element.DisplayCondition = condition; + return node; + } + + /// + /// Gives the element a play once display condition. + /// + public static void UsePlayOnceCondition(RenderProfileElement element) + { + element.DisplayCondition = new PlayOnceCondition(element); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestDataModelNumberNode.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestDataModelNumberNode.cs new file mode 100644 index 000000000..821c680cf --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestDataModelNumberNode.cs @@ -0,0 +1,60 @@ +using Artemis.Storage.Entities.Profile; + +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +/// +/// 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 +/// . +/// +[Node("Test data model number", "Outputs the number at a data model path", "Tests", OutputType = typeof(Numeric))] +public class TestDataModelNumberNode : Node, IDisposable +{ + private DataModelPath? _path; + + public TestDataModelNumberNode() + { + Output = CreateOutputPin(); + StorageModified += (_, _) => ClearPath(); + } + + public OutputPin Output { get; } + + /// + /// Gets the path the node reads, created from storage the first time the node is evaluated + /// + public DataModelPath? Path => _path; + + /// + /// Makes the node read the value at the provided path + /// + 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 GetFeatureDependencies() + { + return base.GetFeatureDependencies().Concat(_path?.GetFeatureDependencies() ?? []); + } + + public void Dispose() + { + ClearPath(); + } + + private void ClearPath() + { + _path?.Dispose(); + _path = null; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestGreaterThanNode.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestGreaterThanNode.cs new file mode 100644 index 000000000..1555e17ac --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestGreaterThanNode.cs @@ -0,0 +1,39 @@ +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +/// +/// 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 +/// . +/// +[Node("Test greater than", "Outputs whether the input is greater than a number", "Tests", InputType = typeof(Numeric), OutputType = typeof(bool))] +public class TestGreaterThanNode : Node +{ + public TestGreaterThanNode() + { + Input = CreateInputPin(); + Output = CreateOutputPin(); + } + + public InputPin Input { get; } + public OutputPin Output { get; } + + /// + /// Gets or sets the number the input must be greater than + /// + public float Threshold + { + get => Storage; + set => Storage = value; + } + + /// + /// Gets the last input the node saw + /// + public float LastInput { get; private set; } + + public override void Evaluate() + { + LastInput = Input.Value; + Output.Value = LastInput > Threshold; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestNode.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestNode.cs new file mode 100644 index 000000000..3b9f4528a --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestNode.cs @@ -0,0 +1,64 @@ +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +/// +/// 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. +/// +public class TestNode : Node +{ + public TestNode(string name = "Test node") : base(name, "") + { + } + + /// + /// Gets or sets what the node does when it is evaluated + /// + public Action? OnEvaluate { get; set; } + + /// + /// Gets the number of times the node was evaluated + /// + public int Evaluations { get; private set; } + + public override void Evaluate() + { + Evaluations++; + OnEvaluate?.Invoke(this); + } +} + +/// +/// 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. +/// +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(); + } + + public OutputPin Output { get; } + + public float Value { get; set; } + + public override void Evaluate() + { + Output.Value = new Numeric(Value); + } +} + +/// +/// A node that tracks whether it was disposed. +/// +public class TestDisposableNode : TestNode, IDisposable +{ + public bool IsDisposed { get; private set; } + + public void Dispose() + { + IsDisposed = true; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestNumericNode.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestNumericNode.cs new file mode 100644 index 000000000..e9331ca3b --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestNumericNode.cs @@ -0,0 +1,36 @@ +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +/// +/// 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 . +/// +[Node("Test number", "Outputs a number controlled by the test", "Tests", OutputType = typeof(Numeric))] +public class TestNumericNode : Node +{ + public TestNumericNode() + { + Output = CreateOutputPin(); + } + + public OutputPin Output { get; } + + /// + /// Gets or sets the value the node outputs + /// + public float Value + { + get => Storage; + set => Storage = value; + } + + /// + /// Gets the number of times the node was evaluated + /// + public int Evaluations { get; private set; } + + public override void Evaluate() + { + Evaluations++; + Output.Value = new Numeric(Value); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestSettingsNode.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestSettingsNode.cs new file mode 100644 index 000000000..dd2350714 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestSettingsNode.cs @@ -0,0 +1,25 @@ +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +/// +/// 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. +/// +[Node("Test settings", "Stores settings", "Tests", "https://example.com/node-from-attribute")] +public class TestSettingsNode : Node +{ + 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 Numbers { get; set; } = []; +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestSumNode.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestSumNode.cs new file mode 100644 index 000000000..f6b095e2c --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestSumNode.cs @@ -0,0 +1,28 @@ +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +/// +/// A node that adds up the numbers on its inputs. The inputs are a pin collection that starts with two pins. +/// +[Node("Test sum", "Adds up its inputs", "Tests", InputType = typeof(Numeric), OutputType = typeof(Numeric))] +public class TestSumNode : Node +{ + public TestSumNode() + { + Values = CreateInputPinCollection("Values", 2); + Result = CreateOutputPin("Result"); + } + + public InputPinCollection Values { get; } + public OutputPin Result { get; } + + /// + /// Gets the number of times the node was evaluated + /// + public int Evaluations { get; private set; } + + public override void Evaluate() + { + Evaluations++; + Result.Value = Values.Values.Sum(); + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestTextNode.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestTextNode.cs new file mode 100644 index 000000000..d0a4858ea --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestTextNode.cs @@ -0,0 +1,26 @@ +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +/// +/// A node that outputs text the test controls. The text is stored, so it survives saving and loading a script. +/// +[Node("Test text", "Outputs text controlled by the test")] +public class TestTextNode : Node +{ + public TestTextNode() + { + Output = CreateOutputPin(); + } + + public OutputPin Output { get; } + + public string? Value + { + get => Storage; + set => Storage = value; + } + + public override void Evaluate() + { + Output.Value = Value; + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestThrowingNode.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestThrowingNode.cs new file mode 100644 index 000000000..a7f851283 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestThrowingNode.cs @@ -0,0 +1,50 @@ +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +/// +/// 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. +/// +[Node("Test throwing", "Fails when told to", "Tests", OutputType = typeof(Numeric))] +public class TestThrowingNode : Node +{ + public TestThrowingNode() + { + Output = CreateOutputPin(); + } + + public OutputPin Output { get; } + + /// + /// Gets or sets the number the node outputs while it isn't failing + /// + public float Value { get; set; } + + /// + /// Gets or sets when the node fails + /// + 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 + } +} diff --git a/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestViewModelNode.cs b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestViewModelNode.cs new file mode 100644 index 000000000..39f0c2427 --- /dev/null +++ b/src/Artemis.Core.Tests/Infrastructure/VisualScripting/TestViewModelNode.cs @@ -0,0 +1,30 @@ +namespace Artemis.Core.Tests.Infrastructure.VisualScripting; + +/// +/// A node with a custom view model, the view model is created through the plugin's container. +/// +[Node("Test view model")] +public class TestViewModelNode : Node +{ + 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 { } + } +} diff --git a/src/Artemis.Core.Tests/Models/ColorGradientTests.cs b/src/Artemis.Core.Tests/Models/ColorGradientTests.cs new file mode 100644 index 000000000..f40505c3e --- /dev/null +++ b/src/Artemis.Core.Tests/Models/ColorGradientTests.cs @@ -0,0 +1,1049 @@ +using System.Collections; +using System.Collections.Specialized; +using SkiaSharp; + +namespace Artemis.Core.Tests.Models; + +public class ColorGradientTests +{ + #region GetColor + + [Theory] + [InlineData(0f, 255, 0, 0)] + [InlineData(0.5f, 127, 0, 127)] + [InlineData(1f, 0, 0, 255)] + public void GetColor_InterpolatesBetweenStops(float position, byte red, byte green, byte blue) + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + gradient.GetColor(position).ShouldBe(new SKColor(red, green, blue)); + } + + [Fact] + public void GetColor_ClampsOutsideOfStops() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0.25f), new ColorGradientStop(SKColors.Blue, 0.75f)]; + + gradient.GetColor(0f).ShouldBe(SKColors.Red); + gradient.GetColor(1f).ShouldBe(SKColors.Blue); + } + + [Theory] + [InlineData(0.1f)] + [InlineData(0.9f)] + [InlineData(-1f)] + [InlineData(2f)] + public void GetColor_UsesTheEdgeColorsBeforeTheFirstAndAfterTheLastStop(float position) + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0.25f), new ColorGradientStop(SKColors.Blue, 0.75f)]; + + gradient.GetColor(position).ShouldBe(position < 0.5f ? SKColors.Red : SKColors.Blue); + } + + [Fact] + public void GetColor_OnAnEmptyGradientIsTransparent() + { + new ColorGradient().GetColor(0.5f).ShouldBe(SKColors.Transparent); + } + + [Theory] + [InlineData(0f)] + [InlineData(0.5f)] + [InlineData(1f)] + public void GetColor_WithASingleStopIsThatStopsColorEverywhere(float position) + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Lime, 0.3f)]; + + gradient.GetColor(position).ShouldBe(SKColors.Lime); + } + + [Fact] + public void GetColor_InterpolatesBetweenTheTwoStopsAroundThePosition() + { + ColorGradient gradient = + [ + new ColorGradientStop(SKColors.Red, 0), + new ColorGradientStop(new SKColor(0, 200, 0), 0.5f), + new ColorGradientStop(SKColors.Blue, 1) + ]; + + gradient.GetColor(0.5f).ShouldBe(new SKColor(0, 200, 0)); + gradient.GetColor(0.75f).ShouldBe(new SKColor(0, 100, 127)); + gradient.GetColor(0.25f).ShouldBe(new SKColor(127, 100, 0)); + } + + [Fact] + public void GetColor_InterpolatesAlpha() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.White, 0), new ColorGradientStop(SKColors.White.WithAlpha(0), 1)]; + + gradient.GetColor(0.5f).ShouldBe(SKColors.White.WithAlpha(127)); + } + + #endregion + + #region Colors and positions + + [Fact] + public void ColorsAndPositions_FollowTheStopsInOrder() + { + ColorGradient gradient = + [ + new ColorGradientStop(SKColors.Blue, 1), + new ColorGradientStop(SKColors.Red, 0), + new ColorGradientStop(SKColors.Lime, 0.5f) + ]; + + gradient.Colors.ShouldBe([SKColors.Red, SKColors.Lime, SKColors.Blue]); + gradient.Positions.ShouldBe([0f, 0.5f, 1f]); + } + + [Fact] + public void ColorsAndPositions_ReflectLaterChanges() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + _ = gradient.Colors; + _ = gradient.Positions; + + gradient[1].Color = SKColors.Lime; + gradient.Add(new ColorGradientStop(SKColors.White, 0.5f)); + + gradient.Colors.ShouldBe([SKColors.Red, SKColors.White, SKColors.Lime]); + gradient.Positions.ShouldBe([0f, 0.5f, 1f]); + + gradient.Clear(); + + gradient.Colors.ShouldBeEmpty(); + gradient.Positions.ShouldBeEmpty(); + } + + #endregion + + #region Adding, removing and ordering stops + + [Fact] + public void Add_KeepsStopsOrderedByPosition() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + ColorGradientStop middle = new(SKColors.Lime, 0.5f); + + gradient.Add(middle); + + gradient.IndexOf(middle).ShouldBe(1); + gradient.Select(s => s.Position).ShouldBe([0f, 0.5f, 1f]); + } + + [Fact] + public void Add_RaisesAnAddAndThenAMoveIntoPlace() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + List events = RecordCollectionChanges(gradient); + ColorGradientStop middle = new(SKColors.Lime, 0.5f); + + gradient.Add(middle); + + events.Count.ShouldBe(2); + events[0].Action.ShouldBe(NotifyCollectionChangedAction.Add); + events[0].NewStartingIndex.ShouldBe(2); + events[0].NewItems![0].ShouldBeSameAs(middle); + events[1].Action.ShouldBe(NotifyCollectionChangedAction.Move); + events[1].OldStartingIndex.ShouldBe(2); + events[1].NewStartingIndex.ShouldBe(1); + } + + [Fact] + public void Add_MovesAStopOnAnOccupiedPositionSlightlyDown() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 0.5f)]; + ColorGradientStop stop = new(SKColors.Lime, 0.5f); + + gradient.Add(stop); + + // Pinned as-is: stops step down 0.001 at a time until they are at least 0.001 away from other stops, but 0.5 - 0.499 + // comes out a hair under 0.001 in floating point, so the stop takes a second step and ends up 0.002 lower + stop.Position.ShouldBe(0.498f, 0.0001f); + gradient.IndexOf(stop).ShouldBe(1); + } + + [Fact] + public void Add_MovesAStopOnAnOccupiedStartSlightlyUp() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0)]; + ColorGradientStop stop = new(SKColors.Lime, 0); + + gradient.Add(stop); + + stop.Position.ShouldBe(0.001f, 0.0001f); + } + + [Fact] + public void Add_SkipsOverEveryOccupiedPosition() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0.5f), new ColorGradientStop(SKColors.Red, 0.499f)]; + ColorGradientStop stop = new(SKColors.Lime, 0.5f); + + gradient.Add(stop); + + stop.Position.ShouldBe(0.496f, 0.0001f); + } + + [Fact] + public void Insert_KeepsStopsOrderedByPosition() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + ColorGradientStop middle = new(SKColors.Lime, 0.5f); + + gradient.Insert(0, middle); + + gradient.IndexOf(middle).ShouldBe(1); + } + + [Fact] + public void Insert_RaisesTheMoveBeforeTheAdd() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + List events = RecordCollectionChanges(gradient); + + gradient.Insert(0, new ColorGradientStop(SKColors.Lime, 0.5f)); + + // Pinned as-is: Insert reorders before reporting the add, so listeners hear about a move of a stop they + // were never told about, and the add then reports the index the stop ended up at. Add does it the other way around + events.Select(e => e.Action).ShouldBe([NotifyCollectionChangedAction.Move, NotifyCollectionChangedAction.Add]); + events[1].NewStartingIndex.ShouldBe(1); + } + + [Fact] + public void Insert_DoesNotMoveAStopOffAnOccupiedPosition() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 0.5f)]; + ColorGradientStop stop = new(SKColors.Lime, 0.5f); + + gradient.Insert(0, stop); + + // Pinned as-is: unlike Add, Insert doesn't link the stop to the gradient, so two stops can share a position. + // Moving the stop later doesn't avoid other stops either + stop.Position.ShouldBe(0.5f); + stop.Position = 0f; + stop.Position.ShouldBe(0f); + gradient.Count(s => s.Position == 0f).ShouldBe(2); + } + + [Fact] + public void Indexer_ReplacesTheStopAndRaisesAReplace() + { + ColorGradientStop red = new(SKColors.Red, 0); + ColorGradient gradient = [red, new ColorGradientStop(SKColors.Blue, 1)]; + List events = RecordCollectionChanges(gradient); + ColorGradientStop lime = new(SKColors.Lime, 0.2f); + + gradient[0] = lime; + + gradient[0].ShouldBeSameAs(lime); + events.Single().Action.ShouldBe(NotifyCollectionChangedAction.Replace); + events.Single().NewItems![0].ShouldBeSameAs(lime); + events.Single().OldItems![0].ShouldBeSameAs(red); + } + + [Fact] + public void Indexer_ReordersAReplacementOnAnotherPosition() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 0.5f)]; + ColorGradientStop last = new(SKColors.Lime, 1f); + + gradient[0] = last; + + gradient.Select(s => s.Color).ShouldBe([SKColors.Blue, SKColors.Lime]); + } + + [Fact] + public void Indexer_StopsListeningToTheReplacedStop() + { + ColorGradientStop red = new(SKColors.Red, 0); + ColorGradient gradient = [red, new ColorGradientStop(SKColors.Blue, 1)]; + int changes = 0; + gradient.StopChanged += (_, _) => changes++; + + gradient[0] = new ColorGradientStop(SKColors.Lime, 0); + red.Color = SKColors.White; + gradient[0].Color = SKColors.Black; + + changes.ShouldBe(1); + } + + [Fact] + public void Remove_RemovesTheStopAndRaisesARemove() + { + ColorGradientStop blue = new(SKColors.Blue, 1); + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), blue]; + List events = RecordCollectionChanges(gradient); + + gradient.Remove(blue).ShouldBeTrue(); + + gradient.Count.ShouldBe(1); + events.Single().Action.ShouldBe(NotifyCollectionChangedAction.Remove); + events.Single().OldStartingIndex.ShouldBe(1); + events.Single().OldItems![0].ShouldBeSameAs(blue); + } + + [Fact] + public void Remove_OfAStopThatIsNotInTheGradientDoesNothing() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0)]; + List events = RecordCollectionChanges(gradient); + + gradient.Remove(new ColorGradientStop(SKColors.Blue, 0)).ShouldBeFalse(); + + gradient.Count.ShouldBe(1); + events.ShouldBeEmpty(); + } + + [Fact] + public void Remove_OfAnEqualStopRemovesTheGradientsStopButKeepsListeningToIt() + { + ColorGradientStop red = new(SKColors.Red, 0); + ColorGradient gradient = [red, new ColorGradientStop(SKColors.Blue, 1)]; + int changes = 0; + gradient.StopChanged += (_, _) => changes++; + + gradient.Remove(new ColorGradientStop(SKColors.Red, 0)).ShouldBeTrue(); + red.Color = SKColors.White; + + // Pinned as-is: stops are equal when their color and position match, so removing a look-alike removes the + // gradient's own stop. Only the look-alike is unsubscribed though, the gradient keeps reacting to the removed stop + gradient.Count.ShouldBe(1); + changes.ShouldBe(1); + } + + [Fact] + public void Remove_StopsListeningToTheStop() + { + ColorGradientStop blue = new(SKColors.Blue, 1); + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), blue]; + int changes = 0; + gradient.StopChanged += (_, _) => changes++; + + gradient.Remove(blue); + blue.Color = SKColors.White; + + changes.ShouldBe(0); + } + + [Fact] + public void Remove_LeavesTheStopLinkedToTheGradient() + { + ColorGradientStop red = new(SKColors.Red, 0); + ColorGradientStop blue = new(SKColors.Blue, 1); + ColorGradient gradient = [red, blue]; + + gradient.Remove(blue); + blue.Position = 0f; + + // Pinned as-is: a removed stop still avoids the positions of the stops in the gradient it was removed from + blue.Position.ShouldBe(0.001f, 0.0001f); + } + + [Fact] + public void RemoveAt_RemovesTheStop() + { + ColorGradientStop red = new(SKColors.Red, 0); + ColorGradient gradient = [red, new ColorGradientStop(SKColors.Blue, 1)]; + int changes = 0; + gradient.StopChanged += (_, _) => changes++; + + gradient.RemoveAt(0); + red.Color = SKColors.White; + + gradient.Single().Color.ShouldBe(SKColors.Blue); + changes.ShouldBe(0); + } + + [Fact] + public void RemoveAt_ReportsTheIndexAsTheRemovedStop() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + List events = RecordCollectionChanges(gradient); + + gradient.RemoveAt(1); + + // Pinned as-is: the index is passed where the event expects the removed item, so listeners get the number 1 as + // the removed stop and no index. Remove reports the stop and its index properly + events.Single().Action.ShouldBe(NotifyCollectionChangedAction.Remove); + events.Single().OldItems![0].ShouldBe(1); + events.Single().OldStartingIndex.ShouldBe(-1); + } + + [Fact] + public void Clear_RemovesAllStopsAndRaisesAReset() + { + ColorGradientStop red = new(SKColors.Red, 0); + ColorGradient gradient = [red, new ColorGradientStop(SKColors.Blue, 1)]; + List events = RecordCollectionChanges(gradient); + int changes = 0; + gradient.StopChanged += (_, _) => changes++; + + gradient.Clear(); + red.Color = SKColors.White; + + gradient.Count.ShouldBe(0); + events.Single().Action.ShouldBe(NotifyCollectionChangedAction.Reset); + changes.ShouldBe(0); + } + + [Fact] + public void ListMembers_WorkLikeAList() + { + ColorGradientStop red = new(SKColors.Red, 0); + ColorGradientStop blue = new(SKColors.Blue, 1); + ColorGradient gradient = [red, blue]; + + gradient.Contains(red).ShouldBeTrue(); + gradient.Contains(new ColorGradientStop(SKColors.Red, 0)).ShouldBeTrue("stops are compared by color and position"); + gradient.IndexOf(blue).ShouldBe(1); + gradient.IsReadOnly.ShouldBeFalse(); + + ColorGradientStop[] array = new ColorGradientStop[3]; + gradient.CopyTo(array, 1); + array.ShouldBe([null!, red, blue]); + } + + [Fact] + public void NonGenericListMembers_OnlyAcceptStops() + { + ColorGradientStop red = new(SKColors.Red, 0); + IList list = new ColorGradient {red}; + + list.Add("not a stop").ShouldBe(-1); + list.Insert(0, "not a stop"); + list.Remove("not a stop"); + list.Count.ShouldBe(1); + + ColorGradientStop blue = new(SKColors.Blue, 1); + list.Add(blue).ShouldBe(1); + list.Contains(blue).ShouldBeTrue(); + list.IndexOf(blue).ShouldBe(1); + list[1].ShouldBeSameAs(blue); + + ColorGradientStop lime = new(SKColors.Lime, 0.5f); + list.Insert(0, lime); + list.IndexOf(lime).ShouldBe(1); + + ColorGradientStop white = new(SKColors.White, 0); + list[0] = white; + list[0].ShouldBeSameAs(white); + + list.Remove(lime); + list.Count.ShouldBe(2); + + ColorGradientStop[] array = new ColorGradientStop[2]; + list.CopyTo(array, 0); + array.ShouldBe([white, blue]); + + list.IsFixedSize.ShouldBeFalse(); + list.IsSynchronized.ShouldBeFalse(); + list.SyncRoot.ShouldBeSameAs(list); + } + + #endregion + + #region Stop changes + + [Fact] + public void StopChanged_IsRaisedWhenAStopChangesColor() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + int changes = 0; + gradient.StopChanged += (_, _) => changes++; + + gradient[0].Color = SKColors.Lime; + + changes.ShouldBe(1); + } + + [Fact] + public void StopChanged_IsNotRaisedWhenNothingChanges() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + int changes = 0; + gradient.StopChanged += (_, _) => changes++; + + gradient[0].Color = SKColors.Red; + gradient[0].Position = 0; + + changes.ShouldBe(0); + } + + [Fact] + public void MovingAStop_KeepsStopsOrderedAndRaisesStopChanged() + { + ColorGradientStop red = new(SKColors.Red, 0); + ColorGradient gradient = [red, new ColorGradientStop(SKColors.Lime, 0.5f), new ColorGradientStop(SKColors.Blue, 1)]; + List events = RecordCollectionChanges(gradient); + int changes = 0; + gradient.StopChanged += (_, _) => changes++; + + red.Position = 0.75f; + + gradient.Select(s => s.Color).ShouldBe([SKColors.Lime, SKColors.Red, SKColors.Blue]); + changes.ShouldBe(1); + events.ShouldAllBe(e => e.Action == NotifyCollectionChangedAction.Move); + } + + #endregion + + #region Seamless + + [Fact] + public void IsSeamless_WhenTheFirstAndLastColorMatch() + { + ColorGradient seamless = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 0.5f), new ColorGradientStop(SKColors.Red, 1)]; + ColorGradient notSeamless = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + seamless.IsSeamless().ShouldBeTrue(); + notSeamless.IsSeamless().ShouldBeFalse(); + new ColorGradient().IsSeamless().ShouldBeTrue(); + } + + [Fact] + public void ToggleSeamless_SqueezesTheStopsAndRepeatsTheFirstColorAtTheEnd() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + gradient.ToggleSeamless(); + + gradient.Select(s => s.Color).ShouldBe([SKColors.Red, SKColors.Blue, SKColors.Red]); + gradient.Select(s => s.Position).ShouldBe([0f, 0.5f, 1f]); + gradient.IsSeamless().ShouldBeTrue(); + } + + [Fact] + public void ToggleSeamless_TwiceRestoresTheGradient() + { + ColorGradient gradient = + [ + new ColorGradientStop(SKColors.Red, 0), + new ColorGradientStop(SKColors.Lime, 0.5f), + new ColorGradientStop(SKColors.Blue, 1) + ]; + ColorGradient original = new(gradient); + + gradient.ToggleSeamless(); + gradient.Select(s => s.Position).ShouldBe([0f, 0.333f, 0.667f, 1f]); + gradient.ToggleSeamless(); + + gradient.Select(s => s.Color).ShouldBe(original.Select(s => s.Color)); + gradient.Select(s => s.Position).ShouldBe([0f, 0.5f, 1f], 0.001f); + } + + [Fact] + public void ToggleSeamless_OnAGradientThatWasMadeSeamlessByHand() + { + ColorGradient gradient = + [ + new ColorGradientStop(SKColors.Red, 0), + new ColorGradientStop(SKColors.Lime, 0.5f), + new ColorGradientStop(SKColors.Blue, 0.9f), + new ColorGradientStop(SKColors.Red, 1) + ]; + + gradient.ToggleSeamless(); + + // Pinned as-is: the remaining stops are stretched as if they were squeezed by an earlier toggle. Blue would land + // past the end (1.35) so it stays where it was, the cap of 100 in ToggleSeamless looks like it was meant to be 1 + gradient.Select(s => s.Color).ShouldBe([SKColors.Red, SKColors.Lime, SKColors.Blue]); + gradient.Select(s => s.Position).ShouldBe([0f, 0.75f, 0.9f]); + } + + [Fact] + public void ToggleSeamless_OnAnEmptyGradientThrows() + { + // Pinned as-is: an empty gradient counts as seamless, so toggling tries to remove a last stop that isn't there + Should.Throw(() => new ColorGradient().ToggleSeamless()); + } + + #endregion + + #region Spreading, flipping and rotating + + [Fact] + public void SpreadStops_SpacesTheStopsEvenly() + { + ColorGradient gradient = + [ + new ColorGradientStop(SKColors.Red, 0.1f), + new ColorGradientStop(SKColors.Lime, 0.2f), + new ColorGradientStop(SKColors.Blue, 0.3f), + new ColorGradientStop(SKColors.White, 0.4f) + ]; + + gradient.SpreadStops(); + + gradient.Select(s => s.Position).ShouldBe([0f, 0.333f, 0.667f, 1f]); + gradient.Select(s => s.Color).ShouldBe([SKColors.Red, SKColors.Lime, SKColors.Blue, SKColors.White]); + } + + [Fact] + public void SpreadStops_AvoidsAStopThatIsStillInTheWay() + { + ColorGradient gradient = + [ + new ColorGradientStop(SKColors.Red, 0), + new ColorGradientStop(SKColors.Lime, 0.1f), + new ColorGradientStop(SKColors.Blue, 0.333f), + new ColorGradientStop(SKColors.White, 0.5f) + ]; + + gradient.SpreadStops(); + + // Pinned as-is: stops are moved one by one, Lime can't take 0.333 while Blue still sits there and moves down instead + gradient.Select(s => s.Position).ShouldBe([0f, 0.331f, 0.667f, 1f], 0.0001f); + } + + [Fact] + public void SpreadStops_WithASingleStopGivesItNoPosition() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0.5f)]; + + gradient.SpreadStops(); + + // Pinned as-is: spreading divides by the number of gaps, which is zero for a single stop + float.IsNaN(gradient[0].Position).ShouldBeTrue(); + } + + [Fact] + public void FlipStops_MirrorsThePositions() + { + ColorGradient gradient = + [ + new ColorGradientStop(SKColors.Red, 0.1f), + new ColorGradientStop(SKColors.Lime, 0.25f), + new ColorGradientStop(SKColors.Blue, 0.8f) + ]; + + gradient.FlipStops(); + + gradient.Select(s => s.Color).ShouldBe([SKColors.Blue, SKColors.Lime, SKColors.Red]); + gradient.Select(s => s.Position).ShouldBe([0.2f, 0.75f, 0.9f], 0.0001f); + } + + [Fact] + public void FlipStops_WithStopsOnMirroredPositions() + { + ColorGradient gradient = + [ + new ColorGradientStop(SKColors.Red, 0), + new ColorGradientStop(SKColors.Lime, 0.25f), + new ColorGradientStop(SKColors.Blue, 1) + ]; + + gradient.FlipStops(); + + // Pinned as-is: stops are moved one by one, Red can't take 1 while Blue still sits there so it moves down (by + // 0.002, see Add_MovesAStopOnAnOccupiedPositionSlightlyDown). + // The same happens to any pair of stops on mirrored positions, like 0.1 and 0.9 + gradient.Select(s => s.Color).ShouldBe([SKColors.Blue, SKColors.Lime, SKColors.Red]); + gradient.Select(s => s.Position).ShouldBe([0f, 0.75f, 0.998f], 0.0001f); + } + + [Fact] + public void RotateStops_MovesEveryStopToTheNextPosition() + { + ColorGradient gradient = + [ + new ColorGradientStop(SKColors.Red, 0.1f), + new ColorGradientStop(SKColors.Lime, 0.5f), + new ColorGradientStop(SKColors.Blue, 0.9f) + ]; + + gradient.RotateStops(false); + + // Pinned as-is: stops are moved one by one, starting with the last stop which goes to the position of the first. + // The first stop is still there at that moment, so the last stop lands just below it + gradient.Select(s => s.Color).ShouldBe([SKColors.Blue, SKColors.Red, SKColors.Lime]); + gradient.Select(s => s.Position).ShouldBe([0.099f, 0.5f, 0.9f], 0.0001f); + } + + [Fact] + public void RotateStops_InverseMovesEveryStopToThePreviousPosition() + { + ColorGradient gradient = + [ + new ColorGradientStop(SKColors.Red, 0.1f), + new ColorGradientStop(SKColors.Lime, 0.5f), + new ColorGradientStop(SKColors.Blue, 0.9f) + ]; + + gradient.RotateStops(true); + + // Pinned as-is: like rotating forwards, the first stop to move lands just below the stop that still occupies its + // new position + gradient.Select(s => s.Color).ShouldBe([SKColors.Lime, SKColors.Blue, SKColors.Red]); + gradient.Select(s => s.Position).ShouldBe([0.1f, 0.5f, 0.898f], 0.0001f); + } + + [Fact] + public void RotateStops_WithStopsOnBothEnds() + { + ColorGradient forward = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Lime, 0.5f), new ColorGradientStop(SKColors.Blue, 1)]; + ColorGradient backward = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Lime, 0.5f), new ColorGradientStop(SKColors.Blue, 1)]; + + forward.RotateStops(false); + backward.RotateStops(true); + + // Pinned as-is: see above, at the start of the gradient there is no room below so the stop lands just above 0 + forward.Select(s => s.Color).ShouldBe([SKColors.Blue, SKColors.Red, SKColors.Lime]); + forward.Select(s => s.Position).ShouldBe([0.001f, 0.5f, 1f], 0.0001f); + backward.Select(s => s.Color).ShouldBe([SKColors.Lime, SKColors.Blue, SKColors.Red]); + backward.Select(s => s.Position).ShouldBe([0f, 0.5f, 0.998f], 0.0001f); + } + + [Fact] + public void BulkChanges_RaiseStopChangedForEveryMovedStop() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0.1f), new ColorGradientStop(SKColors.Blue, 0.8f)]; + int changes = 0; + gradient.StopChanged += (_, _) => changes++; + + gradient.FlipStops(); + + changes.ShouldBe(2); + } + + #endregion + + #region Random and preset gradients + + [Theory] + [InlineData(2)] + [InlineData(5)] + public void Randomize_ReplacesTheStopsWithEvenlySpacedVividColors(int stops) + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Black, 0.5f)]; + + gradient.Randomize(stops); + + gradient.Count.ShouldBe(stops); + gradient.Select(s => s.Position).ShouldBe(Enumerable.Range(0, stops).Select(i => i / (stops - 1f)), 0.0001f); + foreach (ColorGradientStop stop in gradient) + { + stop.Color.ToHsv(out _, out float saturation, out float value); + saturation.ShouldBe(100f, 0.5f); + value.ShouldBe(100f, 0.5f); + } + } + + [Fact] + public void Randomize_WithASingleStopGivesItNoPosition() + { + ColorGradient gradient = []; + + gradient.Randomize(1); + + // Pinned as-is: like SpreadStops, the spacing divides by the number of gaps, which is zero for a single stop + float.IsNaN(gradient.Single().Position).ShouldBeTrue(); + } + + [Fact] + public void GetRandom_CreatesAGradientWithTheGivenAmountOfStops() + { + ColorGradient gradient = ColorGradient.GetRandom(4); + + gradient.Count.ShouldBe(4); + gradient.First().Position.ShouldBe(0f); + gradient.Last().Position.ShouldBe(1f, 0.0001f); + } + + [Fact] + public void GetUnicornBarf_IsASeamlessRainbow() + { + ColorGradient gradient = ColorGradient.GetUnicornBarf(); + + gradient.Count.ShouldBe(9); + gradient.IsSeamless().ShouldBeTrue(); + gradient.Select(s => s.Position).ShouldBe(Enumerable.Range(0, 9).Select(i => i / 8f), 0.0001f); + gradient.Colors.ShouldBe([ + new SKColor(0xFFFF0000), new SKColor(0xFFFF9900), new SKColor(0xFFFFFF00), new SKColor(0xFF00FF00), new SKColor(0xFF00FF7E), + new SKColor(0xFF0078FF), new SKColor(0xFF9E22FF), new SKColor(0xFFFF34AE), new SKColor(0xFFFF0000) + ]); + } + + [Fact] + public void GetUnicornBarf_ReturnsANewGradientEachTime() + { + ColorGradient first = ColorGradient.GetUnicornBarf(); + first[0].Color = SKColors.Black; + + ColorGradient.GetUnicornBarf()[0].Color.ShouldBe(SKColors.Red); + } + + #endregion + + #region Interpolating gradients + + [Fact] + public void Interpolate_BlendsEveryStopTowardsTheTarget() + { + ColorGradient from = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + ColorGradient to = [new ColorGradientStop(SKColors.Black, 0.5f), new ColorGradientStop(SKColors.White, 1)]; + + ColorGradient halfway = from.Interpolate(to, 0.5f); + + halfway.Colors.ShouldBe([new SKColor(127, 0, 0), new SKColor(127, 127, 255)]); + halfway.Positions.ShouldBe([0.25f, 1f]); + } + + [Fact] + public void Interpolate_LeavesBothGradientsAlone() + { + ColorGradient from = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + ColorGradient to = [new ColorGradientStop(SKColors.Black, 0.5f), new ColorGradientStop(SKColors.White, 1)]; + + ColorGradient result = from.Interpolate(to, 0.5f); + + result.ShouldNotBeSameAs(from); + from.Colors.ShouldBe([SKColors.Red, SKColors.Blue]); + from.Positions.ShouldBe([0f, 1f]); + to.Colors.ShouldBe([SKColors.Black, SKColors.White]); + to.Positions.ShouldBe([0.5f, 1f]); + } + + [Fact] + public void Interpolate_AddsStopsWhenTheTargetHasMore() + { + ColorGradient from = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + ColorGradient to = [new ColorGradientStop(SKColors.Black, 0), new ColorGradientStop(SKColors.White, 0.5f), new ColorGradientStop(SKColors.Black, 1)]; + + ColorGradient start = from.Interpolate(to, 0f); + ColorGradient end = from.Interpolate(to, 1f); + + // The new stop takes the position of the target's first stop and the color the gradient has there. That position + // is taken by Red, so it moves up a little + start.Colors.ShouldBe([SKColors.Red, SKColors.Red, SKColors.Blue]); + start.Positions.ShouldBe([0f, 0.001f, 1f], 0.0001f); + end.Colors.ShouldBe([SKColors.Black, SKColors.White, SKColors.Black]); + end.Positions.ShouldBe([0f, 0.5f, 1f], 0.0001f); + } + + [Fact] + public void Interpolate_BlendsExtraStopsTowardsTheTargetsColorAtTheirPosition() + { + ColorGradient from = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Lime, 0.5f), new ColorGradientStop(SKColors.Blue, 1)]; + ColorGradient to = [new ColorGradientStop(SKColors.Black, 0), new ColorGradientStop(SKColors.White, 1)]; + + ColorGradient end = from.Interpolate(to, 1f); + + // The first two stops follow the target's stops, the third has no counterpart and takes the target's color at its + // own position. Pinned as-is: the second stop goes to the target's last stop at 1, which is still taken by the + // third stop, so it lands just below it + end.Count.ShouldBe(3); + end.Colors.ShouldBe([SKColors.Black, SKColors.White, SKColors.White]); + end.Positions.ShouldBe([0f, 0.998f, 1f], 0.0001f); + } + + #endregion + + #region Copies and equality + + [Fact] + public void Copy_HasEqualButSeparateStops() + { + ColorGradient original = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + ColorGradient copy = new(original); + + copy.ShouldBe(original); + copy.GetHashCode().ShouldBe(original.GetHashCode()); + copy[0].ShouldNotBeSameAs(original[0]); + + copy[0].Color = SKColors.Lime; + original[0].Color.ShouldBe(SKColors.Red); + copy.ShouldNotBe(original); + } + + [Fact] + public void Copy_ListensToItsOwnStops() + { + ColorGradient original = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + ColorGradient copy = new(original); + ColorGradientStop copiedRed = copy[0], copiedBlue = copy[1]; + int originalChanges = 0, copyChanges = 0; + original.StopChanged += (_, _) => originalChanges++; + copy.StopChanged += (_, _) => copyChanges++; + + copiedRed.Position = 0.5f; + copiedBlue.Position = 0.5f; + + copyChanges.ShouldBe(2); + originalChanges.ShouldBe(0); + copiedBlue.Position.ShouldBe(0.498f, 0.0001f, "the copied stops avoid each other's position"); + } + + [Fact] + public void Copy_OfNothingIsEmpty() + { + new ColorGradient((ColorGradient?) null).ShouldBeEmpty(); + } + + [Fact] + public void Copy_FromAListOfStops() + { + List stops = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + ColorGradient gradient = new(stops); + + gradient.Count.ShouldBe(2); + gradient[0].ShouldNotBeSameAs(stops[0]); + gradient[0].ShouldBe(stops[0]); + + int changes = 0; + gradient.StopChanged += (_, _) => changes++; + gradient[1].Color = SKColors.Lime; + stops[1].Color.ShouldBe(SKColors.Blue); + changes.ShouldBe(1); + } + + [Fact] + public void Copy_FromAListKeepsTheOrderOfTheList() + { + List stops = [new ColorGradientStop(SKColors.Blue, 1), new ColorGradientStop(SKColors.Red, 0)]; + + ColorGradient gradient = new(stops); + + // Pinned as-is: the copy constructors don't sort, so stops out of order stay out of order until one of them changes, + // and GetColor relies on stops being in order + gradient.Colors.ShouldBe([SKColors.Blue, SKColors.Red]); + gradient.GetColor(0.5f).ShouldBe(SKColors.Blue); + } + + [Fact] + public void Equals_ComparesStopsInOrder() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + gradient.Equals(new ColorGradient {new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)}).ShouldBeTrue(); + gradient.Equals(new ColorGradient {new ColorGradientStop(SKColors.Red, 0)}).ShouldBeFalse(); + gradient.Equals(new ColorGradient {new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 0.9f)}).ShouldBeFalse(); + gradient.Equals(new ColorGradient {new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Lime, 1)}).ShouldBeFalse(); + gradient.Equals(gradient).ShouldBeTrue(); + gradient.Equals(null).ShouldBeFalse(); + gradient.Equals(gradient.ToList()).ShouldBeFalse(); + new ColorGradient().Equals(new ColorGradient()).ShouldBeTrue(); + new ColorGradient().GetHashCode().ShouldBe(new ColorGradient().GetHashCode()); + } + + #endregion + + #region Repeating and seamless colors (obsolete) + + // These methods are marked obsolete as errors so code can't call them anymore, but they're still there for plugins + // built against older versions of Artemis + + [Fact] + public void ObsoleteColorsArray_RepeatsTheColorsAndCanCloseTheLoop() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + GetColorsArray(gradient, 0, false).ShouldBe([SKColors.Red, SKColors.Blue]); + GetColorsArray(gradient, 1, false).ShouldBe([SKColors.Red, SKColors.Blue, SKColors.Red, SKColors.Blue]); + GetColorsArray(gradient, 0, true).ShouldBe([SKColors.Red, SKColors.Blue, SKColors.Red]); + GetColorsArray(gradient, 1, true).ShouldBe([SKColors.Red, SKColors.Blue, SKColors.Red, SKColors.Blue, SKColors.Red]); + } + + [Fact] + public void ObsoleteColorsArray_DoesNotCloseAnAlreadySeamlessGradient() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 0.5f), new ColorGradientStop(SKColors.Red, 1)]; + + GetColorsArray(gradient, 0, true).ShouldBe([SKColors.Red, SKColors.Blue, SKColors.Red]); + } + + [Fact] + public void ObsoletePositionsArray_SqueezesRepeatsIntoTheGradient() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + GetPositionsArray(gradient, 0, false).ShouldBe([0f, 1f]); + GetPositionsArray(gradient, 1, false).ShouldBe([0f, 0.5f, 0.5f, 1f]); + GetPositionsArray(gradient, 1, true).ShouldBe([0f, 0.375f, 0.375f, 0.75f, 1f]); + } + + [Fact] + public void ObsoletePositionsArray_IgnoresSeamlessWithoutRepeats() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + // Pinned as-is: without repeats the seamless positions are skipped (the check looks inverted), so there is one + // position less than there are colors + GetPositionsArray(gradient, 0, true).ShouldBe([0f, 1f]); + GetColorsArray(gradient, 0, true).Length.ShouldBe(3); + } + + [Fact] + public void ObsoleteGetColor_WithoutRepeatsOrSeamlessIsGetColor() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + GetColor(gradient, 0.3f, 0, false).ShouldBe(gradient.GetColor(0.3f)); + } + + [Fact] + public void ObsoleteGetColor_RepeatsTheGradient() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + GetColor(gradient, 0f, 1, false).ShouldBe(SKColors.Red); + GetColor(gradient, 0.25f, 1, false).ShouldBe(new SKColor(127, 0, 127)); + GetColor(gradient, 1f, 1, false).ShouldBe(SKColors.Blue); + } + + [Fact] + public void ObsoleteGetColor_PicksTheWrongColorAfterARepeat() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + // Pinned as-is: the second repeat starts at 0.5, where the first repeat also ends. Colors are looked up by + // position, which finds the end of the first repeat (blue) instead of the start of the second (red), so the + // second half is solid blue instead of fading from red to blue again + GetColor(gradient, 0.75f, 1, false).ShouldBe(SKColors.Blue); + } + + [Fact] + public void ObsoleteGetColor_IgnoresSeamlessWithoutRepeats() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + + // Pinned as-is: because of the missing seamless positions (see ObsoletePositionsArray_IgnoresSeamlessWithoutRepeats) + // this fades from red to blue as if seamless was off, instead of turning back towards red past the middle + GetColor(gradient, 0.75f, 0, true).ShouldBe(new SKColor(63, 0, 191)); + } + + [Fact] + public void ObsoleteGetColor_OnAnEmptyGradientIsWhite() + { + // Unlike GetColor(position), which gives transparent + GetColor(new ColorGradient(), 0.5f, 1, false).ShouldBe(new SKColor(255, 255, 255)); + } + + private static SKColor[] GetColorsArray(ColorGradient gradient, int timesToRepeat, bool seamless) + { + return (SKColor[]) typeof(ColorGradient).GetMethod("GetColorsArray")!.Invoke(gradient, [timesToRepeat, seamless])!; + } + + private static float[] GetPositionsArray(ColorGradient gradient, int timesToRepeat, bool seamless) + { + return (float[]) typeof(ColorGradient).GetMethod("GetPositionsArray")!.Invoke(gradient, [timesToRepeat, seamless])!; + } + + private static SKColor GetColor(ColorGradient gradient, float position, int timesToRepeat, bool seamless) + { + return (SKColor) typeof(ColorGradient).GetMethod("GetColor", [typeof(float), typeof(int), typeof(bool)])!.Invoke(gradient, [position, timesToRepeat, seamless])!; + } + + #endregion + + private static List RecordCollectionChanges(ColorGradient gradient) + { + List events = []; + gradient.CollectionChanged += (_, e) => events.Add(e); + return events; + } +} diff --git a/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionHintTests.cs b/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionHintTests.cs new file mode 100644 index 000000000..93022679f --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionHintTests.cs @@ -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 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 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 + + /// + /// Applies the hint to a new layer and returns the devices that got LEDs, in the order they were added + /// + private List DevicesWithLeds(Profile profile, IAdaptionHint hint) + { + Layer layer = AddLayer(profile); + hint.Apply(layer, Devices); + return layer.Leds.Select(l => l.Device).Distinct().ToList(); + } +} diff --git a/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionLayouts.cs b/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionLayouts.cs new file mode 100644 index 000000000..90b63ef74 --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionLayouts.cs @@ -0,0 +1,39 @@ +using Artemis.Core.Tests.Infrastructure.Plugins.DeviceProviders; + +namespace Artemis.Core.Tests.Profiles.Adaption; + +/// +/// The device layouts used by the adaption tests on top of +/// +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; + + /// + /// Just the keys around the movement keys of an AZERTY keyboard, A sits above Q + /// + public static string AzertyKeys => Path.Combine(AppContext.BaseDirectory, "TestLayouts", "Test AZERTY keys.xml"); + + /// + /// The names of every keyboard layout shipped with Artemis, use to get its path + /// + public static TheoryData 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" + ); + + /// + /// Gets the path to the keyboard layout shipped with Artemis by its name + /// + public static string Keyboard(string name) + { + return Path.Combine(Root, "Keyboard", name + ".xml"); + } +} diff --git a/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionTestBase.cs b/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionTestBase.cs new file mode 100644 index 000000000..a3ab32167 --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionTestBase.cs @@ -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; + +/// +/// Base class for adaption tests, gives each test its own harness so tests can pick their own devices +/// +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 Devices => Harness.Devices.ToList(); + + protected Layer AddLayer(Profile profile, IEnumerable leds, string name = "Test layer") + { + return Harness.AddLayer(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(); + } + + /// + /// Gets the IDs of the layer's LEDs on the given device, sorted so they can be compared + /// + protected static LedId[] LedIdsOn(Layer layer, ArtemisDevice device) + { + return layer.Leds.Where(l => l.Device == device).Select(l => l.RgbLed.Id).Order().ToArray(); + } + + /// + /// Gets the IDs of all LEDs on the given device, sorted so they can be compared + /// + protected static LedId[] AllLedIds(ArtemisDevice device) + { + return device.Leds.Select(l => l.RgbLed.Id).Order().ToArray(); + } + + public void Dispose() + { + Harness.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/Artemis.Core.Tests/Profiles/Adaption/LayerAdapterTests.cs b/src/Artemis.Core.Tests/Profiles/Adaption/LayerAdapterTests.cs new file mode 100644 index 000000000..1efebb782 --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/Adaption/LayerAdapterTests.cs @@ -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 hints = layer.Adapter.DetermineHints(Devices); + + DeviceAdaptionHint hint = hints.Single().ShouldBeOfType(); + 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 hints = layer.Adapter.DetermineHints(Devices); + + hints.Count.ShouldBe(2); + hints[0].ShouldBeOfType().DeviceType.ShouldBe(RGBDeviceType.Keyboard); + hints[1].ShouldBeOfType().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 hints = layer.Adapter.DetermineHints(Devices); + + hints.Single().ShouldBeOfType().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 hints = layer.Adapter.DetermineHints(Devices); + + SingleLedAdaptionHint hint = hints.Single().ShouldBeOfType(); + 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 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().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 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 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 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 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 removed = []; + layer.Adapter.AdapterHintRemoved += (_, e) => removed.Add(e.AdaptionHint); + layer.Adapter.Add(new DeviceAdaptionHint()); + + layer.Adapter.Load(); + + layer.Adapter.AdaptionHints.Single().ShouldBeOfType().Section.ShouldBe(KeyboardSection.NumPad); + removed.ShouldBeEmpty("loading drops the current hints without reporting them as removed"); + } + + #endregion +} diff --git a/src/Artemis.Core.Tests/Profiles/Adaption/ProfileAdaptionTests.cs b/src/Artemis.Core.Tests/Profiles/Adaption/ProfileAdaptionTests.cs new file mode 100644 index 000000000..5cc1ac620 --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/Adaption/ProfileAdaptionTests.cs @@ -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; + +/// +/// 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 +/// +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); + } + + /// + /// Creates a profile on a full size ANSI keyboard and returns it as stored, the keyboard is removed again + /// + /// The names of the layers to keep, defaults to all of them + 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; + } + + /// + /// Adapts every layer the way ProfileService.AdaptProfile does + /// + 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); + } +} diff --git a/src/Artemis.Core.Tests/Profiles/BlendModeTests.cs b/src/Artemis.Core.Tests/Profiles/BlendModeTests.cs new file mode 100644 index 000000000..202f1932f --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/BlendModeTests.cs @@ -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; + +/// +/// 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. +/// +/// 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. +/// +/// +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 BlendModes => new(Enum.GetValues()); + + [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); + } + + /// + /// 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. + /// + [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(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(root, [Led(LedId.Keyboard_S)], "In root"); + inRootBrush.Properties.Color.SetCurrentValue(Source); + inRoot.General.BlendMode.SetCurrentValue(blendMode); + (_, SolidColorTestBrush destination) = Harness.AddLayer(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(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 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); + } + + /// + /// Folder.Render composites its children in a layer, using a default paint + /// + 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); + } + + /// + /// Layer.DelegateRendering composites the layer with its blend mode and opacity, the brush draws inside that layer + /// + 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 +} diff --git a/src/Artemis.Core.Tests/Profiles/BusyFolder.cs b/src/Artemis.Core.Tests/Profiles/BusyFolder.cs new file mode 100644 index 000000000..b3ee50d77 --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/BusyFolder.cs @@ -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; + +/// +/// 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. +/// +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; + } + + /// + /// Gets the busy folder + /// + public Folder Folder { get; } + + /// + /// Gets the folder nested inside the busy folder + /// + public Folder Nested { get; } + + /// + /// Gets the layer next to the busy folder, it does not overlap any of the folder's LEDs + /// + public Layer Outside { get; } + + /// + /// Gets all LEDs covered by the busy folder, including those of the nested folder + /// + public ArtemisLed[] Leds { get; } + + /// + /// Gets the LEDs covered by the nested folder + /// + public ArtemisLed[] NestedLeds { get; } + + /// + /// Adds a busy folder to the root of the profile, with a layer below it on LEDs the folder doesn't cover. + /// + 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(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(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(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(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(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(nested, Leds(LedId.Keyboard_V, LedId.Keyboard_B, LedId.Keyboard_N), "Tinted"); + white.Properties.Color.SetCurrentValue(SKColors.White); + harness.AddEffect(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(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); + } +} diff --git a/src/Artemis.Core.Tests/Profiles/CompositingTests.cs b/src/Artemis.Core.Tests/Profiles/CompositingTests.cs new file mode 100644 index 000000000..e404f22ee --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/CompositingTests.cs @@ -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; + +/// +/// 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. +/// +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(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(parent, Leds(leds), $"{color} {blendMode}", order); + brush.Properties.Color.SetCurrentValue(color); + layer.General.BlendMode.SetCurrentValue(blendMode); + return layer; + } +} diff --git a/src/Artemis.Core.Tests/Profiles/FolderEffectTests.cs b/src/Artemis.Core.Tests/Profiles/FolderEffectTests.cs new file mode 100644 index 000000000..d164eb0f1 --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/FolderEffectTests.cs @@ -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; + +/// +/// 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. +/// +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(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(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(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(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(busy.Folder); + OpacityTestEffect opacity = Harness.AddEffect(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(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(folder, [Led(LedId.Keyboard_A)], "Top"); + (_, SolidColorTestBrush bottom) = Harness.AddLayer(folder, [Led(LedId.Keyboard_A)], "Bottom"); + top.Properties.Color.SetCurrentValue(SKColors.Lime); + bottom.Properties.Color.SetCurrentValue(SKColors.Red); + Harness.AddEffect(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(folder, [Led(LedId.Keyboard_A)], "In folder"); + (_, SolidColorTestBrush below) = Harness.AddLayer(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "Below folder"); + inFolder.Properties.Color.SetCurrentValue(SKColors.Lime); + below.Properties.Color.SetCurrentValue(SKColors.Red); + Harness.AddEffect(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(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(folder, [Led(LedId.Keyboard_Q)], "Q"); + Harness.AddLayer(folder, [Led(LedId.Keyboard_P)], "P"); + (_, SolidColorTestBrush below) = Harness.AddLayer(profile.GetRootFolder(), TopLetterRow, "Below"); + below.Properties.Color.SetCurrentValue(SKColors.White); + Harness.AddEffect(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(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); + } + + /// + /// 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. + /// + 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)); + } +} diff --git a/src/Artemis.Core.Tests/Profiles/GlowTests.cs b/src/Artemis.Core.Tests/Profiles/GlowTests.cs new file mode 100644 index 000000000..4baa4d5f6 --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/GlowTests.cs @@ -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; + +/// +/// Effects that render beyond the element itself. Layers clip to their own LEDs, folders don't clip at all. +/// +public class GlowTests : RenderTestBase +{ + [Fact] + public void LayerGlow_IsClippedToTheLedsOfTheLayer() + { + Profile profile = Harness.CreateProfile(); + (Layer layer, _) = Harness.AddLayer(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + Harness.AddEffect(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(folder, [Led(LedId.Keyboard_A)]); + Harness.AddEffect(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(profile.GetRootFolder(), [Led(LedId.Keyboard_S)], "Above"); + above.Properties.Color.SetCurrentValue(SKColors.Blue); + Folder folder = Harness.AddFolder(profile.GetRootFolder()); + Harness.AddLayer(folder, [Led(LedId.Keyboard_A)]); + Harness.AddEffect(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(folder, [Led(LedId.Keyboard_A)]); + Harness.AddEffect(folder); + (_, SolidColorTestBrush below) = Harness.AddLayer(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(glowing, [Led(LedId.Keyboard_A)]); + Harness.AddEffect(glowing); + Harness.AddEffect(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(folder, [Led(LedId.Keyboard_A)], "Blue"); + blueBrush.Properties.Color.SetCurrentValue(SKColors.Blue); + blue.General.BlendMode.SetCurrentValue(SKBlendMode.Plus); + Harness.AddLayer(folder, [Led(LedId.Keyboard_A)], "Red"); + Harness.AddEffect(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(folder, [Led(LedId.Keyboard_A)]); + GlowTestEffect layerGlow = Harness.AddEffect(layer); + layerGlow.Properties.Radius.SetCurrentValue(40); + Harness.AddEffect(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); + } +} diff --git a/src/Artemis.Core.Tests/Profiles/LayerBrushTests.cs b/src/Artemis.Core.Tests/Profiles/LayerBrushTests.cs new file mode 100644 index 000000000..6917162f2 --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/LayerBrushTests.cs @@ -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(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(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(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(profile.GetRootFolder(), TopLetterRow); + Dictionary 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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + + PerLedTestBrush replacement = Harness.ChangeBrush(layer); + replacement.ColorSelector = (_, _) => SKColors.Blue; + + using RenderFrame frame = Render(profile); + + layer.LayerBrush.ShouldBe(replacement); + frame.GetColor(Keyboard, LedId.Keyboard_A).ShouldBeColor(SKColors.Blue); + } +} diff --git a/src/Artemis.Core.Tests/Profiles/LayerEffectTests.cs b/src/Artemis.Core.Tests/Profiles/LayerEffectTests.cs new file mode 100644 index 000000000..37d043011 --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/LayerEffectTests.cs @@ -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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + Harness.AddEffect(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + layer.Transform.Opacity.SetCurrentValue(50); + Harness.AddEffect(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + Harness.AddEffect(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(profile.GetRootFolder(), Leds(LedId.Keyboard_A, LedId.Keyboard_S)); + brush.Properties.Color.SetCurrentValue(SKColors.White); + Harness.AddEffect(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "Tinted"); + (_, SolidColorTestBrush neighbour) = Harness.AddLayer(profile.GetRootFolder(), [Led(LedId.Keyboard_S)], "Neighbour"); + tinted.Properties.Color.SetCurrentValue(SKColors.White); + neighbour.Properties.Color.SetCurrentValue(SKColors.White); + Harness.AddEffect(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + brush.Properties.Color.SetCurrentValue(SKColors.White); + Harness.AddEffect(layer); + Harness.AddEffect(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + ChannelSwapTestEffect effect = Harness.AddEffect(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + ChannelSwapTestEffect effect = Harness.AddEffect(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + OpacityTestEffect effect = Harness.AddEffect(layer); + FloatLayerProperty opacity = effect.Properties.Opacity; + opacity.AddKeyframe(new LayerPropertyKeyframe(100, TimeSpan.Zero, Easings.Functions.Linear, opacity)); + opacity.AddKeyframe(new LayerPropertyKeyframe(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + + OpacityTestEffect first = Harness.AddEffect(layer); + TintTestEffect second = Harness.AddEffect(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]); + } +} diff --git a/src/Artemis.Core.Tests/Profiles/LayerTransformTests.cs b/src/Artemis.Core.Tests/Profiles/LayerTransformTests.cs new file mode 100644 index 000000000..f013913cb --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/LayerTransformTests.cs @@ -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(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(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(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(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(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(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(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "Top"); + (_, SolidColorTestBrush bottomBrush) = Harness.AddLayer(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); + } +} diff --git a/src/Artemis.Core.Tests/Profiles/ProfileSerializationTests.cs b/src/Artemis.Core.Tests/Profiles/ProfileSerializationTests.cs new file mode 100644 index 000000000..187cc3cc8 --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/ProfileSerializationTests.cs @@ -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(folder, [Led(LedId.Keyboard_A)], "Nested layer"); + (Layer suspended, _) = Harness.AddLayer(profile.GetRootFolder(), [Led(LedId.Keyboard_S)], "Suspended layer"); + Harness.AddLayer(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(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(SKColors.Red, TimeSpan.Zero, Easings.Functions.CubicEaseIn, color)); + color.AddKeyframe(new LayerPropertyKeyframe(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().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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "Play once"); + (Layer staticCondition, _) = Harness.AddLayer(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(); + StaticCondition reloadedStatic = reloaded.GetAllLayers().Single(l => l.Name == "Static").DisplayCondition.ShouldBeOfType(); + 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(folder, [Led(LedId.Keyboard_A)]); + OpacityTestEffect opacity = Harness.AddEffect(layer); + TintTestEffect tint = Harness.AddEffect(layer); + Harness.AddEffect(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().Single().Properties.Opacity.CurrentValue.ShouldBe(25); + reloadedLayer.LayerEffects.OfType().Single().Suspended.ShouldBeTrue(); + reloaded.GetAllFolders().Single(f => !f.IsRootFolder).LayerEffects.Single().ShouldBeOfType(); + } + + [Fact] + public void RoundTrip_LosesTheNameOfRenamedEffects() + { + Profile profile = Harness.CreateProfile(); + (Layer layer, _) = Harness.AddLayer(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + TintTestEffect tint = Harness.AddEffect(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(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + Harness.AddEffect(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(); + // 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(folder); + + (Layer gradient, GradientTestBrush gradientBrush) = Harness.AddLayer(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(20, TimeSpan.Zero, Easings.Functions.QuadraticEaseOut, opacity)); + opacity.AddKeyframe(new LayerPropertyKeyframe(100, TimeSpan.FromSeconds(1), Easings.Functions.Linear, opacity)); + + (Layer tinted, SolidColorTestBrush tintedBrush) = Harness.AddLayer(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(tinted); + tint.Properties.Color.SetCurrentValue(new SKColor(0, 128, 255)); + + (Layer suspended, _) = Harness.AddLayer(profile.GetRootFolder(), Keyboard.Leds, "Suspended"); + suspended.Suspended = true; + + return profile; + } +} diff --git a/src/Artemis.Core.Tests/Profiles/ProfileStructureTests.cs b/src/Artemis.Core.Tests/Profiles/ProfileStructureTests.cs new file mode 100644 index 000000000..0c9f86267 --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/ProfileStructureTests.cs @@ -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(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(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(profile.GetRootFolder(), Leds(LedId.Keyboard_A, LedId.Keyboard_S), "Top"); + (_, SolidColorTestBrush bottom) = Harness.AddLayer(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)], "First"); + (Layer insertedLayer, SolidColorTestBrush inserted) = Harness.AddLayer(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(root, [], "A"); + (Layer b, _) = Harness.AddLayer(root, [], "B"); + (Layer c, _) = Harness.AddLayer(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(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(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(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(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); + } +} diff --git a/src/Artemis.Core.Tests/Profiles/TimelineTests.cs b/src/Artemis.Core.Tests/Profiles/TimelineTests.cs new file mode 100644 index 000000000..57c4ab80c --- /dev/null +++ b/src/Artemis.Core.Tests/Profiles/TimelineTests.cs @@ -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(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + SKColorLayerProperty color = brush.Properties.Color; + color.AddKeyframe(new LayerPropertyKeyframe(SKColors.Black, TimeSpan.Zero, Easings.Functions.CubicEaseIn, color)); + color.AddKeyframe(new LayerPropertyKeyframe(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(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(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(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(profile.GetRootFolder(), [Led(LedId.Keyboard_A)]); + FloatLayerProperty opacity = layer.Transform.Opacity; + opacity.AddKeyframe(new LayerPropertyKeyframe(0, TimeSpan.Zero, Easings.Functions.Linear, opacity)); + opacity.AddKeyframe(new LayerPropertyKeyframe(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(color, TimeSpan.FromSeconds(seconds), Easings.Functions.Linear, property)); + } +} diff --git a/src/Artemis.Core.Tests/Rendering/RenderScaleTests.cs b/src/Artemis.Core.Tests/Rendering/RenderScaleTests.cs new file mode 100644 index 000000000..395e135da --- /dev/null +++ b/src/Artemis.Core.Tests/Rendering/RenderScaleTests.cs @@ -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; + +/// +/// 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. +/// +public class RenderScaleTests +{ + public static TheoryData 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 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); + } +} diff --git a/src/Artemis.Core.Tests/Rendering/SmallLedTests.cs b/src/Artemis.Core.Tests/Rendering/SmallLedTests.cs new file mode 100644 index 000000000..3d46683f1 --- /dev/null +++ b/src/Artemis.Core.Tests/Rendering/SmallLedTests.cs @@ -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; + +/// +/// 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. +/// +public class SmallLedTests +{ + public static TheoryData 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(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(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; + } +} diff --git a/src/Artemis.Core.Tests/Rendering/SurfaceTests.cs b/src/Artemis.Core.Tests/Rendering/SurfaceTests.cs new file mode 100644 index 000000000..268abd02d --- /dev/null +++ b/src/Artemis.Core.Tests/Rendering/SurfaceTests.cs @@ -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; + +/// +/// 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. +/// +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); + } +} diff --git a/src/Artemis.Core.Tests/Rendering/TextureSamplingTests.cs b/src/Artemis.Core.Tests/Rendering/TextureSamplingTests.cs new file mode 100644 index 000000000..bf78dc908 --- /dev/null +++ b/src/Artemis.Core.Tests/Rendering/TextureSamplingTests.cs @@ -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; + +/// +/// How SKTexture turns the canvas into LED colors: every LED gets the average color of the canvas pixels under its +/// rectangle. +/// +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); + } +} diff --git a/src/Artemis.Core.Tests/Services/DeviceServiceTests.cs b/src/Artemis.Core.Tests/Services/DeviceServiceTests.cs new file mode 100644 index 000000000..2de585117 --- /dev/null +++ b/src/Artemis.Core.Tests/Services/DeviceServiceTests.cs @@ -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(); + + [Fact] + public void AddDeviceProvider_AddsItsDevices() + { + List added = []; + List 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 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 removed = []; + List 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 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 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().IsPaused.ShouldBeTrue(); + } + + private DeviceEntity StoredDevice() + { + using var context = Services.CreateDbContext(); + return context.Devices.Single(); + } + + /// + /// Attaches the devices to an RGB.NET surface, like the render service does when Artemis starts rendering + /// + private RGBSurface AttachToSurface() + { + RGBSurface surface = new(); + surface.Attach(Devices.Devices.Select(d => d.RgbDevice)); + return surface; + } +} diff --git a/src/Artemis.Core.Tests/Services/ModuleServiceTests.cs b/src/Artemis.Core.Tests/Services/ModuleServiceTests.cs new file mode 100644 index 000000000..f97573c89 --- /dev/null +++ b/src/Artemis.Core.Tests/Services/ModuleServiceTests.cs @@ -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 _activated = []; + private readonly List _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(() => EnableFeature(module)); + exception.InnerException.ShouldBeOfType(); + module.IsEnabled.ShouldBeFalse(); + } + + /// + /// Exports a profile into a file in the temporary data folder and removes the profile, the way a module ships one + /// + private async Task 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; + } + + /// + /// Default profiles are imported in the background, waits for the profile to show up + /// + private async Task 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); + } +} diff --git a/src/Artemis.Core.Tests/Services/NodeServiceTests.cs b/src/Artemis.Core.Tests/Services/NodeServiceTests.cs new file mode 100644 index 000000000..ce37b3b02 --- /dev/null +++ b/src/Artemis.Core.Tests/Services/NodeServiceTests.cs @@ -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(); + + [Fact] + public void AvailableNodes_ContainsTheNodesOfEnabledProviders() + { + TestNodeProvider provider = EnableFeature(new TestNodeProvider()); + + List 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 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 script = new("Test", "A test script"); + TestBooleanNode node = provider.AddNode(script); + node.Value = true; + node.X = 12; + script.Nodes.OfType>().Single().Input.ConnectTo(node.Output); + + string json = Nodes.ExportScript(script); + NodeScript target = new("Target", "Another script"); + Nodes.ImportScript(json, target); + target.Run(); + + TestBooleanNode imported = target.Nodes.OfType().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 source = new("Source", "Script with a node"); + provider.AddNode(source); + NodeScript target = new("Target", "Script with another node"); + provider.AddNode(target); + + Nodes.ImportScript(Nodes.ExportScript(source), target); + + target.Nodes.OfType().ShouldBeEmpty(); + target.Nodes.OfType().Count().ShouldBe(1); + } + + [Fact] + public void ImportScript_WithNull_Throws() + { + NodeScript target = new("Target", "A script"); + + Should.Throw(() => Nodes.ImportScript("null", target)); + } +} diff --git a/src/Artemis.Core.Tests/Services/ProfileActivationTests.cs b/src/Artemis.Core.Tests/Services/ProfileActivationTests.cs new file mode 100644 index 000000000..0f1c72d79 --- /dev/null +++ b/src/Artemis.Core.Tests/Services/ProfileActivationTests.cs @@ -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; + +/// +/// Activating, deactivating, updating and rendering profiles through the profile service +/// +public class ProfileActivationTests : ProfileServiceTestBase +{ + [Fact] + public void ActivateProfile_LoadsTheProfileOntoTheEnabledDevices() + { + AddDeviceProvider(TestLayouts.KeyboardAnsi); + ArtemisDevice keyboard = Resolve().Devices.Single(); + TestLayerBrushProvider brushes = EnableFeature(new TestLayerBrushProvider()); + ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); + Profile profile = ProfileService.ActivateProfile(configuration); + AddLayer(profile, brushes, [keyboard.GetLed(LedId.Keyboard_A, false)!]); + ProfileService.SaveProfile(profile, true); + ProfileService.DeactivateProfile(configuration); + List 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(); + } + + [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 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(() => 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(); + TestLayerBrushProvider brushes = EnableFeature(new TestLayerBrushProvider()); + ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); + Profile profile = ProfileService.ActivateProfile(configuration); + Layer layer = AddLayer(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(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().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().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().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().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().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().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(profile, brushes, [led]); + profile.Reset(); + return (configuration, led); + } + + /// + /// Renders the profiles onto a canvas covering the whole surface and returns the color in the middle of the LED + /// + 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); + } +} diff --git a/src/Artemis.Core.Tests/Services/ProfileCategoryTests.cs b/src/Artemis.Core.Tests/Services/ProfileCategoryTests.cs new file mode 100644 index 000000000..bf1f859d8 --- /dev/null +++ b/src/Artemis.Core.Tests/Services/ProfileCategoryTests.cs @@ -0,0 +1,288 @@ +using Artemis.Core.Services; +using Artemis.Core.Tests.Infrastructure.Services; +using Artemis.Storage.Entities.Profile; + +namespace Artemis.Core.Tests.Services; + +/// +/// Profile categories and the profile configurations inside them, as managed by the profile service +/// +public class ProfileCategoryTests : ProfileServiceTestBase +{ + [Fact] + public void ANewDatabase_GetsTheDefaultCategories() + { + ProfileService.ProfileCategories.Select(c => (c.Name, c.Order)).ShouldBe([("Games", 1), ("Applications", 2), ("General", 3)]); + GetStoredCategories().Select(c => c.Name).ShouldBe(["Games", "Applications", "General"]); + } + + [Fact] + public void Restarting_LoadsTheStoredCategoriesInOrder() + { + ProfileCategory games = ProfileService.ProfileCategories.Single(c => c.Name == "Games"); + games.Order = 10; + ProfileService.SaveProfileCategory(games); + + IProfileService restarted = CreateNewProfileService(); + + // The default categories are not created again + restarted.ProfileCategories.Select(c => c.Name).ShouldBe(["Applications", "General", "Games"]); + } + + [Fact] + public void Restarting_WithoutAnyCategories_CreatesTheDefaultCategoriesAgain() + { + foreach (ProfileCategory category in ProfileService.ProfileCategories.ToList()) + ProfileService.DeleteProfileCategory(category); + + IProfileService restarted = CreateNewProfileService(); + + restarted.ProfileCategories.Select(c => c.Name).ShouldBe(["Games", "Applications", "General"]); + } + + [Fact] + public void CreateProfileCategory_AddsItAtTheBottom() + { + List added = []; + ProfileService.ProfileCategoryAdded += (_, e) => added.Add(e.ProfileCategory); + + ProfileCategory category = ProfileService.CreateProfileCategory("Tests"); + + category.Order.ShouldBe(4); + ProfileService.ProfileCategories.Last().ShouldBeSameAs(category); + added.ShouldBe([category]); + GetStoredCategories().Last().Name.ShouldBe("Tests"); + } + + [Fact] + public void CreateProfileCategory_AtTheTop_MovesTheOtherCategoriesDown() + { + ProfileCategory category = ProfileService.CreateProfileCategory("Tests", true); + + category.Order.ShouldBe(1); + ProfileService.ProfileCategories.Where(c => c != category).Select(c => c.Order).ShouldBe([2, 3, 4]); + GetStoredCategories().Select(c => c.Name).ShouldBe(["Tests", "Games", "Applications", "General"]); + } + + [Fact] + public void CreateProfileCategory_AtTheTop_IsListedLastUntilTheCategoriesAreSortedAgain() + { + ProfileCategory category = ProfileService.CreateProfileCategory("Tests", true); + + // Pinned as-is: the category is created with order 1 but appended to the end of ProfileCategories. Profiles are + // updated and rendered in list order (the first category ends up on top), so until something saves a category, + // which sorts the list, this category's profiles are drawn underneath the others rather than on top + ProfileService.ProfileCategories.Last().ShouldBeSameAs(category); + + ProfileService.SaveProfileCategory(General); + ProfileService.ProfileCategories.First().ShouldBeSameAs(category); + } + + [Fact] + public void SaveProfileCategory_StoresItsChanges() + { + ProfileCategory category = ProfileService.CreateProfileCategory("Tests"); + category.Name = "Renamed"; + category.IsCollapsed = true; + category.IsSuspended = true; + + ProfileService.SaveProfileCategory(category); + + ProfileCategory reloaded = CreateNewProfileService().ProfileCategories.Single(c => c.EntityId == category.EntityId); + reloaded.Name.ShouldBe("Renamed"); + reloaded.IsCollapsed.ShouldBeTrue(); + reloaded.IsSuspended.ShouldBeTrue(); + } + + [Fact] + public void ChangingTheOrder_LikeTheSidebarDoes_ReordersTheCategories() + { + // Moving General up one place, the sidebar gives every category its new order and saves the ones that changed + List categories = ProfileService.ProfileCategories.ToList(); + categories.Remove(General); + categories.Insert(1, General); + for (int i = 0; i < categories.Count; i++) + { + if (categories[i].Order == i + 1) + continue; + categories[i].Order = i + 1; + ProfileService.SaveProfileCategory(categories[i]); + } + + ProfileService.ProfileCategories.Select(c => c.Name).ShouldBe(["Games", "General", "Applications"]); + CreateNewProfileService().ProfileCategories.Select(c => c.Name).ShouldBe(["Games", "General", "Applications"]); + } + + [Fact] + public void DeleteProfileCategory_RemovesTheCategoryAndItsProfiles() + { + ProfileCategory category = ProfileService.CreateProfileCategory("Tests"); + ProfileConfiguration first = ProfileService.CreateProfileConfiguration(category, "First", "TestTube"); + ProfileConfiguration second = ProfileService.CreateProfileConfiguration(category, "Second", "TestTube"); + ProfileService.ActivateProfile(first); + List removedProfiles = []; + List removedCategories = []; + ProfileService.ProfileRemoved += (_, e) => removedProfiles.Add(e.ProfileConfiguration); + ProfileService.ProfileCategoryRemoved += (_, e) => removedCategories.Add(e.ProfileCategory); + + ProfileService.DeleteProfileCategory(category); + + ProfileService.ProfileCategories.ShouldNotContain(category); + removedProfiles.ShouldBe([second, first], ignoreOrder: true); + removedCategories.ShouldBe([category]); + first.Profile.ShouldBeNull(); + GetStoredCategories().ShouldNotContain(c => c.Name == "Tests"); + GetStoredProfiles().ShouldBeEmpty(); + } + + [Fact] + public void CreateProfileConfiguration_AddsItToTheTopOfTheCategory() + { + List added = []; + ProfileService.ProfileAdded += (_, e) => added.Add(e.ProfileConfiguration); + + ProfileConfiguration first = ProfileService.CreateProfileConfiguration(General, "First", "TestTube"); + ProfileConfiguration second = ProfileService.CreateProfileConfiguration(General, "Second", "Abacus"); + + General.ProfileConfigurations.ShouldBe([second, first]); + second.Order.ShouldBe(0); + first.Order.ShouldBe(1); + added.ShouldBe([first, second]); + } + + [Fact] + public void CreateProfileConfiguration_IsStoredButNotActivated() + { + ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); + + configuration.Profile.ShouldBeNull(); + configuration.Category.ShouldBeSameAs(General); + configuration.ProfileId.ShouldNotBe(Guid.Empty); + configuration.Icon.IconName.ShouldBe("TestTube"); + + ProfileConfiguration reloaded = CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Single(); + reloaded.Name.ShouldBe("Test profile"); + reloaded.ProfileId.ShouldBe(configuration.ProfileId); + reloaded.Icon.IconName.ShouldBe("TestTube"); + } + + [Fact] + public void SaveProfileCategory_StoresTheSettingsOfItsProfiles() + { + ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); + configuration.Name = "Renamed"; + configuration.IsSuspended = true; + configuration.FadeInAndOut = true; + configuration.ActivationBehaviour = ActivationBehaviour.DisableOthersBelow; + configuration.HotkeyMode = ProfileConfigurationHotkeyMode.EnableDisable; + configuration.EnableHotkey = new Hotkey(KeyboardKey.F1, KeyboardModifierKey.Shift); + configuration.DisableHotkey = new Hotkey(KeyboardKey.F2, null); + configuration.Icon.SetIconByStream(new MemoryStream([1, 2, 3])); + + ProfileService.SaveProfileCategory(General); + + ProfileConfiguration reloaded = CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Single(); + reloaded.Name.ShouldBe("Renamed"); + reloaded.IsSuspended.ShouldBeTrue(); + reloaded.FadeInAndOut.ShouldBeTrue(); + reloaded.ActivationBehaviour.ShouldBe(ActivationBehaviour.DisableOthersBelow); + reloaded.HotkeyMode.ShouldBe(ProfileConfigurationHotkeyMode.EnableDisable); + reloaded.EnableHotkey!.Key.ShouldBe(KeyboardKey.F1); + reloaded.EnableHotkey.Modifiers.ShouldBe(KeyboardModifierKey.Shift); + reloaded.DisableHotkey!.Key.ShouldBe(KeyboardKey.F2); + reloaded.DisableHotkey.Modifiers.ShouldBeNull(); + reloaded.Icon.IconType.ShouldBe(ProfileConfigurationIconType.BitmapImage); + reloaded.Icon.IconBytes.ShouldBe([1, 2, 3]); + } + + [Fact] + public void AddProfileConfiguration_WithATarget_PutsItInFrontOfTheTarget() + { + ProfileConfiguration first = ProfileService.CreateProfileConfiguration(General, "First", "TestTube"); + ProfileConfiguration second = ProfileService.CreateProfileConfiguration(General, "Second", "TestTube"); + ProfileConfiguration third = ProfileService.CreateProfileConfiguration(General, "Third", "TestTube"); + // Listed as third, second, first + + General.AddProfileConfiguration(first, second); + ProfileService.SaveProfileCategory(General); + + General.ProfileConfigurations.ShouldBe([third, first, second]); + General.ProfileConfigurations.Select(c => c.Order).ShouldBe([0, 1, 2]); + CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Select(c => c.Name).ShouldBe(["Third", "First", "Second"]); + } + + [Fact] + public void MovingAProfileToAnotherCategory_LikeTheSidebarDoes_StoresTheMove() + { + ProfileCategory games = ProfileService.ProfileCategories.Single(c => c.Name == "Games"); + ProfileConfiguration staying = ProfileService.CreateProfileConfiguration(General, "Staying", "TestTube"); + ProfileConfiguration moving = ProfileService.CreateProfileConfiguration(General, "Moving", "TestTube"); + List removedFromGeneral = []; + General.ProfileConfigurationRemoved += (_, e) => removedFromGeneral.Add(e.ProfileConfiguration); + + // The sidebar adds it to the new category and saves both categories + games.AddProfileConfiguration(moving, null); + ProfileService.SaveProfileCategory(games); + ProfileService.SaveProfileCategory(General); + + moving.Category.ShouldBeSameAs(games); + General.ProfileConfigurations.ShouldBe([staying]); + staying.Order.ShouldBe(0); + removedFromGeneral.ShouldBe([moving]); + + IProfileService restarted = CreateNewProfileService(); + restarted.ProfileCategories.Single(c => c.Name == "Games").ProfileConfigurations.Select(c => c.Name).ShouldBe(["Moving"]); + restarted.ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Select(c => c.Name).ShouldBe(["Staying"]); + } + + [Fact] + public void RemoveProfileConfiguration_DeactivatesAndRemovesIt() + { + ProfileConfiguration first = ProfileService.CreateProfileConfiguration(General, "First", "TestTube"); + ProfileConfiguration second = ProfileService.CreateProfileConfiguration(General, "Second", "TestTube"); + ProfileService.ActivateProfile(second); + List removed = []; + ProfileService.ProfileRemoved += (_, e) => removed.Add(e.ProfileConfiguration); + + ProfileService.RemoveProfileConfiguration(second); + + second.Profile.ShouldBeNull(); + General.ProfileConfigurations.ShouldBe([first]); + first.Order.ShouldBe(0); + removed.ShouldBe([second]); + GetStoredProfiles().Select(p => p.ProfileConfiguration.Name).ShouldBe(["First"]); + } + + [Fact] + public void CloneProfileConfiguration_SharesTheStoredDataWithTheOriginal() + { + ProfileConfiguration original = ProfileService.CreateProfileConfiguration(General, "Original", "TestTube"); + + ProfileConfiguration clone = ProfileService.CloneProfileConfiguration(original); + + clone.ShouldNotBeSameAs(original); + clone.Name.ShouldBe("Original"); + clone.Category.ShouldBeSameAs(General); + clone.ProfileId.ShouldBe(original.ProfileId); + // The clone is not added to the category + General.ProfileConfigurations.ShouldBe([original]); + + // Pinned as-is: documented as creating a copy, but the clone uses the same entity as the original, so saving + // changes made to the clone changes the original's stored data too + clone.Entity.ShouldBeSameAs(original.Entity); + clone.Name = "Changed through the clone"; + clone.Save(); + original.Entity.ProfileConfiguration.Name.ShouldBe("Changed through the clone"); + } + + [Fact] + public void ProfileCategories_AreStoredWithTheirProfiles() + { + ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); + + ProfileCategoryEntity stored = GetStoredCategories().Single(c => c.Name == "General"); + + stored.ProfileConfigurations.Single().ProfileConfiguration.ProfileId.ShouldBe(configuration.ProfileId); + stored.ProfileConfigurations.Single().ProfileConfiguration.ProfileCategoryId.ShouldBe(General.EntityId); + } +} diff --git a/src/Artemis.Core.Tests/Services/ProfileImportExportTests.cs b/src/Artemis.Core.Tests/Services/ProfileImportExportTests.cs new file mode 100644 index 000000000..a82545156 --- /dev/null +++ b/src/Artemis.Core.Tests/Services/ProfileImportExportTests.cs @@ -0,0 +1,371 @@ +using System.IO.Compression; +using System.Text; +using Artemis.Core.LayerEffects; +using Artemis.Core.Services; +using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes; +using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects; +using Artemis.Core.Tests.Infrastructure.Plugins.Modules; +using Artemis.Core.Tests.Infrastructure.Plugins.Nodes; +using Artemis.Core.Tests.Infrastructure.Services; +using Artemis.Core.Tests.Infrastructure.VisualScripting; +using Artemis.Storage.Repositories.Interfaces; + +namespace Artemis.Core.Tests.Services; + +/// +/// Exporting profiles to a file and importing them again through the profile service +/// +public class ProfileImportExportTests : ProfileServiceTestBase +{ + [Fact] + public async Task ExportProfile_CreatesAnArchiveWithTheConfigurationAndProfile() + { + ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); + + await using Stream export = await ProfileService.ExportProfile(configuration); + + using ZipArchive archive = new(export, ZipArchiveMode.Read); + archive.Entries.Select(e => e.Name).ShouldBe(["configuration.json", "profile.json"]); + ReadEntry(archive, "configuration.json").ShouldContain("\"Test profile\""); + } + + [Fact] + public async Task ExportProfile_WithAnImageAsIcon_IncludesTheIcon() + { + ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); + configuration.Icon.SetIconByStream(new MemoryStream([1, 2, 3])); + + await using Stream export = await ProfileService.ExportProfile(configuration); + + using ZipArchive archive = new(export, ZipArchiveMode.Read); + archive.Entries.Select(e => e.Name).ShouldBe(["configuration.json", "profile.json", "icon.png"]); + } + + [Fact] + public async Task ExportProfile_ExportsWhatWasLastSaved() + { + ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Saved name", "TestTube"); + configuration.Name = "Unsaved name"; + + await using Stream export = await ProfileService.ExportProfile(configuration); + + using ZipArchive archive = new(export, ZipArchiveMode.Read); + ReadEntry(archive, "configuration.json").ShouldContain("\"Saved name\""); + } + + [Fact] + public async Task ImportProfile_MadeUnique_AddsACopyWithANewId() + { + ProfileConfiguration original = CreateSavedProfile("Test profile"); + ProfileCategory games = ProfileService.ProfileCategories.Single(c => c.Name == "Games"); + + ProfileConfiguration imported = await ImportExport(original, games, true); + + imported.ShouldNotBeSameAs(original); + imported.ProfileId.ShouldNotBe(original.ProfileId); + imported.Name.ShouldBe("Test profile"); + imported.Category.ShouldBeSameAs(games); + games.ProfileConfigurations.ShouldBe([imported]); + imported.Profile.ShouldBeNull(); + + ProfileConfiguration stored = CreateNewProfileService().ProfileCategories.Single(c => c.Name == "Games").ProfileConfigurations.Single(); + stored.ProfileId.ShouldBe(imported.ProfileId); + } + + [Fact] + public async Task ImportProfile_MadeUnique_KeepsTheContentsOfTheProfile() + { + TestLayerBrushProvider brushes = EnableFeature(new TestLayerBrushProvider()); + ProfileConfiguration original = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); + Profile profile = ProfileService.ActivateProfile(original); + AddLayer(profile, brushes, [], "Exported layer"); + ProfileService.SaveProfile(profile, true); + + ProfileConfiguration imported = await ImportExport(original, General, true); + Profile importedProfile = ProfileService.ActivateProfile(imported); + + Layer layer = importedProfile.GetAllLayers().Single(); + layer.Name.ShouldBe("Exported layer"); + layer.LayerBrush.ShouldBeOfType(); + // The root folder moved along with the new ID + importedProfile.GetRootFolder().ShouldNotBeNull(); + } + + [Fact] + public async Task ImportProfile_WithANameAffix_AddsItToTheName() + { + ProfileConfiguration original = CreateSavedProfile("Test profile"); + + // Like the editor's duplicate option + ProfileConfiguration imported = await ImportExport(original, General, true, nameAffix: "copy"); + + imported.Name.ShouldBe("Test profile - copy"); + imported.Entity.Profile.Name.ShouldBe("Test profile - copy"); + } + + [Fact] + public async Task ImportProfile_ByDefault_GoesAtTheBottomOfTheCategory() + { + ProfileConfiguration original = CreateSavedProfile("First"); + CreateSavedProfile("Second"); + + ProfileConfiguration imported = await ImportExport(original, General, true); + + General.ProfileConfigurations.Last().ShouldBeSameAs(imported); + imported.Order.ShouldBe(2); + } + + [Fact] + public async Task ImportProfile_WithATarget_GoesInFrontOfTheTarget() + { + ProfileConfiguration original = CreateSavedProfile("First"); + ProfileConfiguration second = CreateSavedProfile("Second"); + // Listed as second, first + + ProfileConfiguration imported = await ImportExport(original, General, true, target: original); + + General.ProfileConfigurations.ShouldBe([second, imported, original]); + } + + [Fact] + public async Task ImportProfile_DoesNotRaiseProfileAdded() + { + ProfileConfiguration original = CreateSavedProfile("Test profile"); + int profileAdded = 0; + int addedToCategory = 0; + ProfileService.ProfileAdded += (_, _) => profileAdded++; + General.ProfileConfigurationAdded += (_, _) => addedToCategory++; + + await ImportExport(original, General, true); + + // Pinned as-is: creating a profile raises ProfileAdded but importing one doesn't, only the category's own + // ProfileConfigurationAdded event tells listeners about an imported profile + profileAdded.ShouldBe(0); + addedToCategory.ShouldBe(1); + } + + [Fact] + public async Task ImportProfile_NotMadeUnique_ReturnsTheExistingProfile() + { + ProfileConfiguration original = CreateSavedProfile("Test profile"); + + ProfileConfiguration imported = await ImportExport(original, General, false); + + imported.ShouldBeSameAs(original); + General.ProfileConfigurations.ShouldBe([original]); + } + + [Fact] + public async Task ImportProfile_NotMadeUnique_KeepsTheIdOfANewProfile() + { + ProfileConfiguration original = CreateSavedProfile("Test profile"); + await using Stream export = await ProfileService.ExportProfile(original); + ProfileService.RemoveProfileConfiguration(original); + + ProfileConfiguration imported = await ProfileService.ImportProfile(export, General, false, false, null); + + imported.ProfileId.ShouldBe(original.ProfileId); + } + + [Fact] + public async Task ImportProfile_MarkedAsFreshImport_StaysFreshUntilSaved() + { + ProfileConfiguration original = CreateSavedProfile("Test profile"); + + ProfileConfiguration imported = await ImportExport(original, General, true, markAsFreshImport: true); + Profile profile = ProfileService.ActivateProfile(imported); + + // A fresh import is adapted to the devices when it is activated + profile.IsFreshImport.ShouldBeTrue(); + ProfileService.SaveProfile(profile, true); + profile.IsFreshImport.ShouldBeFalse(); + CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations + .Single(c => c.ProfileId == imported.ProfileId).Entity.Profile.IsFreshImport.ShouldBeFalse(); + } + + [Fact] + public async Task ImportProfile_WithAnImageAsIcon_ImportsTheIcon() + { + ProfileConfiguration original = CreateSavedProfile("Test profile"); + original.Icon.SetIconByStream(new MemoryStream([1, 2, 3])); + ProfileService.SaveProfileCategory(General); + + ProfileConfiguration imported = await ImportExport(original, General, true); + + imported.Icon.IconType.ShouldBe(ProfileConfigurationIconType.BitmapImage); + imported.Icon.IconBytes.ShouldBe([1, 2, 3]); + } + + [Fact] + public async Task ImportProfile_WithoutAConfiguration_Throws() + { + using MemoryStream archive = CreateArchive(("profile.json", "{}")); + + ArtemisCoreException exception = await Should.ThrowAsync(() => ProfileService.ImportProfile(archive, General, true, false, null)); + exception.Message.ShouldContain("configuration.json missing"); + } + + [Fact] + public async Task ImportProfile_WithoutAProfile_Throws() + { + using MemoryStream archive = CreateArchive(("configuration.json", "{}")); + + ArtemisCoreException exception = await Should.ThrowAsync(() => ProfileService.ImportProfile(archive, General, true, false, null)); + exception.Message.ShouldContain("profile.json missing"); + } + + [Fact] + public async Task ImportProfile_WithUnavailableBrushesAndEffects_KeepsThemForWhenTheyReturn() + { + TestLayerBrushProvider brushes = EnableFeature(new TestLayerBrushProvider()); + TestLayerEffectProvider effects = EnableFeature(new TestLayerEffectProvider()); + ProfileConfiguration original = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); + Profile profile = ProfileService.ActivateProfile(original); + Layer layer = AddLayer(profile, brushes, []); + AddEffect(layer, effects); + ProfileService.SaveProfile(profile, true); + await using Stream export = await ProfileService.ExportProfile(original); + DisableFeature(brushes); + DisableFeature(effects); + + ProfileConfiguration imported = await ProfileService.ImportProfile(export, General, true, false, null); + Profile importedProfile = ProfileService.ActivateProfile(imported); + Layer importedLayer = importedProfile.GetAllLayers().Single(); + + // Without the plugins the layer has no brush and a placeholder effect + importedLayer.LayerBrush.ShouldBeNull(); + importedLayer.BrokenState.ShouldNotBeNull(); + importedLayer.LayerEffects.Single().Descriptor.IsPlaceholder.ShouldBeTrue(); + + // Saving in the meantime doesn't lose them + ProfileService.SaveProfile(importedProfile, true); + EnableFeature(new TestLayerBrushProvider()); + EnableFeature(new TestLayerEffectProvider()); + ProfileService.DeactivateProfile(imported); + Layer reloadedLayer = ProfileService.ActivateProfile(imported).GetAllLayers().Single(); + reloadedLayer.LayerBrush.ShouldBeOfType(); + reloadedLayer.LayerEffects.Single().ShouldBeOfType(); + } + + [Fact] + public async Task ImportProfile_WithUnavailableNodes_DropsThemFromTheActivationCondition() + { + TestNodeProvider nodes = EnableFeature(new TestNodeProvider()); + ProfileConfiguration original = CreateSavedProfile("Test profile"); + UseActivationCondition(original, nodes, false); + ProfileService.SaveProfileCategory(General); + await using Stream export = await ProfileService.ExportProfile(original); + DisableFeature(nodes); + + ProfileConfiguration imported = await ProfileService.ImportProfile(export, General, true, false, null); + + // Pinned as-is: the node can't be loaded without its plugin, and because importing saves the category right + // away, the node and its connection are removed from the stored activation condition for good. Unlike missing + // brushes and effects, enabling the plugin afterwards doesn't bring it back, and the imported profile now + // activates unconditionally + imported.ActivationCondition.Nodes.OfType().ShouldBeEmpty(); + imported.Entity.ProfileConfiguration.ActivationCondition!.Nodes.ShouldNotContain(n => n.Type == nameof(TestBooleanNode)); + EnableFeature(new TestNodeProvider()); + ProfileConfiguration reloaded = CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations + .Single(c => c.ProfileId == imported.ProfileId); + reloaded.ActivationCondition.Nodes.OfType().ShouldBeEmpty(); + reloaded.ActivationCondition.ExitNodeConnected.ShouldBeFalse(); + } + + [Fact] + public async Task ImportProfile_WithAnUnavailableModule_IsMissingItsModuleUntilItIsEnabled() + { + TestModule module = EnableFeature(new TestModule()); + ProfileConfiguration original = CreateSavedProfile("Test profile"); + original.Module = module; + ProfileService.SaveProfileCategory(General); + await using Stream export = await ProfileService.ExportProfile(original); + DisableFeature(module); + + ProfileConfiguration imported = await ProfileService.ImportProfile(export, General, true, false, null); + + imported.IsMissingModule.ShouldBeTrue(); + imported.Entity.ProfileConfiguration.ModuleId.ShouldBe(module.Id); + PluginManagement.EnablePluginFeature(module, false, false); + imported.Module.ShouldBeSameAs(module); + } + + [Fact] + public void ProfilesThatWereNeverSavedInTheEditor_AreStoredAsVersionZero() + { + ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); + GetStoredProfiles().Single().ProfileConfiguration.Version.ShouldBe(0); + + ProfileService.SaveProfile(ProfileService.ActivateProfile(configuration), true); + + GetStoredProfiles().Single().ProfileConfiguration.Version.ShouldBeGreaterThan(0); + } + + [Fact] + public void ProfilesThatWereNeverSavedInTheEditor_FailToMigrateOnStartupButStillLoad() + { + ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); + + // Artemis migrates stored profiles once on startup, a failure is logged and the profile is left as it is + Resolve().MigrateProfiles(); + + GetStoredProfiles().Single().ProfileConfiguration.Version.ShouldBe(0); + CreateNewProfileService().ProfileCategories.Single(c => c.Name == "General").ProfileConfigurations.Single().Name.ShouldBe("Test profile"); + } + + [Fact] + public async Task ImportProfile_OfAProfileThatWasNeverSavedInTheEditor_Fails() + { + ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, "Test profile", "TestTube"); + await using Stream export = await ProfileService.ExportProfile(configuration); + + // Pinned as-is: saving the category (which is all creating a profile does) stores the profile without setting its + // migration version, only SaveProfile sets it. The export says version 0, so importing runs every migration on + // JSON that is already up to date and the first one fails on it. Duplicating a freshly created profile from the + // sidebar or the editor (an export followed by an import) runs into this. Restarting Artemis runs into it too, but + // there the failed migration is only logged and the profile loads normally, see the test above + InvalidOperationException exception = await Should.ThrowAsync(() => ProfileService.ImportProfile(export, General, true, false, "copy")); + exception.StackTrace!.ShouldContain("M0001NodeProviders"); + General.ProfileConfigurations.ShouldBe([configuration]); + } + + /// + /// Creates a profile and saves it the way the editor does, which also stamps it with the latest migration version + /// + private ProfileConfiguration CreateSavedProfile(string name) + { + ProfileConfiguration configuration = ProfileService.CreateProfileConfiguration(General, name, "TestTube"); + ProfileService.SaveProfile(ProfileService.ActivateProfile(configuration), true); + ProfileService.DeactivateProfile(configuration); + return configuration; + } + + private async Task ImportExport(ProfileConfiguration configuration, ProfileCategory category, bool makeUnique, + bool markAsFreshImport = false, string? nameAffix = null, ProfileConfiguration? target = null) + { + await using Stream export = await ProfileService.ExportProfile(configuration); + return await ProfileService.ImportProfile(export, category, makeUnique, markAsFreshImport, nameAffix, target); + } + + private static string ReadEntry(ZipArchive archive, string name) + { + using StreamReader reader = new(archive.GetEntry(name)!.Open()); + return reader.ReadToEnd(); + } + + private static MemoryStream CreateArchive(params (string Name, string Content)[] entries) + { + MemoryStream stream = new(); + using (ZipArchive archive = new(stream, ZipArchiveMode.Create, true)) + { + foreach ((string name, string content) in entries) + { + using Stream entryStream = archive.CreateEntry(name).Open(); + entryStream.Write(Encoding.UTF8.GetBytes(content)); + } + } + + stream.Seek(0, SeekOrigin.Begin); + return stream; + } +} diff --git a/src/Artemis.Core.Tests/Services/RegistrationServiceTests.cs b/src/Artemis.Core.Tests/Services/RegistrationServiceTests.cs new file mode 100644 index 000000000..024457dc2 --- /dev/null +++ b/src/Artemis.Core.Tests/Services/RegistrationServiceTests.cs @@ -0,0 +1,283 @@ +using Artemis.Core.LayerBrushes; +using Artemis.Core.LayerEffects; +using Artemis.Core.Modules; +using Artemis.Core.Services; +using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes; +using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects; +using Artemis.Core.Tests.Infrastructure.Plugins.Modules; +using Artemis.Core.Tests.Infrastructure.Services; + +namespace Artemis.Core.Tests.Services; + +public class DataModelServiceTests : ServiceTestBase +{ + [Fact] + public void ModulesEnabledBeforeTheServiceStarts_HaveTheirDataModelRegistered() + { + TestModule module = EnableFeature(new TestModule()); + + IDataModelService dataModels = Resolve(); + + dataModels.GetDataModels().ShouldContain(module.DataModel); + dataModels.GetPluginDataModel(module).ShouldBeSameAs(module.DataModel); + } + + [Fact] + public void ModulesEnabledLater_HaveTheirDataModelRegistered() + { + IDataModelService dataModels = Resolve(); + + TestModule module = EnableFeature(new TestModule()); + + dataModels.GetDataModels().ShouldContain(module.DataModel); + dataModels.GetDataModel().ShouldBeSameAs(module.DataModel); + } + + [Fact] + public void DisablingAModule_RemovesItsDataModel() + { + IDataModelService dataModels = Resolve(); + TestModule module = EnableFeature(new TestModule()); + TestDataModel dataModel = module.DataModel; + + DisableFeature(module); + + dataModels.GetDataModels().ShouldNotContain(dataModel); + dataModels.GetPluginDataModel(module).ShouldBeNull(); + dataModels.GetDataModel().ShouldBeNull(); + } + + [Fact] + public void AModuleWithoutRequirements_IsAnExpansion() + { + Resolve(); + + TestModule alwaysAvailable = EnableFeature(new TestModule()); + TestModule withRequirements = EnableFeature(new TestModule {Requirements = [new BooleanActivationRequirement()]}); + + // Expansions extend the data model of every profile rather than only of profiles targeting the module + alwaysAvailable.DataModel.IsExpansion.ShouldBeTrue(); + withRequirements.DataModel.IsExpansion.ShouldBeFalse(); + } + + [Fact] + public void DataModel_DescribesTheModule() + { + Resolve(); + + TestModule module = EnableFeature(new TestModule()); + + module.DataModel.DataModelDescription.ShouldNotBeNull(); + module.DataModel.DataModelDescription.Name.ShouldBe(module.Info.Name); + module.DataModel.Module.ShouldBeSameAs(module); + } + + [Fact] + public void RegisterDataModel_TheSameDataModelTwice_Throws() + { + IDataModelService dataModels = Resolve(); + TestModule module = EnableFeature(new TestModule()); + + Should.Throw(() => dataModels.RegisterDataModel(module.DataModel)); + } + + [Fact] + public void RemoveDataModel_RemovesTheRegistration() + { + IDataModelService dataModels = Resolve(); + TestModule module = EnableFeature(new TestModule()); + DataModelRegistration registration = DataModelStore.GetAll().Single(r => r.DataModel == module.DataModel); + + dataModels.RemoveDataModel(registration); + + registration.IsInStore.ShouldBeFalse(); + dataModels.GetDataModels().ShouldNotContain(module.DataModel); + // Removing it twice is not allowed + Should.Throw(() => dataModels.RemoveDataModel(registration)); + } + + [Fact] + public void NullArguments_Throw() + { + IDataModelService dataModels = Resolve(); + + Should.Throw(() => dataModels.RegisterDataModel(null!)); + Should.Throw(() => dataModels.RemoveDataModel(null!)); + } +} + +public class LayerBrushServiceTests : ServiceTestBase +{ + private const string DefaultBrushSetting = "ProfileEditor.DefaultLayerBrushDescriptor"; + + [Fact] + public void GetLayerBrushes_ContainsTheBrushesOfEnabledProviders() + { + ILayerBrushService brushes = Resolve(); + + TestLayerBrushProvider provider = EnableFeature(new TestLayerBrushProvider()); + + brushes.GetLayerBrushes().Where(d => d.Provider == provider).Select(d => d.LayerBrushType) + .ShouldBe([typeof(SolidColorTestBrush), typeof(GradientTestBrush), typeof(PerLedTestBrush)]); + } + + [Fact] + public void GetLayerBrushes_NoLongerContainsTheBrushesOfADisabledProvider() + { + ILayerBrushService brushes = Resolve(); + TestLayerBrushProvider provider = EnableFeature(new TestLayerBrushProvider()); + + DisableFeature(provider); + + brushes.GetLayerBrushes().ShouldNotContain(d => d.Provider == provider); + } + + [Fact] + public void RegisterAndRemoveLayerBrush_AddsAndRemovesTheBrush() + { + ILayerBrushService brushes = Resolve(); + TestLayerBrushProvider provider = EnableFeature(new TestLayerBrushProvider()); + LayerBrushDescriptor descriptor = provider.LayerBrushDescriptors.First(); + LayerBrushRegistration registration = LayerBrushStore.Get(provider.Id, descriptor.LayerBrushType.Name)!; + + brushes.RemoveLayerBrush(registration); + brushes.GetLayerBrushes().ShouldNotContain(descriptor); + + LayerBrushRegistration added = brushes.RegisterLayerBrush(descriptor); + added.LayerBrushDescriptor.ShouldBeSameAs(descriptor); + brushes.GetLayerBrushes().ShouldContain(descriptor); + } + + [Fact] + public void NullArguments_Throw() + { + ILayerBrushService brushes = Resolve(); + + Should.Throw(() => brushes.RegisterLayerBrush(null!)); + Should.Throw(() => brushes.RemoveLayerBrush(null!)); + } + + [Fact] + public void GetDefaultLayerBrush_WithoutTheColorBrushPlugin_IsNull() + { + ILayerBrushService brushes = Resolve(); + EnableFeature(new TestLayerBrushProvider()); + + // By default this is the solid brush of the Color brush plugin in the Artemis.Plugins repository + brushes.GetDefaultLayerBrush().ShouldBeNull(); + + LayerBrushReference reference = Resolve().GetSetting(DefaultBrushSetting).Value!; + reference.LayerBrushProviderId.ShouldBe("Artemis.Plugins.LayerBrushes.Color.ColorBrushProvider-92a9d6ba"); + reference.BrushType.ShouldBe("SolidBrush"); + } + + [Fact] + public void GetDefaultLayerBrush_FollowsTheSetting() + { + ILayerBrushService brushes = Resolve(); + TestLayerBrushProvider provider = EnableFeature(new TestLayerBrushProvider()); + LayerBrushDescriptor gradient = provider.LayerBrushDescriptors.Single(d => d.LayerBrushType == typeof(GradientTestBrush)); + + Resolve().GetSetting(DefaultBrushSetting).Value = new LayerBrushReference(gradient); + + brushes.GetDefaultLayerBrush().ShouldBeSameAs(gradient); + } + + [Fact] + public void GetDefaultLayerBrush_FillsInAnEmptySetting() + { + ILayerBrushService brushes = Resolve(); + PluginSetting setting = Resolve().GetSetting(DefaultBrushSetting); + setting.Value = new LayerBrushReference(); + + brushes.GetDefaultLayerBrush(); + + setting.Value!.LayerBrushProviderId.ShouldBe("Artemis.Plugins.LayerBrushes.Color.ColorBrushProvider-92a9d6ba"); + setting.Value.BrushType.ShouldBe("SolidBrush"); + } + + [Fact] + public void ApplyDefaultBrush_GivesTheLayerTheDefaultBrush() + { + ILayerBrushService brushes = Resolve(); + TestLayerBrushProvider provider = EnableFeature(new TestLayerBrushProvider()); + LayerBrushDescriptor solid = provider.LayerBrushDescriptors.Single(d => d.LayerBrushType == typeof(SolidColorTestBrush)); + Resolve().GetSetting(DefaultBrushSetting).Value = new LayerBrushReference(solid); + Layer layer = CreateLayer(); + + brushes.ApplyDefaultBrush(layer); + + layer.LayerBrush.ShouldBeOfType(); + } + + [Fact] + public void ApplyDefaultBrush_WithoutTheDefaultBrush_LeavesTheLayerAlone() + { + ILayerBrushService brushes = Resolve(); + Layer layer = CreateLayer(); + + brushes.ApplyDefaultBrush(layer); + + layer.LayerBrush.ShouldBeNull(); + } + + private static Layer CreateLayer() + { + ProfileConfiguration configuration = new(new ProfileCategory("Tests", 0), "Test profile", "TestTube"); + Profile profile = new(configuration, configuration.Entity.Profile); + Layer layer = new(profile.GetRootFolder(), "Test layer"); + profile.GetRootFolder().AddChild(layer); + return layer; + } +} + +public class LayerEffectServiceTests : ServiceTestBase +{ + [Fact] + public void GetLayerEffects_ContainsTheEffectsOfEnabledProviders() + { + ILayerEffectService effects = Resolve(); + + TestLayerEffectProvider provider = EnableFeature(new TestLayerEffectProvider()); + + effects.GetLayerEffects().Where(d => d.Provider == provider).Select(d => d.LayerEffectType) + .ShouldBe([typeof(OpacityTestEffect), typeof(ChannelSwapTestEffect), typeof(TintTestEffect), typeof(GlowTestEffect)]); + } + + [Fact] + public void GetLayerEffects_NoLongerContainsTheEffectsOfADisabledProvider() + { + ILayerEffectService effects = Resolve(); + TestLayerEffectProvider provider = EnableFeature(new TestLayerEffectProvider()); + + DisableFeature(provider); + + effects.GetLayerEffects().ShouldNotContain(d => d.Provider == provider); + } + + [Fact] + public void RegisterAndRemoveLayerEffect_AddsAndRemovesTheEffect() + { + ILayerEffectService effects = Resolve(); + TestLayerEffectProvider provider = EnableFeature(new TestLayerEffectProvider()); + LayerEffectDescriptor descriptor = provider.LayerEffectDescriptors.First(); + // Unlike brushes, effects are looked up by the full name of their type + LayerEffectRegistration registration = LayerEffectStore.Get(provider.Id, descriptor.LayerEffectType!.FullName!)!; + + effects.RemoveLayerEffect(registration); + effects.GetLayerEffects().ShouldNotContain(descriptor); + + LayerEffectRegistration added = effects.RegisterLayerEffect(descriptor); + added.LayerEffectDescriptor.ShouldBeSameAs(descriptor); + effects.GetLayerEffects().ShouldContain(descriptor); + } + + [Fact] + public void NullArguments_Throw() + { + ILayerEffectService effects = Resolve(); + + Should.Throw(() => effects.RegisterLayerEffect(null!)); + Should.Throw(() => effects.RemoveLayerEffect(null!)); + } +} diff --git a/src/Artemis.Core.Tests/Services/ServiceHarnessTests.cs b/src/Artemis.Core.Tests/Services/ServiceHarnessTests.cs new file mode 100644 index 000000000..19d639e2a --- /dev/null +++ b/src/Artemis.Core.Tests/Services/ServiceHarnessTests.cs @@ -0,0 +1,49 @@ +using Artemis.Core.Services; +using Artemis.Core.Tests.Infrastructure.Services; +using Artemis.Storage.Repositories.Interfaces; + +namespace Artemis.Core.Tests.Services; + +public class ServiceHarnessTests : IDisposable +{ + private readonly ServiceHarness _services = new(); + + [Fact] + public void Settings_AreStoredInTheTemporaryDatabase() + { + PluginSetting setting = _services.Resolve().GetSetting("Tests.Number", 1); + setting.Value = 5; + setting.Save(); + + // A fresh settings instance reads the value back from the database + SettingsService reloaded = new(_services.Resolve()); + reloaded.GetSetting("Tests.Number", 1).Value.ShouldBe(5); + using var context = _services.CreateDbContext(); + context.PluginSettings.ShouldContain(s => s.Name == "Tests.Number"); + } + + [Fact] + public void ProfileService_StoresCategoriesInTheTemporaryDatabase() + { + IProfileService profileService = _services.Resolve(); + + profileService.CreateProfileCategory("Test category"); + + using var context = _services.CreateDbContext(); + context.ProfileCategories.ShouldContain(c => c.Name == "Test category"); + } + + [Fact] + public void Harness_DoesNotTouchTheRealDataFolder() + { + _services.DataFolder.ShouldNotStartWith(Constants.DataFolder); + _services.Resolve().GetSetting("Tests.Number", 1).Save(); + + File.Exists(Path.Combine(_services.DataFolder, "artemis.db")).ShouldBeTrue(); + } + + public void Dispose() + { + _services.Dispose(); + } +} diff --git a/src/Artemis.Core.Tests/Services/SettingsServiceTests.cs b/src/Artemis.Core.Tests/Services/SettingsServiceTests.cs new file mode 100644 index 000000000..61db1621a --- /dev/null +++ b/src/Artemis.Core.Tests/Services/SettingsServiceTests.cs @@ -0,0 +1,286 @@ +using System.ComponentModel; +using Artemis.Core.Services; +using Artemis.Core.Tests.Infrastructure.Plugins; +using Artemis.Core.Tests.Infrastructure.Services; +using Artemis.Storage.Repositories.Interfaces; + +namespace Artemis.Core.Tests.Services; + +public class SettingsServiceTests : IDisposable +{ + private readonly ServiceHarness _services = new(); + + private ISettingsService Settings => _services.Resolve(); + + /// + /// A settings service on the same database, like starting Artemis again + /// + private SettingsService CreateNewSettingsService() + { + return new SettingsService(_services.Resolve()); + } + + [Fact] + public void GetSetting_ANewSetting_HasTheDefaultValue() + { + PluginSetting setting = Settings.GetSetting("Tests.Number", 42); + + setting.Name.ShouldBe("Tests.Number"); + setting.Value.ShouldBe(42); + setting.HasChanged.ShouldBeFalse(); + } + + [Fact] + public void GetSetting_WithoutADefault_UsesTheDefaultOfTheType() + { + Settings.GetSetting("Tests.Number").Value.ShouldBe(0); + Settings.GetSetting("Tests.Text").Value.ShouldBeNull(); + } + + [Fact] + public void GetSetting_ANewSetting_IsStoredRightAway() + { + Settings.GetSetting("Tests.Number", 42); + + // Without saving, the default value is already in the database + CreateNewSettingsService().GetSetting("Tests.Number", 1).Value.ShouldBe(42); + } + + [Fact] + public void GetSetting_ASecondTime_ReturnsTheSameSetting() + { + PluginSetting first = Settings.GetSetting("Tests.Number", 1); + PluginSetting second = Settings.GetSetting("Tests.Number", 2); + + second.ShouldBeSameAs(first); + second.Value.ShouldBe(1); + } + + [Fact] + public void GetSetting_ASecondTimeWithAnotherType_Throws() + { + Settings.GetSetting("Tests.Number", 1); + + Should.Throw(() => Settings.GetSetting("Tests.Number", "text")); + } + + [Fact] + public void GetSetting_AStoredSetting_IgnoresTheDefaultValue() + { + PluginSetting setting = Settings.GetSetting("Tests.Number", 1); + setting.Value = 5; + setting.Save(); + + CreateNewSettingsService().GetSetting("Tests.Number", 1).Value.ShouldBe(5); + } + + [Fact] + public void GetSetting_AStoredValueThatDoesNotFitTheType_UsesTheDefaultOfTheType() + { + Settings.GetSetting("Tests.Value", "not a number"); + + // Pinned as-is: when the stored value can't be read as the requested type the setting falls back to default(T) + // rather than to the default value passed in, so the caller's default of 7 is ignored here + PluginSetting setting = CreateNewSettingsService().GetSetting("Tests.Value", 7); + + setting.Value.ShouldBe(0); + setting.HasChanged.ShouldBeTrue(); + } + + [Fact] + public void GetSetting_ANameLongerThan128Characters_Throws() + { + Should.Throw(() => Settings.GetSetting(new string('a', 129), 1)); + Should.NotThrow(() => Settings.GetSetting(new string('a', 128), 1)); + } + + [Fact] + public void ChangingTheValue_RaisesSettingChangedAndPropertyChanged() + { + PluginSetting setting = Settings.GetSetting("Tests.Number", 1); + int settingChanged = 0; + List propertiesChanged = []; + setting.SettingChanged += (_, _) => settingChanged++; + ((INotifyPropertyChanged) setting).PropertyChanged += (_, e) => propertiesChanged.Add(e.PropertyName); + + setting.Value = 2; + + settingChanged.ShouldBe(1); + propertiesChanged.ShouldBe(["Value"]); + setting.HasChanged.ShouldBeTrue(); + } + + [Fact] + public void SettingTheSameValue_RaisesNothing() + { + PluginSetting setting = Settings.GetSetting("Tests.Number", 1); + int settingChanged = 0; + setting.SettingChanged += (_, _) => settingChanged++; + + setting.Value = 1; + + settingChanged.ShouldBe(0); + } + + [Fact] + public void ChangingTheValue_IsNotStoredUntilSaved() + { + PluginSetting setting = Settings.GetSetting("Tests.Number", 1); + + setting.Value = 2; + + CreateNewSettingsService().GetSetting("Tests.Number", 1).Value.ShouldBe(1); + } + + [Fact] + public void Save_StoresTheValueAndRaisesSettingSaved() + { + PluginSetting setting = Settings.GetSetting("Tests.Number", 1); + int saved = 0; + setting.SettingSaved += (_, _) => saved++; + + setting.Value = 2; + setting.Save(); + + saved.ShouldBe(1); + setting.HasChanged.ShouldBeFalse(); + CreateNewSettingsService().GetSetting("Tests.Number", 1).Value.ShouldBe(2); + } + + [Fact] + public void Save_WithoutChanges_DoesNothing() + { + PluginSetting setting = Settings.GetSetting("Tests.Number", 1); + int saved = 0; + setting.SettingSaved += (_, _) => saved++; + + setting.Save(); + + saved.ShouldBe(0); + } + + [Fact] + public void Save_AfterChangingAListInPlace_StoresTheChange() + { + PluginSetting> setting = Settings.GetSetting("Tests.Numbers", new List {1}); + + // Changes are detected by comparing the stored JSON, so changing the list itself counts as a change + setting.Value!.Add(2); + setting.HasChanged.ShouldBeTrue(); + setting.Save(); + + CreateNewSettingsService().GetSetting>("Tests.Numbers").Value.ShouldBe([1, 2]); + } + + [Fact] + public void AutoSave_StoresEveryChange() + { + PluginSetting setting = Settings.GetSetting("Tests.Number", 1); + int saved = 0; + setting.SettingSaved += (_, _) => saved++; + setting.AutoSave = true; + + setting.Value = 2; + setting.Value = 3; + + saved.ShouldBe(2); + setting.HasChanged.ShouldBeFalse(); + CreateNewSettingsService().GetSetting("Tests.Number", 1).Value.ShouldBe(3); + } + + [Fact] + public void RejectChanges_GoesBackToTheLastSavedValue() + { + PluginSetting setting = Settings.GetSetting("Tests.Number", 1); + setting.Value = 2; + setting.Save(); + setting.Value = 3; + int settingChanged = 0; + setting.SettingChanged += (_, _) => settingChanged++; + + setting.RejectChanges(); + + setting.Value.ShouldBe(2); + setting.HasChanged.ShouldBeFalse(); + settingChanged.ShouldBe(1); + } + + [Fact] + public void SaveAllSettings_SavesEveryChangedSetting() + { + PluginSetting number = Settings.GetSetting("Tests.Number", 1); + PluginSetting text = Settings.GetSetting("Tests.Text", "a"); + PluginSetting unchanged = Settings.GetSetting("Tests.Boolean", true); + int unchangedSaved = 0; + unchanged.SettingSaved += (_, _) => unchangedSaved++; + number.Value = 2; + text.Value = "b"; + + Settings.SaveAllSettings(); + + SettingsService reloaded = CreateNewSettingsService(); + reloaded.GetSetting("Tests.Number", 1).Value.ShouldBe(2); + reloaded.GetSetting("Tests.Text", "a").Value.ShouldBe("b"); + unchangedSaved.ShouldBe(0); + } + + [Fact] + public void ToString_DescribesTheSetting() + { + PluginSetting setting = Settings.GetSetting("Tests.Number", 1); + setting.Value = 2; + + setting.ToString().ShouldBe("Name: Tests.Number, Value: 2, HasChanged: True"); + } + + [Fact] + public void PluginSettings_AreKeptApartPerPlugin() + { + using TestPlugin first = new(); + using TestPlugin second = new(); + IPluginRepository repository = _services.Resolve(); + PluginSettings firstSettings = new(first.Plugin, repository); + PluginSettings secondSettings = new(second.Plugin, repository); + + PluginSetting setting = firstSettings.GetSetting("Number", 1); + setting.Value = 5; + setting.Save(); + + secondSettings.GetSetting("Number", 1).Value.ShouldBe(1); + new PluginSettings(first.Plugin, repository).GetSetting("Number", 1).Value.ShouldBe(5); + // Core settings belong to their own plugin + Settings.GetSetting("Number", 1).Value.ShouldBe(1); + } + + [Fact] + public void PluginSettings_BelongToThePlugin() + { + using TestPlugin plugin = new(); + + PluginSettings settings = new(plugin.Plugin, _services.Resolve()); + + settings.Plugin.ShouldBeSameAs(plugin.Plugin); + plugin.Plugin.Settings.ShouldBeSameAs(settings); + } + + [Fact] + public void PluginSettings_ClearSettings_ForgetsLoadedSettings() + { + using TestPlugin plugin = new(); + PluginSettings settings = new(plugin.Plugin, _services.Resolve()); + PluginSetting before = settings.GetSetting("Number", 1); + before.Value = 2; + + settings.ClearSettings(); + PluginSetting after = settings.GetSetting("Number", 1); + + // A new setting is read from the database, the unsaved change is gone + after.ShouldNotBeSameAs(before); + after.Value.ShouldBe(1); + } + + public void Dispose() + { + _services.Dispose(); + } +} diff --git a/src/Artemis.Core.Tests/Storage/Migrations/M0001NodeProvidersTests.cs b/src/Artemis.Core.Tests/Storage/Migrations/M0001NodeProvidersTests.cs new file mode 100644 index 000000000..19708aa40 --- /dev/null +++ b/src/Artemis.Core.Tests/Storage/Migrations/M0001NodeProvidersTests.cs @@ -0,0 +1,139 @@ +using System.Text.Json.Nodes; +using Artemis.Core.Tests.Infrastructure.Storage; +using Artemis.Storage.Migrations.Profile; + +namespace Artemis.Core.Tests.Storage.Migrations; + +public class M0001NodeProvidersTests +{ + private readonly M0001NodeProviders _migration = new(); + + [Fact] + public void Version_IsOne() + { + _migration.Version.ShouldBe(1); + } + + [Theory] + [InlineData(OldProfileJson.FolderConditionNodeId, "Artemis.Plugins.Nodes.General.Nodes.DataModel.DataModelNode")] + [InlineData(OldProfileJson.FolderEffectNodeId, "Artemis.Plugins.Nodes.General.Nodes.Mathematics.MathExpressionNode")] + [InlineData(OldProfileJson.LayerConditionNodeId, "Artemis.Plugins.Nodes.General.Nodes.Static.StaticBooleanValueNode")] + [InlineData(OldProfileJson.LayerTransformNodeId, "Artemis.Plugins.Nodes.General.Nodes.Static.StaticNumericValueNode")] + [InlineData(OldProfileJson.LayerBrushNodeId, "Artemis.Plugins.Nodes.General.Nodes.Color.ColorGradientNode")] + public void Migrate_MovesNodesEverywhereInTheProfileToTheGeneralNodesPlugin(string nodeId, string expectedType) + { + JsonObject profile = OldProfileJson.Parse(OldProfileJson.Version0Profile); + + _migration.Migrate(OldProfileJson.Parse(OldProfileJson.Version0Configuration), profile); + + JsonObject node = profile.FindNode(nodeId); + node["Type"]!.GetValue().ShouldBe(expectedType); + node["ProviderId"]!.GetValue().ShouldBe(OldProfileJson.GeneralNodesProviderId); + } + + [Fact] + public void Migrate_ChangesNothingButTheNodes() + { + JsonObject profile = OldProfileJson.Parse(OldProfileJson.Version0Profile); + + _migration.Migrate(OldProfileJson.Parse(OldProfileJson.Version0Configuration), profile); + + // Undo the two node changes, what remains should be the original + foreach (JsonObject node in profile.FindNodes()) + { + node.Remove("ProviderId"); + node["Type"] = node["Type"]!.GetValue().Replace("Artemis.Plugins.Nodes.General.Nodes", "Artemis.VisualScripting.Nodes"); + } + + profile.ShouldBeJson(OldProfileJson.Version0Profile); + } + + [Fact] + public void Migrate_LeavesTheConfigurationAlone() + { + // The activation condition was forgotten in this migration, M0002 migrates it + JsonObject configuration = OldProfileJson.Parse(OldProfileJson.Version0Configuration); + + _migration.Migrate(configuration, OldProfileJson.Parse(OldProfileJson.Version0Profile)); + + configuration.ShouldBeJson(OldProfileJson.Version0Configuration); + } + + [Fact] + public void Migrate_GivesNodesOfOtherNamespacesTheProviderButKeepsTheirType() + { + JsonObject profile = OldProfileJson.Parse(OldProfileJson.Version0Profile); + JsonObject node = profile.FindNode(OldProfileJson.LayerTransformNodeId); + node["Type"] = "Artemis.Core.Internal.DataBindingExitNode`1"; + + _migration.Migrate(new JsonObject(), profile); + + // At version 0 all nodes came from Artemis itself, so every node is given the general nodes provider + node["Type"]!.GetValue().ShouldBe("Artemis.Core.Internal.DataBindingExitNode`1"); + node["ProviderId"]!.GetValue().ShouldBe(OldProfileJson.GeneralNodesProviderId); + } + + [Fact] + public void Migrate_RunningItTwice_GivesTheSameResult() + { + JsonObject once = OldProfileJson.Parse(OldProfileJson.Version0Profile); + _migration.Migrate(new JsonObject(), once); + JsonObject twice = OldProfileJson.Parse(OldProfileJson.Version0Profile); + _migration.Migrate(new JsonObject(), twice); + _migration.Migrate(new JsonObject(), twice); + + twice.ShouldBeJson(once.ToJsonString()); + } + + [Fact] + public void Migrate_ProfileWithoutFoldersOrLayers_DoesNothing() + { + JsonObject profile = new() {["Name"] = "Empty"}; + + _migration.Migrate(new JsonObject(), profile); + + profile.ShouldBeJson("""{"Name": "Empty"}"""); + } + + [Fact] + public void Migrate_LayerWithoutPropertiesOrConditions_DoesNothing() + { + const string json = """ + { + "Folders": {"$type": "List", "$values": [null, {"Name": "Folder"}]}, + "Layers": {"$type": "List", "$values": [null, {"Name": "Layer", "DisplayCondition": {"$type": "AlwaysOn"}, "LayerBrush": null}]} + } + """; + JsonObject profile = OldProfileJson.Parse(json); + + _migration.Migrate(new JsonObject(), profile); + + profile.ShouldBeJson(json); + } + + [Fact] + public void Migrate_NodeWithoutType_GetsANullType() + { + JsonObject profile = OldProfileJson.Parse(OldProfileJson.Version0Profile); + JsonObject node = profile.FindNode(OldProfileJson.LayerTransformNodeId); + node.Remove("Type"); + + _migration.Migrate(new JsonObject(), profile); + + // Pinned as-is: a missing type is written back as "Type": null rather than left out, which gives the NodeEntity + // a null type even though the property isn't nullable + node.ContainsKey("Type").ShouldBeTrue(); + node["Type"].ShouldBeNull(); + node["ProviderId"]!.GetValue().ShouldBe(OldProfileJson.GeneralNodesProviderId); + } + + [Fact] + public void Migrate_ProfileAlreadyInTheNewFormat_Throws() + { + // Lists in the new format are plain arrays, this migration only understands the old {"$values": []} lists. + // MigrateProfile only runs it on profiles below version 1 so this shouldn't happen. + JsonObject profile = OldProfileJson.Parse(OldProfileJson.MigratedProfile); + + Should.Throw(() => _migration.Migrate(new JsonObject(), profile)); + } +} diff --git a/src/Artemis.Core.Tests/Storage/Migrations/M0002NodeProvidersProfileConfigTests.cs b/src/Artemis.Core.Tests/Storage/Migrations/M0002NodeProvidersProfileConfigTests.cs new file mode 100644 index 000000000..9f1454d90 --- /dev/null +++ b/src/Artemis.Core.Tests/Storage/Migrations/M0002NodeProvidersProfileConfigTests.cs @@ -0,0 +1,93 @@ +using System.Text.Json.Nodes; +using Artemis.Core.Tests.Infrastructure.Storage; +using Artemis.Storage.Migrations.Profile; + +namespace Artemis.Core.Tests.Storage.Migrations; + +public class M0002NodeProvidersProfileConfigTests +{ + private readonly M0002NodeProvidersProfileConfig _migration = new(); + + [Fact] + public void Version_IsTwo() + { + _migration.Version.ShouldBe(2); + } + + [Fact] + public void Migrate_MovesActivationConditionNodesToTheGeneralNodesPlugin() + { + JsonObject configuration = OldProfileJson.Parse(OldProfileJson.Version0Configuration); + + _migration.Migrate(configuration, OldProfileJson.Parse(OldProfileJson.Version0Profile)); + + JsonObject dataModelNode = configuration.FindNode(OldProfileJson.ActivationDataModelNodeId); + dataModelNode["Type"]!.GetValue().ShouldBe("Artemis.Plugins.Nodes.General.Nodes.DataModel.DataModelNode"); + dataModelNode["ProviderId"]!.GetValue().ShouldBe(OldProfileJson.GeneralNodesProviderId); + + JsonObject exitNode = configuration.FindNode(OldProfileJson.ActivationExitNodeId); + exitNode["Type"]!.GetValue().ShouldBe("Artemis.Core.Internal.ExitNode`1"); + exitNode["ProviderId"]!.GetValue().ShouldBe(OldProfileJson.GeneralNodesProviderId); + } + + [Fact] + public void Migrate_ChangesNothingButTheNodes() + { + JsonObject configuration = OldProfileJson.Parse(OldProfileJson.Version0Configuration); + + _migration.Migrate(configuration, new JsonObject()); + + foreach (JsonObject node in configuration.FindNodes()) + { + node.Remove("ProviderId"); + node["Type"] = node["Type"]!.GetValue().Replace("Artemis.Plugins.Nodes.General.Nodes", "Artemis.VisualScripting.Nodes"); + } + + configuration.ShouldBeJson(OldProfileJson.Version0Configuration); + } + + [Fact] + public void Migrate_LeavesTheProfileAlone() + { + JsonObject profile = OldProfileJson.Parse(OldProfileJson.Version0Profile); + + _migration.Migrate(OldProfileJson.Parse(OldProfileJson.Version0Configuration), profile); + + profile.ShouldBeJson(OldProfileJson.Version0Profile); + } + + [Fact] + public void Migrate_RunningItTwice_GivesTheSameResult() + { + JsonObject once = OldProfileJson.Parse(OldProfileJson.Version0Configuration); + _migration.Migrate(once, new JsonObject()); + JsonObject twice = OldProfileJson.Parse(OldProfileJson.Version0Configuration); + _migration.Migrate(twice, new JsonObject()); + _migration.Migrate(twice, new JsonObject()); + + twice.ShouldBeJson(once.ToJsonString()); + } + + [Theory] + [InlineData("""{"Name": "No condition"}""")] + [InlineData("""{"Name": "Empty condition", "ActivationCondition": null}""")] + [InlineData("""{"Name": "Condition without nodes", "ActivationCondition": {"Name": "Script"}}""")] + [InlineData("""{"Name": "Empty node list", "ActivationCondition": {"Nodes": {"$type": "List", "$values": [null]}}}""")] + public void Migrate_ConfigurationWithoutNodes_DoesNothing(string json) + { + JsonObject configuration = OldProfileJson.Parse(json); + + _migration.Migrate(configuration, new JsonObject()); + + configuration.ShouldBeJson(json); + } + + [Fact] + public void Migrate_ConfigurationAlreadyInTheNewFormat_Throws() + { + // Like M0001 it only understands the old {"$values": []} lists + JsonObject configuration = OldProfileJson.Parse(OldProfileJson.MigratedConfiguration); + + Should.Throw(() => _migration.Migrate(configuration, new JsonObject())); + } +} diff --git a/src/Artemis.Core.Tests/Storage/Migrations/M0003SystemTextJsonTests.cs b/src/Artemis.Core.Tests/Storage/Migrations/M0003SystemTextJsonTests.cs new file mode 100644 index 000000000..90dfb9441 --- /dev/null +++ b/src/Artemis.Core.Tests/Storage/Migrations/M0003SystemTextJsonTests.cs @@ -0,0 +1,191 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Artemis.Core.Tests.Infrastructure.Storage; +using Artemis.Storage.Entities.Profile; +using Artemis.Storage.Entities.Profile.AdaptionHints; +using Artemis.Storage.Entities.Profile.Conditions; +using Artemis.Storage.Migrations.Profile; + +namespace Artemis.Core.Tests.Storage.Migrations; + +public class M0003SystemTextJsonTests +{ + private readonly M0003SystemTextJson _migration = new(); + + [Fact] + public void Version_IsThree() + { + _migration.Version.ShouldBe(3); + } + + [Fact] + public void Migrate_ConvertsEverythingButNodeStorage() + { + (JsonObject configuration, JsonObject profile) = Version2Profile(); + + _migration.Migrate(configuration, profile); + + // Node storage is a JSON string inside the JSON and is left for M0004, copy what M0004 makes of it + CopyNodeStorage(OldProfileJson.MigratedConfiguration, configuration); + CopyNodeStorage(OldProfileJson.MigratedProfile, profile); + // Only MigrateProfile sets the version + configuration["Version"] = 4; + configuration.ShouldBeJson(OldProfileJson.MigratedConfiguration); + profile.ShouldBeJson(OldProfileJson.MigratedProfile); + } + + [Fact] + public void Migrate_LeavesNodeStorageAlone() + { + (JsonObject configuration, JsonObject profile) = Version2Profile(); + + _migration.Migrate(configuration, profile); + + configuration.FindNode(OldProfileJson.ActivationDataModelNodeId)["Storage"]!.GetValue() + .ShouldStartWith("{\"$type\":\"Artemis.Storage.Entities.Profile.DataModelPathEntity, Artemis.Storage\""); + profile.FindNode(OldProfileJson.LayerBrushNodeId)["Storage"]!.GetValue() + .ShouldStartWith("{\"$type\":\"Artemis.Core.ColorGradient, Artemis.Core\",\"$values\":["); + } + + [Fact] + public void Migrate_ProfileCanBeReadAfterwards() + { + (JsonObject configuration, JsonObject profile) = Version2Profile(); + + _migration.Migrate(configuration, profile); + + ProfileEntity entity = profile.Deserialize(Constants.JsonConvertSettings)!; + entity.Folders.ShouldHaveSingleItem().DisplayCondition.ShouldBeOfType(); + LayerEntity layer = entity.Layers.ShouldHaveSingleItem(); + layer.DisplayCondition.ShouldBeOfType(); + layer.AdaptionHints.Count.ShouldBe(2); + layer.AdaptionHints[0].ShouldBeOfType(); + layer.AdaptionHints[1].ShouldBeOfType(); + layer.Leds.Count.ShouldBe(2); + + ProfileConfigurationEntity configurationEntity = configuration.Deserialize(Constants.JsonConvertSettings)!; + configurationEntity.ActivationCondition!.Nodes.Count.ShouldBe(2); + configurationEntity.EnableHotkey!.Key.ShouldBe(44); + } + + [Theory] + [InlineData("Artemis.Storage.Entities.Profile.AdaptionHints.CategoryAdaptionHintEntity, Artemis.Storage", "Category")] + [InlineData("Artemis.Storage.Entities.Profile.AdaptionHints.DeviceAdaptionHintEntity, Artemis.Storage", "Device")] + [InlineData("Artemis.Storage.Entities.Profile.AdaptionHints.KeyboardSectionAdaptionHintEntity, Artemis.Storage", "KeyboardSection")] + [InlineData("Artemis.Storage.Entities.Profile.AdaptionHints.SingleLedAdaptionHintEntity, Artemis.Storage", "SingleLed")] + [InlineData("Artemis.Storage.Entities.Profile.Conditions.AlwaysOnConditionEntity, Artemis.Storage", "AlwaysOn")] + [InlineData("Artemis.Storage.Entities.Profile.Conditions.EventConditionEntity, Artemis.Storage", "Event")] + [InlineData("Artemis.Storage.Entities.Profile.Conditions.PlayOnceConditionEntity, Artemis.Storage", "PlayOnce")] + [InlineData("Artemis.Storage.Entities.Profile.Conditions.StaticConditionEntity, Artemis.Storage", "Static")] + public void Migrate_GivesAdaptionHintsAndConditionsTheirShortTypeName(string oldType, string newType) + { + JsonObject profile = new() {["Element"] = new JsonObject {["$type"] = oldType, ["Value"] = 1}}; + + _migration.Migrate(new JsonObject(), profile); + + profile.ShouldBeJson($$$"""{"Element": {"$type": "{{{newType}}}", "Value": 1}}"""); + } + + [Theory] + [InlineData("Artemis.Storage.Entities.Profile.LayerEntity, Artemis.Storage")] + [InlineData("Artemis.Storage.Entities.Profile.Conditions.SomeNewConditionEntity, Artemis.Storage")] + [InlineData("Static")] + public void Migrate_RemovesAnyOtherTypeName(string type) + { + JsonObject profile = new() {["$type"] = type, ["Element"] = new JsonObject {["$type"] = type, ["Value"] = 1}}; + + _migration.Migrate(new JsonObject(), profile); + + profile.ShouldBeJson("""{"Element": {"Value": 1}}"""); + } + + [Fact] + public void Migrate_ReplacesListsByTheirValues_WhateverTheirType() + { + JsonObject profile = OldProfileJson.Parse(""" + { + "Numbers": {"$type": "System.Int32[], System.Private.CoreLib", "$values": [1, 2]}, + "Elements": {"$type": "Something else entirely", "$values": [{"$type": "Element", "Children": {"$type": "List", "$values": []}}]} + } + """); + + _migration.Migrate(new JsonObject(), profile); + + profile.ShouldBeJson("""{"Numbers": [1, 2], "Elements": [{"Children": []}]}"""); + } + + [Fact] + public void Migrate_ListDirectlyInsideAList_KeepsItsValuesWrapper() + { + JsonObject profile = OldProfileJson.Parse(""" + {"Rows": {"$type": "List", "$values": [{"$type": "List", "$values": [1, 2]}]}} + """); + + _migration.Migrate(new JsonObject(), profile); + + // Pinned as-is: items of a list are only searched for lists in their properties, a list that is itself an item + // keeps its "$values" wrapper. No profile entity has a list of lists, so old profiles aren't affected. + profile.ShouldBeJson("""{"Rows": [{"$values": [1, 2]}]}"""); + } + + [Fact] + public void Migrate_LeavesObjectsInPlainArraysAlone() + { + // JSON.NET wrapped every list when it wrote type names, so old profiles have no plain arrays + const string json = """{"Elements": [{"$type": "Element", "Children": {"$type": "List", "$values": []}}]}"""; + JsonObject profile = OldProfileJson.Parse(json); + + _migration.Migrate(new JsonObject(), profile); + + profile.ShouldBeJson(json); + } + + [Fact] + public void Migrate_ValuesThatAreNull_AreTreatedAsAnObject() + { + JsonObject profile = OldProfileJson.Parse("""{"Elements": {"$type": "List", "$values": null}}"""); + + _migration.Migrate(new JsonObject(), profile); + + profile.ShouldBeJson("""{"Elements": {"$values": null}}"""); + } + + [Fact] + public void Migrate_ValuesThatAreNotAList_Throws() + { + JsonObject profile = OldProfileJson.Parse("""{"Elements": {"$type": "List", "$values": 5}}"""); + + Should.Throw(() => _migration.Migrate(new JsonObject(), profile)); + } + + [Fact] + public void Migrate_RunningItOnAMigratedProfile_ChangesNothing() + { + JsonObject configuration = OldProfileJson.Parse(OldProfileJson.MigratedConfiguration); + JsonObject profile = OldProfileJson.Parse(OldProfileJson.MigratedProfile); + + _migration.Migrate(configuration, profile); + + // The short type names would be removed (see Migrate_RemovesAnyOtherTypeName), but in a migrated profile they + // are all inside plain arrays, which this migration doesn't look into + configuration.ShouldBeJson(OldProfileJson.MigratedConfiguration); + profile.ShouldBeJson(OldProfileJson.MigratedProfile); + profile.FindTypeNames().ShouldBe(["Static", "Category", "KeyboardSection", "Event"]); + } + + private (JsonObject Configuration, JsonObject Profile) Version2Profile() + { + JsonObject configuration = OldProfileJson.Parse(OldProfileJson.Version0Configuration); + JsonObject profile = OldProfileJson.Parse(OldProfileJson.Version0Profile); + new M0001NodeProviders().Migrate(configuration, profile); + new M0002NodeProvidersProfileConfig().Migrate(configuration, profile); + return (configuration, profile); + } + + private static void CopyNodeStorage(string fromJson, JsonNode to) + { + JsonNode from = JsonNode.Parse(fromJson)!; + foreach (JsonObject node in to.FindNodes()) + node["Storage"] = from.FindNode(node["Id"]!.GetValue())["Storage"]!.DeepClone(); + } +} diff --git a/src/Artemis.Core.Tests/Storage/Migrations/M0004NodeStorageTests.cs b/src/Artemis.Core.Tests/Storage/Migrations/M0004NodeStorageTests.cs new file mode 100644 index 000000000..1e5fe3d9c --- /dev/null +++ b/src/Artemis.Core.Tests/Storage/Migrations/M0004NodeStorageTests.cs @@ -0,0 +1,174 @@ +using System.Text.Json.Nodes; +using Artemis.Core.Tests.Infrastructure.Storage; +using Artemis.Storage.Migrations.Profile; + +namespace Artemis.Core.Tests.Storage.Migrations; + +public class M0004NodeStorageTests +{ + private readonly RecordingLogger _log = new(); + private readonly M0004NodeStorage _migration; + + public M0004NodeStorageTests() + { + _migration = new M0004NodeStorage(_log.Logger); + } + + [Fact] + public void Version_IsFour() + { + _migration.Version.ShouldBe(4); + } + + [Fact] + public void Migrate_ConvertsTheStorageOfEveryNode() + { + (JsonObject configuration, JsonObject profile) = Version3Profile(); + + _migration.Migrate(configuration, profile); + + // Only MigrateProfile sets the version + configuration["Version"] = 4; + configuration.ShouldBeJson(OldProfileJson.MigratedConfiguration); + profile.ShouldBeJson(OldProfileJson.MigratedProfile); + _log.Errors.ShouldBeEmpty(); + } + + [Theory] + [InlineData(OldProfileJson.FolderConditionNodeId, """{"Path":"Player.IsAlive","DataModelId":"Artemis.Plugins.Modules.Game-12345678"}""")] + [InlineData(OldProfileJson.LayerBrushNodeId, """[{"Color":"#ffff0000","Position":0.0},{"Color":"#ff0000ff","Position":1.0}]""")] + [InlineData(OldProfileJson.FolderEffectNodeId, "\"1 + 1\"")] + [InlineData(OldProfileJson.LayerConditionNodeId, "true")] + [InlineData(OldProfileJson.LayerTransformNodeId, "50.0")] + public void Migrate_ConvertsStorageEverywhereInTheProfile(string nodeId, string expectedStorage) + { + (JsonObject configuration, JsonObject profile) = Version3Profile(); + + _migration.Migrate(configuration, profile); + + profile.FindNode(nodeId)["Storage"]!.GetValue().ShouldBe(expectedStorage); + } + + [Fact] + public void Migrate_ConvertsTheStorageOfActivationConditionNodes() + { + (JsonObject configuration, JsonObject profile) = Version3Profile(); + + _migration.Migrate(configuration, profile); + + configuration.FindNode(OldProfileJson.ActivationDataModelNodeId)["Storage"]!.GetValue() + .ShouldBe("""{"Path":"IsInGame","DataModelId":"Artemis.Plugins.Modules.Game-12345678"}"""); + configuration.FindNode(OldProfileJson.ActivationExitNodeId)["Storage"]!.GetValue().ShouldBe(""); + } + + [Fact] + public void Migrate_RunningItTwice_GivesTheSameResult() + { + (JsonObject configuration, JsonObject profile) = Version3Profile(); + + _migration.Migrate(configuration, profile); + _migration.Migrate(configuration, profile); + + profile.ShouldBeJson(OldProfileJson.MigratedProfile); + } + + [Theory] + // A plain object loses its type name, also in its children + [InlineData("""{"$type":"Artemis.Storage.Entities.Profile.DataModelPathEntity, Artemis.Storage","Path":"Value"}""", """{"Path":"Value"}""")] + [InlineData("""{"$type":"A","Child":{"$type":"B","Items":{"$type":"List","$values":[{"$type":"C","X":1}]}}}""", """{"Child":{"Items":[{"X":1}]}}""")] + // A list becomes its values + [InlineData("""{"$type":"List","$values":[{"$type":"Item","X":1},2]}""", """[{"X":1},2]""")] + [InlineData("""{"$type":"List","$values":[]}""", "[]")] + // Anything that isn't an object stays as it is + [InlineData("5", "5")] + [InlineData("\"text\"", "\"text\"")] + [InlineData("""[{"$type":"Item"}]""", """[{"$type":"Item"}]""")] + [InlineData("", "")] + [InlineData(null, null)] + // Already converted storage + [InlineData("""{"Path":"Value"}""", """{"Path":"Value"}""")] + public void MigrateNodeStorageJson_ConvertsOldStorage(string? storage, string? expected) + { + M0004NodeStorage.MigrateNodeStorageJson(storage, _log.Logger).ShouldBe(expected); + _log.Errors.ShouldBeEmpty(); + } + + [Fact] + public void MigrateNodeStorageJson_UsesTheShortTypeNamesOfConditionsAndAdaptionHints() + { + const string storage = """{"$type":"Artemis.Storage.Entities.Profile.Conditions.StaticConditionEntity, Artemis.Storage","PlayMode":1}"""; + + M0004NodeStorage.MigrateNodeStorageJson(storage, _log.Logger).ShouldBe("""{"$type":"Static","PlayMode":1}"""); + } + + [Fact] + public void MigrateNodeStorageJson_WritesTextEscaped() + { + // The storage is written again with the default encoder, which escapes characters like + and quotes. Reading it + // back gives the same values. + const string storage = """{"$type":"Expression","Text":"1 + 'a'"}"""; + + M0004NodeStorage.MigrateNodeStorageJson(storage, _log.Logger).ShouldBe("{\"Text\":\"1 \\u002B \\u0027a\\u0027\"}"); + } + + [Theory] + [InlineData("{not json")] + [InlineData("""{"$type":"List","$values":5}""")] + public void MigrateNodeStorageJson_BrokenStorage_IsLeftAloneAndLogged(string storage) + { + M0004NodeStorage.MigrateNodeStorageJson(storage, _log.Logger).ShouldBe(storage); + + _log.Errors.ShouldHaveSingleItem().MessageTemplate.Text.ShouldBe("Failed to migrate node storage JSON"); + } + + [Fact] + public void Migrate_NodeWithoutStorage_GetsANullStorage() + { + (JsonObject configuration, JsonObject profile) = Version3Profile(); + JsonObject node = profile.FindNode(OldProfileJson.LayerTransformNodeId); + node.Remove("Storage"); + + _migration.Migrate(configuration, profile); + + // Pinned as-is: a missing storage is written back as "Storage": null rather than left out, which gives the + // NodeEntity a null storage even though the property isn't nullable + node.ContainsKey("Storage").ShouldBeTrue(); + node["Storage"].ShouldBeNull(); + } + + [Fact] + public void Migrate_ProfileWithoutNodes_DoesNothing() + { + const string configurationJson = """{"Name": "Empty", "ActivationCondition": null}"""; + const string profileJson = """ + { + "Folders": [null, {"Name": "Folder", "LayerEffects": [{"PropertyGroup": null}]}], + "Layers": [null, {"Name": "Layer", "DisplayCondition": {"$type": "AlwaysOn"}, "GeneralPropertyGroup": {"Properties": [{"DataBinding": null}]}}] + } + """; + JsonObject configuration = OldProfileJson.Parse(configurationJson); + JsonObject profile = OldProfileJson.Parse(profileJson); + + _migration.Migrate(configuration, profile); + + configuration.ShouldBeJson(configurationJson); + profile.ShouldBeJson(profileJson); + } + + [Fact] + public void Migrate_ProfileStillInTheOldFormat_Throws() + { + // This migration expects M0003 to have turned the lists into arrays + Should.Throw(() => _migration.Migrate(new JsonObject(), OldProfileJson.Parse(OldProfileJson.Version0Profile))); + } + + private static (JsonObject Configuration, JsonObject Profile) Version3Profile() + { + JsonObject configuration = OldProfileJson.Parse(OldProfileJson.Version0Configuration); + JsonObject profile = OldProfileJson.Parse(OldProfileJson.Version0Profile); + new M0001NodeProviders().Migrate(configuration, profile); + new M0002NodeProvidersProfileConfig().Migrate(configuration, profile); + new M0003SystemTextJson().Migrate(configuration, profile); + return (configuration, profile); + } +} diff --git a/src/Artemis.Core.Tests/Storage/ProfileMigrationTests.cs b/src/Artemis.Core.Tests/Storage/ProfileMigrationTests.cs new file mode 100644 index 000000000..04cca65ec --- /dev/null +++ b/src/Artemis.Core.Tests/Storage/ProfileMigrationTests.cs @@ -0,0 +1,231 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json.Nodes; +using Artemis.Core.Services; +using Artemis.Core.Tests.Infrastructure.Services; +using Artemis.Core.Tests.Infrastructure.Storage; +using Artemis.Storage.Entities.Profile; +using Artemis.Storage.Entities.Profile.AdaptionHints; +using Artemis.Storage.Entities.Profile.Conditions; +using Artemis.Storage.Migrations; +using Artemis.Storage.Repositories; +using Artemis.Storage.Repositories.Interfaces; +using DryIoc; + +namespace Artemis.Core.Tests.Storage; + +/// +/// Tests how applies the profile migrations, with stand-in migrations +/// and with the real ones registered by Artemis.Core +/// +public class ProfileMigrationTests : IDisposable +{ + private readonly RecordingLogger _log = new(); + private readonly ServiceHarness _services = new(); + private readonly List _calls = []; + + [Fact] + public void MigrateProfile_RunsMigrationsInVersionOrder() + { + ProfileRepository repository = CreateRepository(3, 1, 2); + JsonObject configuration = new() {["Version"] = 0}; + + repository.MigrateProfile(configuration, new JsonObject()); + + _calls.ShouldBe(["1 at version 0", "2 at version 1", "3 at version 2"]); + configuration["Version"]!.GetValue().ShouldBe(3); + } + + [Theory] + [InlineData(1, new[] {"2 at version 1", "3 at version 2"})] + [InlineData(2, new[] {"3 at version 2"})] + [InlineData(3, new string[0])] + [InlineData(10, new string[0])] + public void MigrateProfile_SkipsMigrationsTheProfileAlreadyHad(int version, string[] expectedCalls) + { + ProfileRepository repository = CreateRepository(1, 2, 3); + JsonObject configuration = new() {["Version"] = version}; + + repository.MigrateProfile(configuration, new JsonObject()); + + _calls.ShouldBe(expectedCalls); + configuration["Version"]!.GetValue().ShouldBe(Math.Max(version, 3)); + } + + [Fact] + public void MigrateProfile_ConfigurationWithoutVersion_StartsAtVersionZero() + { + ProfileRepository repository = CreateRepository(1, 2); + JsonObject configuration = new() {["Name"] = "No version"}; + + repository.MigrateProfile(configuration, new JsonObject()); + + _calls.ShouldBe(["1 at version 0", "2 at version 1"]); + configuration["Version"]!.GetValue().ShouldBe(2); + } + + [Fact] + public void MigrateProfile_MissingConfigurationOrProfile_DoesNothing() + { + ProfileRepository repository = CreateRepository(1); + JsonObject configuration = new() {["Name"] = "No profile"}; + + repository.MigrateProfile(configuration, null); + repository.MigrateProfile(null, new JsonObject()); + + _calls.ShouldBeEmpty(); + configuration.ShouldBeJson("""{"Name": "No profile"}"""); + } + + [Fact] + public void MigrateProfile_MigrationThrows_StopsWithTheVersionOfTheLastMigrationThatWorked() + { + ProfileRepository repository = new(_log.Logger, () => throw new InvalidOperationException("No database needed"), + [new RecordingMigration(1, _calls), new FailingMigration(2), new RecordingMigration(3, _calls)]); + JsonObject configuration = new() {["Version"] = 0}; + + Should.Throw(() => repository.MigrateProfile(configuration, new JsonObject())); + + _calls.ShouldBe(["1 at version 0"]); + configuration["Version"]!.GetValue().ShouldBe(1); + } + + [Fact] + public void Artemis_RegistersAllFourMigrations() + { + List migrations = _services.Container.Resolve>().ToList(); + + migrations.Select(m => m.Version).Order().ShouldBe([1, 2, 3, 4]); + } + + [Fact] + public void MigrateProfile_Version0Profile_EndsUpInTheCurrentFormat() + { + JsonObject configuration = OldProfileJson.Parse(OldProfileJson.Version0Configuration); + JsonObject profile = OldProfileJson.Parse(OldProfileJson.Version0Profile); + + _services.Resolve().MigrateProfile(configuration, profile); + + configuration.ShouldBeJson(OldProfileJson.MigratedConfiguration); + profile.ShouldBeJson(OldProfileJson.MigratedProfile); + } + + [Fact] + public void MigrateProfile_Version2Profile_OnlyGetsTheLaterMigrations() + { + // The JSON is still in the old format but claims the nodes were already moved to the general nodes plugin + JsonObject configuration = OldProfileJson.Parse(OldProfileJson.Version0Configuration); + configuration["Version"] = 2; + JsonObject profile = OldProfileJson.Parse(OldProfileJson.Version0Profile); + + _services.Resolve().MigrateProfile(configuration, profile); + + configuration["Version"]!.GetValue().ShouldBe(4); + profile["Folders"].ShouldBeOfType(); + JsonObject node = profile.FindNode(OldProfileJson.LayerBrushNodeId); + node["Type"]!.GetValue().ShouldBe("Artemis.VisualScripting.Nodes.Color.ColorGradientNode"); + node.ContainsKey("ProviderId").ShouldBeFalse(); + node["Storage"]!.GetValue().ShouldBe("""[{"Color":"#ffff0000","Position":0.0},{"Color":"#ff0000ff","Position":1.0}]"""); + } + + [Fact] + public void MigrateProfile_CurrentProfile_IsLeftAlone() + { + JsonObject configuration = OldProfileJson.Parse(OldProfileJson.MigratedConfiguration); + JsonObject profile = OldProfileJson.Parse(OldProfileJson.MigratedProfile); + + _services.Resolve().MigrateProfile(configuration, profile); + + configuration.ShouldBeJson(OldProfileJson.MigratedConfiguration); + profile.ShouldBeJson(OldProfileJson.MigratedProfile); + } + + [Fact] + public void MigrateProfile_CurrentProfileWithoutVersion_Throws() + { + JsonObject configuration = OldProfileJson.Parse(OldProfileJson.MigratedConfiguration); + configuration.Remove("Version"); + JsonObject profile = OldProfileJson.Parse(OldProfileJson.MigratedProfile); + + // Pinned as-is: without a version the profile is taken to be from before version 1 and M0001 fails on the + // current format, so a profile in the current format without a version can't be imported + Should.Throw(() => _services.Resolve().MigrateProfile(configuration, profile)); + } + + [Fact] + public async Task ImportProfile_MigratesAnOldExport() + { + IProfileService profileService = _services.Resolve(); + ProfileCategory category = profileService.CreateProfileCategory("Imported"); + using MemoryStream archive = CreateExport(OldProfileJson.Version0Configuration, OldProfileJson.Version0Profile); + + ProfileConfiguration imported = await profileService.ImportProfile(archive, category, true, true, null); + + imported.Name.ShouldBe("Old profile"); + using var context = _services.CreateDbContext(); + ProfileContainerEntity stored = context.ProfileContainers.Single(); + stored.ProfileConfiguration.Version.ShouldBe(4); + stored.ProfileConfiguration.ProfileId.ShouldBe(stored.Profile.Id); + stored.Profile.Id.ShouldNotBe(Guid.Parse(OldProfileJson.ProfileId)); + stored.Profile.IsFreshImport.ShouldBeTrue(); + + FolderEntity folder = stored.Profile.Folders.ShouldHaveSingleItem(); + folder.ParentId.ShouldBe(stored.Profile.Id); + StaticConditionEntity folderCondition = folder.DisplayCondition.ShouldBeOfType(); + folderCondition.Script!.Nodes.ShouldHaveSingleItem().ProviderId.ShouldBe(OldProfileJson.GeneralNodesProviderId); + + LayerEntity layer = stored.Profile.Layers.ShouldHaveSingleItem(); + layer.DisplayCondition.ShouldBeOfType().EventPath!.Path.ShouldBe("Player.Died"); + layer.AdaptionHints.Select(h => h.GetType()).ShouldBe([typeof(CategoryAdaptionHintEntity), typeof(KeyboardSectionAdaptionHintEntity)]); + layer.LayerBrush!.PropertyGroup!.PropertyGroups.ShouldHaveSingleItem().Properties.ShouldHaveSingleItem() + .DataBinding!.NodeScript!.Nodes.ShouldHaveSingleItem().Storage + .ShouldBe("""[{"Color":"#ffff0000","Position":0.0},{"Color":"#ff0000ff","Position":1.0}]"""); + } + + private ProfileRepository CreateRepository(params int[] versions) + { + // MigrateProfile doesn't use the database + return new ProfileRepository(_log.Logger, () => throw new InvalidOperationException("No database needed"), + versions.Select(v => (IProfileMigration) new RecordingMigration(v, _calls)).ToList()); + } + + private static MemoryStream CreateExport(string configurationJson, string profileJson) + { + MemoryStream stream = new(); + using (ZipArchive archive = new(stream, ZipArchiveMode.Create, true)) + { + using (StreamWriter writer = new(archive.CreateEntry("configuration.json").Open(), Encoding.UTF8)) + writer.Write(configurationJson); + using (StreamWriter writer = new(archive.CreateEntry("profile.json").Open(), Encoding.UTF8)) + writer.Write(profileJson); + } + + stream.Seek(0, SeekOrigin.Begin); + return stream; + } + + public void Dispose() + { + _services.Dispose(); + } + + private sealed class RecordingMigration(int version, List calls) : IProfileMigration + { + public int Version { get; } = version; + + public void Migrate(JsonObject configurationJson, JsonObject profileJson) + { + calls.Add($"{Version} at version {configurationJson["Version"]}"); + } + } + + private sealed class FailingMigration(int version) : IProfileMigration + { + public int Version { get; } = version; + + public void Migrate(JsonObject configurationJson, JsonObject profileJson) + { + throw new InvalidOperationException("Migration failed"); + } + } +} diff --git a/src/Artemis.Core.Tests/Storage/Repositories/DeviceRepositoryTests.cs b/src/Artemis.Core.Tests/Storage/Repositories/DeviceRepositoryTests.cs new file mode 100644 index 000000000..421a10a22 --- /dev/null +++ b/src/Artemis.Core.Tests/Storage/Repositories/DeviceRepositoryTests.cs @@ -0,0 +1,142 @@ +using Artemis.Core.Tests.Infrastructure.Services; +using Artemis.Storage.Entities.Surface; +using Artemis.Storage.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace Artemis.Core.Tests.Storage.Repositories; + +public class DeviceRepositoryTests : IDisposable +{ + private readonly ServiceHarness _services = new(); + private readonly IDeviceRepository _repository; + + public DeviceRepositoryTests() + { + _repository = _services.Resolve(); + } + + [Fact] + public void Add_ThenGet_ReturnsEverythingThatWasStored() + { + DeviceEntity device = CreateDevice("Keyboard"); + device.X = 10.5f; + device.Y = 20; + device.Rotation = 90; + device.Scale = 2; + device.ZIndex = 3; + device.RedScale = 0.5f; + device.GreenScale = 0.75f; + device.BlueScale = 1; + device.IsEnabled = true; + device.PhysicalLayout = 1; + device.LogicalLayout = "NA"; + device.LayoutType = "Default"; + device.LayoutParameter = "layout.xml"; + device.InputIdentifiers.Add(new DeviceInputIdentifierEntity {InputProvider = "Windows", Identifier = "HID#1234"}); + device.InputMappings.Add(new InputMappingEntity {OriginalLedId = 1, MappedLedId = 2}); + device.Categories.AddRange([1, 4]); + + _repository.Add(device); + + DeviceEntity stored = _repository.Get("Keyboard").ShouldNotBeNull(); + stored.ShouldNotBeSameAs(device); + stored.DeviceProvider.ShouldBe("Artemis.Plugins.Devices.Test"); + (stored.X, stored.Y, stored.Rotation, stored.Scale, stored.ZIndex).ShouldBe((10.5f, 20f, 90f, 2f, 3)); + (stored.RedScale, stored.GreenScale, stored.BlueScale, stored.IsEnabled).ShouldBe((0.5f, 0.75f, 1f, true)); + (stored.PhysicalLayout, stored.LogicalLayout, stored.LayoutType, stored.LayoutParameter).ShouldBe((1, "NA", "Default", "layout.xml")); + stored.InputIdentifiers.ShouldHaveSingleItem().Identifier.ShouldBe("HID#1234"); + stored.InputIdentifiers[0].InputProvider.ShouldBe("Windows"); + stored.InputMappings.ShouldHaveSingleItem().MappedLedId.ShouldBe(2); + stored.Categories.ShouldBe([1, 4]); + } + + [Fact] + public void Get_UnknownDevice_ReturnsNull() + { + _repository.Add(CreateDevice("Keyboard")); + + _repository.Get("Mouse").ShouldBeNull(); + } + + [Fact] + public void GetAll_ReturnsAllDevices() + { + _repository.Add(CreateDevice("Keyboard")); + _repository.Add(CreateDevice("Mouse")); + + _repository.GetAll().Select(d => d.Id).Order().ShouldBe(["Keyboard", "Mouse"]); + } + + [Fact] + public void Save_StoredDevice_StoresTheChanges() + { + DeviceEntity device = CreateDevice("Keyboard"); + _repository.Add(device); + + device.X = 100; + device.InputMappings.Add(new InputMappingEntity {OriginalLedId = 5, MappedLedId = 6}); + _repository.Save(device); + + DeviceEntity stored = _repository.Get("Keyboard")!; + stored.X.ShouldBe(100); + stored.InputMappings.ShouldHaveSingleItem().OriginalLedId.ShouldBe(5); + } + + [Fact] + public void Save_DeviceThatWasNeverAdded_Throws() + { + // The ID of a device isn't generated, so saving takes it to be an existing device and finds nothing to update. + // The device service adds new devices before it saves them. + Should.Throw(() => _repository.Save(CreateDevice("Keyboard"))); + } + + [Fact] + public void SaveRange_StoresAllDevices() + { + DeviceEntity keyboard = CreateDevice("Keyboard"); + DeviceEntity mouse = CreateDevice("Mouse"); + _repository.Add(keyboard); + _repository.Add(mouse); + + keyboard.ZIndex = 2; + mouse.ZIndex = 1; + _repository.SaveRange([keyboard, mouse]); + + _repository.GetAll().OrderBy(d => d.ZIndex).Select(d => d.Id).ShouldBe(["Mouse", "Keyboard"]); + } + + [Fact] + public void Remove_DeletesTheDevice() + { + DeviceEntity device = CreateDevice("Keyboard"); + _repository.Add(device); + _repository.Add(CreateDevice("Mouse")); + + _repository.Remove(device); + + _repository.GetAll().ShouldHaveSingleItem().Id.ShouldBe("Mouse"); + } + + [Fact] + public void Get_DeviceWithNullInputLists_ReturnsEmptyLists() + { + _repository.Add(CreateDevice("Keyboard")); + using (var context = _services.CreateDbContext()) + context.Database.ExecuteSqlRaw("UPDATE Devices SET InputIdentifiers = 'null', InputMappings = 'null'"); + + DeviceEntity stored = _repository.Get("Keyboard")!; + + stored.InputIdentifiers.ShouldBeEmpty(); + stored.InputMappings.ShouldBeEmpty(); + } + + private static DeviceEntity CreateDevice(string id) + { + return new DeviceEntity {Id = id, DeviceProvider = "Artemis.Plugins.Devices.Test", Scale = 1}; + } + + public void Dispose() + { + _services.Dispose(); + } +} diff --git a/src/Artemis.Core.Tests/Storage/Repositories/EntryRepositoryTests.cs b/src/Artemis.Core.Tests/Storage/Repositories/EntryRepositoryTests.cs new file mode 100644 index 000000000..eabea7e23 --- /dev/null +++ b/src/Artemis.Core.Tests/Storage/Repositories/EntryRepositoryTests.cs @@ -0,0 +1,149 @@ +using System.Text.Json.Nodes; +using Artemis.Core.Tests.Infrastructure.Services; +using Artemis.Storage.Entities.Workshop; +using Artemis.Storage.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace Artemis.Core.Tests.Storage.Repositories; + +public class EntryRepositoryTests : IDisposable +{ + private readonly ServiceHarness _services = new(); + private readonly IEntryRepository _repository; + + public EntryRepositoryTests() + { + _repository = _services.Resolve(); + } + + [Fact] + public void Add_ThenGet_ReturnsEverythingThatWasStored() + { + DateTimeOffset createdAt = new(2024, 5, 1, 12, 0, 0, TimeSpan.FromHours(2)); + DateTimeOffset installedAt = new(2024, 6, 1, 8, 30, 0, TimeSpan.Zero); + EntryEntity entry = new() + { + EntryId = 42, + EntryType = 2, + Author = "Someone", + IsOfficial = true, + Name = "Rainbow profile", + Summary = "All the colors", + Downloads = 1000, + CreatedAt = createdAt, + LatestReleaseId = 7, + ReleaseId = 6, + ReleaseVersion = "1.2.0", + InstalledAt = installedAt, + AutoUpdate = true, + Metadata = new Dictionary {["ProfileId"] = JsonValue.Create("abc"), ["Settings"] = new JsonObject {["Speed"] = 5}}, + Categories = [new EntryCategoryEntity("Games", "Gamepad")] + }; + + _repository.Add(entry); + + EntryEntity stored = _repository.Get(entry.Id).ShouldNotBeNull(); + stored.ShouldNotBeSameAs(entry); + (stored.EntryId, stored.EntryType, stored.Author, stored.IsOfficial).ShouldBe((42L, 2, "Someone", true)); + (stored.Name, stored.Summary, stored.Downloads).ShouldBe(("Rainbow profile", "All the colors", 1000L)); + (stored.CreatedAt, stored.InstalledAt).ShouldBe((createdAt, installedAt)); + (stored.LatestReleaseId, stored.ReleaseId, stored.ReleaseVersion, stored.AutoUpdate).ShouldBe((7L, 6L, "1.2.0", true)); + stored.Metadata!["ProfileId"].GetValue().ShouldBe("abc"); + stored.Metadata["Settings"]["Speed"]!.GetValue().ShouldBe(5); + stored.Categories.ShouldBe([new EntryCategoryEntity("Games", "Gamepad")]); + } + + [Fact] + public void Add_EntryWithoutMetadataOrCategories_ReadsBackWithout() + { + EntryEntity entry = new() {EntryId = 1, Metadata = null, Categories = null}; + + _repository.Add(entry); + + // Null skips the JSON conversion and is stored as an empty column + EntryEntity stored = _repository.Get(entry.Id)!; + stored.Metadata.ShouldBeNull(); + stored.Categories.ShouldBeNull(); + } + + [Fact] + public void Get_EntryWithNullJson_ReturnsEmptyMetadataAndCategories() + { + EntryEntity entry = new() {EntryId = 1}; + _repository.Add(entry); + using (var context = _services.CreateDbContext()) + context.Database.ExecuteSqlRaw("UPDATE Entries SET Metadata = 'null', Categories = 'null'"); + + EntryEntity stored = _repository.Get(entry.Id)!; + + stored.Metadata.ShouldNotBeNull().ShouldBeEmpty(); + stored.Categories.ShouldNotBeNull().ShouldBeEmpty(); + } + + [Fact] + public void GetByEntryId_FindsTheEntryByItsWorkshopId() + { + _repository.Add(new EntryEntity {EntryId = 1, Name = "First"}); + _repository.Add(new EntryEntity {EntryId = 2, Name = "Second"}); + + _repository.GetByEntryId(2).ShouldNotBeNull().Name.ShouldBe("Second"); + _repository.GetByEntryId(3).ShouldBeNull(); + } + + [Fact] + public void Get_UnknownEntry_ReturnsNull() + { + _repository.Add(new EntryEntity {EntryId = 1}); + + _repository.Get(Guid.NewGuid()).ShouldBeNull(); + } + + [Fact] + public void Add_SecondEntryWithTheSameWorkshopId_Throws() + { + _repository.Add(new EntryEntity {EntryId = 1}); + + Should.Throw(() => _repository.Add(new EntryEntity {EntryId = 1})); + } + + [Fact] + public void GetAll_ReturnsAllEntries() + { + _repository.Add(new EntryEntity {EntryId = 1}); + _repository.Add(new EntryEntity {EntryId = 2}); + + _repository.GetAll().Select(e => e.EntryId).Order().ShouldBe([1L, 2L]); + } + + [Fact] + public void Save_StoresTheChanges() + { + EntryEntity entry = new() {EntryId = 1, ReleaseVersion = "1.0.0"}; + _repository.Add(entry); + + entry.ReleaseVersion = "1.1.0"; + entry.Metadata = new Dictionary {["Key"] = JsonValue.Create(true)}; + _repository.Save(entry); + + EntryEntity stored = _repository.Get(entry.Id)!; + stored.ReleaseVersion.ShouldBe("1.1.0"); + stored.Metadata!["Key"].GetValue().ShouldBeTrue(); + } + + [Fact] + public void Remove_DeletesTheEntry() + { + EntryEntity entry = new() {EntryId = 1}; + _repository.Add(entry); + _repository.Add(new EntryEntity {EntryId = 2}); + + _repository.Remove(entry); + + _repository.GetAll().ShouldHaveSingleItem().EntryId.ShouldBe(2); + } + + public void Dispose() + { + _services.Dispose(); + } +} diff --git a/src/Artemis.Core.Tests/Storage/Repositories/PluginRepositoryTests.cs b/src/Artemis.Core.Tests/Storage/Repositories/PluginRepositoryTests.cs new file mode 100644 index 000000000..cd81d8640 --- /dev/null +++ b/src/Artemis.Core.Tests/Storage/Repositories/PluginRepositoryTests.cs @@ -0,0 +1,154 @@ +using Artemis.Core.Tests.Infrastructure.Services; +using Artemis.Storage.Entities.Plugins; +using Artemis.Storage.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace Artemis.Core.Tests.Storage.Repositories; + +public class PluginRepositoryTests : IDisposable +{ + private readonly ServiceHarness _services = new(); + private readonly IPluginRepository _repository; + + public PluginRepositoryTests() + { + _repository = _services.Resolve(); + } + + [Fact] + public void SavePlugin_NewPlugin_IsStoredWithItsFeatures() + { + Guid pluginGuid = Guid.NewGuid(); + PluginEntity plugin = new() {PluginGuid = pluginGuid, IsEnabled = true}; + plugin.Features.Add(new PluginFeatureEntity {Type = "Artemis.Plugins.Test.Brush", IsEnabled = true}); + plugin.Features.Add(new PluginFeatureEntity {Type = "Artemis.Plugins.Test.Module", IsEnabled = false}); + + _repository.SavePlugin(plugin); + + PluginEntity stored = _repository.GetPluginByPluginGuid(pluginGuid).ShouldNotBeNull(); + stored.Id.ShouldNotBe(Guid.Empty); + stored.IsEnabled.ShouldBeTrue(); + stored.Features.Select(f => (f.Type, f.IsEnabled)).Order() + .ShouldBe([("Artemis.Plugins.Test.Brush", true), ("Artemis.Plugins.Test.Module", false)]); + } + + [Fact] + public void GetPluginByPluginGuid_UnknownPlugin_ReturnsNull() + { + _repository.SavePlugin(new PluginEntity {PluginGuid = Guid.NewGuid()}); + + _repository.GetPluginByPluginGuid(Guid.NewGuid()).ShouldBeNull(); + } + + [Fact] + public void SavePlugin_StoredPlugin_StoresChangesAndNewFeatures() + { + Guid pluginGuid = Guid.NewGuid(); + PluginEntity plugin = new() {PluginGuid = pluginGuid, IsEnabled = true}; + plugin.Features.Add(new PluginFeatureEntity {Type = "Brush", IsEnabled = true}); + _repository.SavePlugin(plugin); + + plugin.IsEnabled = false; + plugin.Features[0].IsEnabled = false; + plugin.Features.Add(new PluginFeatureEntity {Type = "Effect", IsEnabled = true}); + _repository.SavePlugin(plugin); + + PluginEntity stored = _repository.GetPluginByPluginGuid(pluginGuid)!; + stored.IsEnabled.ShouldBeFalse(); + stored.Features.Select(f => (f.Type, f.IsEnabled)).Order().ShouldBe([("Brush", false), ("Effect", true)]); + } + + [Fact] + public void SavePlugin_FeatureRemovedFromTheList_StaysStored() + { + Guid pluginGuid = Guid.NewGuid(); + PluginEntity plugin = new() {PluginGuid = pluginGuid}; + plugin.Features.Add(new PluginFeatureEntity {Type = "Brush"}); + plugin.Features.Add(new PluginFeatureEntity {Type = "Effect"}); + _repository.SavePlugin(plugin); + + // Saving only adds and updates, features that are no longer in the list aren't removed + plugin.Features.RemoveAt(0); + _repository.SavePlugin(plugin); + + _repository.GetPluginByPluginGuid(pluginGuid)!.Features.Select(f => f.Type).Order().ShouldBe(["Brush", "Effect"]); + } + + [Fact] + public void SavePlugin_SecondPluginWithTheSameGuid_Throws() + { + Guid pluginGuid = Guid.NewGuid(); + _repository.SavePlugin(new PluginEntity {PluginGuid = pluginGuid}); + + Should.Throw(() => _repository.SavePlugin(new PluginEntity {PluginGuid = pluginGuid})); + } + + [Fact] + public void SaveSetting_NewSetting_CanBeFoundByNameAndPlugin() + { + Guid pluginGuid = Guid.NewGuid(); + + _repository.SaveSetting(new PluginSettingEntity {PluginGuid = pluginGuid, Name = "Brightness", Value = "50"}); + + _repository.GetSettingByNameAndGuid("Brightness", pluginGuid).ShouldNotBeNull().Value.ShouldBe("50"); + _repository.GetSettingByNameAndGuid("Brightness", Guid.NewGuid()).ShouldBeNull(); + _repository.GetSettingByNameAndGuid("Speed", pluginGuid).ShouldBeNull(); + } + + [Fact] + public void SaveSetting_StoredSetting_StoresTheNewValue() + { + Guid pluginGuid = Guid.NewGuid(); + PluginSettingEntity setting = new() {PluginGuid = pluginGuid, Name = "Brightness", Value = "50"}; + _repository.SaveSetting(setting); + + setting.Value = "75"; + _repository.SaveSetting(setting); + + _repository.GetSettingByNameAndGuid("Brightness", pluginGuid)!.Value.ShouldBe("75"); + using var context = _services.CreateDbContext(); + context.PluginSettings.Count().ShouldBe(1); + } + + [Fact] + public void SaveSetting_SecondSettingWithTheSameNameAndPlugin_Throws() + { + Guid pluginGuid = Guid.NewGuid(); + _repository.SaveSetting(new PluginSettingEntity {PluginGuid = pluginGuid, Name = "Brightness", Value = "50"}); + + Should.Throw(() => _repository.SaveSetting(new PluginSettingEntity {PluginGuid = pluginGuid, Name = "Brightness", Value = "75"})); + } + + [Fact] + public void SaveSetting_SameNameForAnotherPlugin_IsAllowed() + { + Guid first = Guid.NewGuid(); + Guid second = Guid.NewGuid(); + + _repository.SaveSetting(new PluginSettingEntity {PluginGuid = first, Name = "Brightness", Value = "1"}); + _repository.SaveSetting(new PluginSettingEntity {PluginGuid = second, Name = "Brightness", Value = "2"}); + + _repository.GetSettingByNameAndGuid("Brightness", first)!.Value.ShouldBe("1"); + _repository.GetSettingByNameAndGuid("Brightness", second)!.Value.ShouldBe("2"); + } + + [Fact] + public void RemoveSettings_RemovesOnlyTheSettingsOfThatPlugin() + { + Guid removed = Guid.NewGuid(); + Guid kept = Guid.NewGuid(); + _repository.SaveSetting(new PluginSettingEntity {PluginGuid = removed, Name = "Brightness"}); + _repository.SaveSetting(new PluginSettingEntity {PluginGuid = removed, Name = "Speed"}); + _repository.SaveSetting(new PluginSettingEntity {PluginGuid = kept, Name = "Brightness"}); + + _repository.RemoveSettings(removed); + + using var context = _services.CreateDbContext(); + context.PluginSettings.ShouldHaveSingleItem().PluginGuid.ShouldBe(kept); + } + + public void Dispose() + { + _services.Dispose(); + } +} diff --git a/src/Artemis.Core.Tests/Storage/Repositories/ProfileCategoryRepositoryTests.cs b/src/Artemis.Core.Tests/Storage/Repositories/ProfileCategoryRepositoryTests.cs new file mode 100644 index 000000000..351311872 --- /dev/null +++ b/src/Artemis.Core.Tests/Storage/Repositories/ProfileCategoryRepositoryTests.cs @@ -0,0 +1,205 @@ +using System.Text.Json.Nodes; +using Artemis.Core.Tests.Infrastructure.Services; +using Artemis.Core.Tests.Infrastructure.Storage; +using Artemis.Storage.Entities.Profile; +using Artemis.Storage.Repositories; +using Artemis.Storage.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace Artemis.Core.Tests.Storage.Repositories; + +public class ProfileCategoryRepositoryTests : IDisposable +{ + private readonly RecordingLogger _log = new(); + private readonly ServiceHarness _services = new(); + private readonly ProfileCategoryRepository _repository; + + public ProfileCategoryRepositoryTests() + { + _repository = CreateRepository(); + } + + [Fact] + public void Artemis_RegistersTheProfileCategoryRepository() + { + _services.Resolve().ShouldBeOfType(); + } + + [Fact] + public void Add_ThenGet_ReturnsTheCategory() + { + ProfileCategoryEntity category = new() {Name = "Games", IsCollapsed = true, IsSuspended = true, Order = 3}; + + _repository.Add(category); + + ProfileCategoryEntity stored = _repository.Get(category.Id).ShouldNotBeNull(); + stored.ShouldNotBeSameAs(category); + (stored.Name, stored.IsCollapsed, stored.IsSuspended, stored.Order).ShouldBe(("Games", true, true, 3)); + } + + [Fact] + public void Add_CategoryWithANameThatIsTaken_Throws() + { + _repository.Add(new ProfileCategoryEntity {Name = "Games"}); + + Should.Throw(() => _repository.Add(new ProfileCategoryEntity {Name = "Games"})); + } + + [Fact] + public void Get_UnknownCategory_ReturnsNull() + { + _repository.Get(Guid.NewGuid()).ShouldBeNull(); + } + + [Fact] + public void GetAll_ReturnsAllCategoriesWithTheirProfiles() + { + ProfileCategoryEntity games = new() {Name = "Games"}; + games.ProfileConfigurations.Add(CreateContainer("Racing")); + games.ProfileConfigurations.Add(CreateContainer("Shooter")); + _repository.Add(games); + _repository.Add(new ProfileCategoryEntity {Name = "Empty"}); + + List categories = _repository.GetAll(); + + categories.Select(c => c.Name).Order().ShouldBe(["Empty", "Games"]); + categories.Single(c => c.Name == "Games").ProfileConfigurations.Select(p => p.Profile.Name).Order().ShouldBe(["Racing", "Shooter"]); + categories.Single(c => c.Name == "Empty").ProfileConfigurations.ShouldBeEmpty(); + } + + [Fact] + public void Save_StoresChangesAndNewProfiles() + { + ProfileCategoryEntity category = new() {Name = "Games"}; + _repository.Add(category); + + category.Name = "Renamed"; + category.Order = 2; + category.ProfileConfigurations.Add(CreateContainer("New profile")); + _repository.Save(category); + + ProfileCategoryEntity stored = _repository.Get(category.Id)!; + stored.Name.ShouldBe("Renamed"); + stored.Order.ShouldBe(2); + ProfileContainerEntity profile = stored.ProfileConfigurations.ShouldHaveSingleItem(); + profile.Profile.Name.ShouldBe("New profile"); + // Unlike ProfileRepository.Save this doesn't set the version, see ProfileRepositoryTests + profile.ProfileConfiguration.Version.ShouldBe(0); + } + + [Fact] + public void SaveRange_StoresAllCategories() + { + ProfileCategoryEntity first = new() {Name = "First"}; + ProfileCategoryEntity second = new() {Name = "Second"}; + _repository.Add(first); + _repository.Add(second); + + first.Order = 2; + second.Order = 1; + _repository.SaveRange([first, second]); + + _repository.GetAll().OrderBy(c => c.Order).Select(c => c.Name).ShouldBe(["Second", "First"]); + } + + [Fact] + public void Remove_DeletesTheCategoryAndItsProfiles() + { + ProfileCategoryEntity category = new() {Name = "Games"}; + category.ProfileConfigurations.Add(CreateContainer("Racing")); + _repository.Add(category); + + _repository.Remove(category); + + using var context = _services.CreateDbContext(); + context.ProfileCategories.ShouldBeEmpty(); + context.ProfileContainers.ShouldBeEmpty(); + } + + [Fact] + public void IsUnique_ReturnsWhetherTheNameIsTaken() + { + ProfileCategoryEntity games = new() {Name = "Games"}; + _repository.Add(games); + + // Pinned as-is: despite its name this returns true when another category already has the name, so true means + // the name is not unique. Nothing in Artemis calls it at the moment. + _repository.IsUnique("Games", null).ShouldBeTrue(); + _repository.IsUnique("Other", null).ShouldBeFalse(); + } + + [Fact] + public void IsUnique_IgnoresTheCategoryWithTheGivenId() + { + ProfileCategoryEntity games = new() {Name = "Games"}; + _repository.Add(games); + + _repository.IsUnique("Games", games.Id).ShouldBeFalse(); + _repository.IsUnique("Games", Guid.NewGuid()).ShouldBeTrue(); + } + + [Fact] + public void IsUnique_IgnoresSpacesAroundTheName() + { + _repository.Add(new ProfileCategoryEntity {Name = "Games"}); + + _repository.IsUnique(" Games ", null).ShouldBeTrue(); + _repository.IsUnique("games", null).ShouldBeFalse(); + } + + [Fact] + public void GetAll_MigratesOldProfilesTheFirstTime() + { + ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext); + StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, StoredProfiles.Version0ConfigurationWithVersion(), OldProfileJson.Version0Profile); + + ProfileContainerEntity profile = _repository.GetAll().Single().ProfileConfigurations.Single(); + + profile.ProfileConfiguration.Version.ShouldBe(4); + profile.Profile.Layers.ShouldHaveSingleItem().AdaptionHints.Count.ShouldBe(2); + } + + [Fact] + public void Get_MigratesOldProfilesTheFirstTime() + { + ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext); + StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, StoredProfiles.Version0ConfigurationWithVersion(), OldProfileJson.Version0Profile); + + _repository.Get(category.Id)!.ProfileConfigurations.Single().ProfileConfiguration.Version.ShouldBe(4); + } + + [Fact] + public void GetAll_MigratesOnlyOncePerRepository() + { + ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext); + _repository.GetAll(); + Guid id = StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, StoredProfiles.Version0ConfigurationWithVersion(), OldProfileJson.Version0Profile); + + // The repository is a singleton, profiles are migrated once when Artemis starts + _repository.Get(Guid.NewGuid()); + JsonNode.Parse(StoredProfiles.ReadRaw(_services.CreateDbContext, id).Configuration)!["Version"]!.GetValue().ShouldBe(0); + + // A new repository does migrate it + CreateRepository().GetAll().Single().ProfileConfigurations.Single().ProfileConfiguration.Version.ShouldBe(4); + } + + private ProfileCategoryRepository CreateRepository() + { + return new ProfileCategoryRepository(_services.CreateDbContext, new ProfileRepository(_log.Logger, _services.CreateDbContext, StoredProfiles.AllMigrations(_log.Logger))); + } + + private static ProfileContainerEntity CreateContainer(string name) + { + Guid profileId = Guid.NewGuid(); + return new ProfileContainerEntity + { + ProfileConfiguration = new ProfileConfigurationEntity {Name = name, ProfileId = profileId}, + Profile = new ProfileEntity {Id = profileId, Name = name} + }; + } + + public void Dispose() + { + _services.Dispose(); + } +} diff --git a/src/Artemis.Core.Tests/Storage/Repositories/ProfileRepositoryTests.cs b/src/Artemis.Core.Tests/Storage/Repositories/ProfileRepositoryTests.cs new file mode 100644 index 000000000..1319c0cbd --- /dev/null +++ b/src/Artemis.Core.Tests/Storage/Repositories/ProfileRepositoryTests.cs @@ -0,0 +1,221 @@ +using System.Text.Json.Nodes; +using Artemis.Core.Services; +using Artemis.Core.Tests.Infrastructure.Services; +using Artemis.Core.Tests.Infrastructure.Storage; +using Artemis.Storage.Entities.Profile; +using Artemis.Storage.Repositories; +using Artemis.Storage.Repositories.Interfaces; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Artemis.Core.Tests.Storage.Repositories; + +public class ProfileRepositoryTests : IDisposable +{ + private readonly RecordingLogger _log = new(); + private readonly ServiceHarness _services = new(); + private readonly ProfileRepository _repository; + + public ProfileRepositoryTests() + { + _repository = new ProfileRepository(_log.Logger, _services.CreateDbContext, StoredProfiles.AllMigrations(_log.Logger)); + } + + [Fact] + public void Artemis_RegistersTheProfileRepository() + { + _services.Resolve().ShouldBeOfType(); + } + + [Fact] + public void Add_StoresTheProfileAndItsNewCategory() + { + ProfileContainerEntity container = CreateContainer(new ProfileCategoryEntity {Name = "New category"}); + + _repository.Add(container); + + using var context = _services.CreateDbContext(); + ProfileContainerEntity stored = context.ProfileContainers.Include(c => c.ProfileCategory).Single(); + stored.Profile.Name.ShouldBe("Profile"); + stored.ProfileCategory.Name.ShouldBe("New category"); + // Unlike Save, Add keeps the version as it is + stored.ProfileConfiguration.Version.ShouldBe(0); + } + + [Fact] + public void Add_WithACategoryThatIsAlreadyStored_Throws() + { + // Add also adds everything the profile refers to, so the category is added a second time. Artemis adds profiles + // by saving their category instead, see ProfileCategoryRepositoryTests. + ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext); + + Should.Throw(() => _repository.Add(CreateContainer(category))); + } + + [Fact] + public void Save_StoresTheProfileWithTheLatestVersion() + { + ProfileContainerEntity container = CreateContainer(StoredProfiles.AddCategory(_services.CreateDbContext)); + + _repository.Save(container); + + container.ProfileConfiguration.Version.ShouldBe(4); + using var context = _services.CreateDbContext(); + context.ProfileContainers.Single().ProfileConfiguration.Version.ShouldBe(4); + } + + [Fact] + public void Save_UpdatesAStoredProfile() + { + ProfileContainerEntity container = CreateContainer(StoredProfiles.AddCategory(_services.CreateDbContext)); + _repository.Save(container); + + container.Profile.Name = "Renamed"; + container.ProfileConfiguration.Name = "Renamed configuration"; + _repository.Save(container); + + using var context = _services.CreateDbContext(); + ProfileContainerEntity stored = context.ProfileContainers.Single(); + stored.Profile.Name.ShouldBe("Renamed"); + stored.ProfileConfiguration.Name.ShouldBe("Renamed configuration"); + } + + [Fact] + public void SaveRange_StoresAllProfilesWithTheLatestVersion() + { + ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext); + List containers = [CreateContainer(category, "First"), CreateContainer(category, "Second")]; + + _repository.SaveRange(containers); + + using var context = _services.CreateDbContext(); + context.ProfileContainers.AsEnumerable().Select(c => (c.Profile.Name, c.ProfileConfiguration.Version)).Order() + .ShouldBe([("First", 4), ("Second", 4)]); + } + + [Fact] + public void Remove_DeletesTheProfile() + { + ProfileContainerEntity container = CreateContainer(StoredProfiles.AddCategory(_services.CreateDbContext)); + _repository.Save(container); + + _repository.Remove(container); + + using var context = _services.CreateDbContext(); + context.ProfileContainers.ShouldBeEmpty(); + context.ProfileCategories.ShouldHaveSingleItem(); + } + + [Fact] + public void MigrateProfiles_MigratesOldProfilesInTheDatabase() + { + ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext); + Guid id = StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, StoredProfiles.Version0ConfigurationWithVersion(), OldProfileJson.Version0Profile); + + _repository.MigrateProfiles(); + + (string configuration, string profile) = StoredProfiles.ReadRaw(_services.CreateDbContext, id); + JsonNode.Parse(configuration).ShouldBeJson(OldProfileJson.MigratedConfiguration); + JsonNode.Parse(profile).ShouldBeJson(OldProfileJson.MigratedProfile); + _log.Errors.ShouldBeEmpty(); + + // Entity Framework can read it now + using var context = _services.CreateDbContext(); + ProfileContainerEntity stored = context.ProfileContainers.Single(); + stored.Profile.Layers.ShouldHaveSingleItem().AdaptionHints.Count.ShouldBe(2); + } + + [Fact] + public void MigrateProfiles_LeavesProfilesAtTheLatestVersionAlone() + { + ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext); + // Indented JSON, migrating would write it again in a different layout + Guid id = StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, OldProfileJson.MigratedConfiguration, OldProfileJson.MigratedProfile); + + _repository.MigrateProfiles(); + + StoredProfiles.ReadRaw(_services.CreateDbContext, id).ShouldBe((OldProfileJson.MigratedConfiguration, OldProfileJson.MigratedProfile)); + } + + [Fact] + public void MigrateProfiles_ProfileWithoutVersion_IsNotMigrated() + { + ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext); + Guid id = StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, OldProfileJson.Version0Configuration, OldProfileJson.Version0Profile); + + _repository.MigrateProfiles(); + + // Pinned as-is: the database query only picks profiles whose version is below the latest, a missing version + // doesn't count as lower. MigrateProfile itself treats a missing version as version 0. Profiles stored by + // Entity Framework always have a version, so this only matters for rows written some other way. + StoredProfiles.ReadRaw(_services.CreateDbContext, id).ShouldBe((OldProfileJson.Version0Configuration, OldProfileJson.Version0Profile)); + } + + [Theory] + [InlineData("{broken", "Failed to migrate profile container {Id}")] + [InlineData("null", "Failed to parse profile or profile configuration of profile container {Id}")] + [InlineData("""{"Folders": 5}""", "Failed to migrate profile container {Id}")] + public void MigrateProfiles_ProfileThatCantBeMigrated_IsLoggedAndSkipped(string brokenProfile, string expectedError) + { + ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext); + Guid broken = StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, StoredProfiles.Version0ConfigurationWithVersion(), brokenProfile); + Guid working = StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, StoredProfiles.Version0ConfigurationWithVersion(), OldProfileJson.Version0Profile); + + _repository.MigrateProfiles(); + + _log.Errors.ShouldHaveSingleItem().MessageTemplate.Text.ShouldBe(expectedError); + StoredProfiles.ReadRaw(_services.CreateDbContext, broken).Profile.ShouldBe(brokenProfile); + JsonNode.Parse(StoredProfiles.ReadRaw(_services.CreateDbContext, working).Profile).ShouldBeJson(OldProfileJson.MigratedProfile); + } + + [Fact] + public void MigrateProfiles_ConfigurationThatIsNotJson_StopsAllMigrations() + { + ProfileCategoryEntity category = StoredProfiles.AddCategory(_services.CreateDbContext); + StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, "{broken", OldProfileJson.Version0Profile); + Guid working = StoredProfiles.InsertRaw(_services.CreateDbContext, category.Id, StoredProfiles.Version0ConfigurationWithVersion(), OldProfileJson.Version0Profile); + + // Pinned as-is: the version is read from the configuration by the database query, which fails on the broken + // JSON before any profile is migrated. Unlike a broken profile, this isn't caught and logged. + Should.Throw(() => _repository.MigrateProfiles()).Message.ShouldContain("malformed JSON"); + StoredProfiles.ReadRaw(_services.CreateDbContext, working).Profile.ShouldBe(OldProfileJson.Version0Profile); + } + + [Fact] + public void MigrateProfiles_ProfileCreatedInArtemis_IsStoredAtVersion0AndFailsToMigrate() + { + IProfileService profileService = _services.Resolve(); + ProfileCategory category = profileService.CreateProfileCategory("Created"); + profileService.CreateProfileConfiguration(category, "New profile", "Keyboard"); + + // Pinned as-is: a new profile is stored by saving its category, which doesn't set the version like + // ProfileRepository.Save does. The profile is in the current format, but at version 0 M0001 runs on it and + // fails. The error is logged each time the profiles are migrated (when the profile service starts) until the + // profile is saved by itself. + using (var context = _services.CreateDbContext()) + context.ProfileContainers.Single().ProfileConfiguration.Version.ShouldBe(0); + + _repository.MigrateProfiles(); + + _log.Errors.ShouldHaveSingleItem().MessageTemplate.Text.ShouldBe("Failed to migrate profile container {Id}"); + _log.Errors.Single().Exception.ShouldBeOfType(); + using (var context = _services.CreateDbContext()) + context.ProfileContainers.Single().ProfileConfiguration.Version.ShouldBe(0); + } + + private static ProfileContainerEntity CreateContainer(ProfileCategoryEntity category, string name = "Profile") + { + Guid profileId = Guid.NewGuid(); + return new ProfileContainerEntity + { + ProfileCategory = category, + ProfileConfiguration = new ProfileConfigurationEntity {Name = name, ProfileCategoryId = category.Id, ProfileId = profileId}, + Profile = new ProfileEntity {Id = profileId, Name = name} + }; + } + + public void Dispose() + { + _services.Dispose(); + } +} diff --git a/src/Artemis.Core.Tests/Storage/Repositories/ReleaseRepositoryTests.cs b/src/Artemis.Core.Tests/Storage/Repositories/ReleaseRepositoryTests.cs new file mode 100644 index 000000000..235223e8b --- /dev/null +++ b/src/Artemis.Core.Tests/Storage/Repositories/ReleaseRepositoryTests.cs @@ -0,0 +1,96 @@ +using Artemis.Core.Tests.Infrastructure.Services; +using Artemis.Storage.Entities.General; +using Artemis.Storage.Repositories.Interfaces; + +namespace Artemis.Core.Tests.Storage.Repositories; + +public class ReleaseRepositoryTests : IDisposable +{ + private readonly ServiceHarness _services = new(); + private readonly IReleaseRepository _repository; + + public ReleaseRepositoryTests() + { + _repository = _services.Resolve(); + } + + [Fact] + public void SaveVersionInstallDate_NewVersion_IsStoredWithTheCurrentTime() + { + DateTimeOffset before = DateTimeOffset.UtcNow; + + _repository.SaveVersionInstallDate("1.0.0").ShouldBeTrue(); + + using var context = _services.CreateDbContext(); + ReleaseEntity release = context.Releases.ShouldHaveSingleItem(); + release.Version.ShouldBe("1.0.0"); + release.InstalledAt.ShouldNotBeNull().ShouldBeInRange(before, DateTimeOffset.UtcNow); + } + + [Fact] + public void SaveVersionInstallDate_VersionThatIsAlreadyStored_KeepsTheFirstDate() + { + DateTimeOffset installedAt = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + AddRelease("1.0.0", installedAt); + + _repository.SaveVersionInstallDate("1.0.0").ShouldBeFalse(); + + using var context = _services.CreateDbContext(); + context.Releases.ShouldHaveSingleItem().InstalledAt.ShouldBe(installedAt); + } + + [Fact] + public void GetPreviousInstalledVersion_NothingOrOnlyOneVersionInstalled_ReturnsNull() + { + _repository.GetPreviousInstalledVersion().ShouldBeNull(); + + _repository.SaveVersionInstallDate("1.0.0"); + + _repository.GetPreviousInstalledVersion().ShouldBeNull(); + } + + [Fact] + public void GetPreviousInstalledVersion_ReturnsTheVersionInstalledBeforeTheLatest() + { + // Stored out of order, the install date decides + AddRelease("1.1.0", new DateTimeOffset(2024, 2, 1, 0, 0, 0, TimeSpan.Zero)); + AddRelease("1.2.0", new DateTimeOffset(2024, 3, 1, 0, 0, 0, TimeSpan.Zero)); + AddRelease("1.0.0", new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + _repository.GetPreviousInstalledVersion().ShouldNotBeNull().Version.ShouldBe("1.1.0"); + } + + [Fact] + public void GetPreviousInstalledVersion_ComparesTheMomentNotTheTimeZone() + { + // 10:00 at +05:00 is 05:00 UTC, so it was installed before 08:00 UTC + AddRelease("1.0.0", new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.FromHours(5))); + AddRelease("1.1.0", new DateTimeOffset(2024, 1, 1, 8, 0, 0, TimeSpan.Zero)); + + _repository.GetPreviousInstalledVersion()!.Version.ShouldBe("1.0.0"); + } + + [Fact] + public void GetPreviousInstalledVersion_VersionWithoutInstallDate_CountsAsTheOldest() + { + AddRelease("0.9.0", null); + AddRelease("1.0.0", new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + _repository.GetPreviousInstalledVersion()!.Version.ShouldBe("0.9.0"); + + AddRelease("1.1.0", new DateTimeOffset(2024, 2, 1, 0, 0, 0, TimeSpan.Zero)); + _repository.GetPreviousInstalledVersion()!.Version.ShouldBe("1.0.0"); + } + + private void AddRelease(string version, DateTimeOffset? installedAt) + { + using var context = _services.CreateDbContext(); + context.Releases.Add(new ReleaseEntity {Version = version, InstalledAt = installedAt}); + context.SaveChanges(); + } + + public void Dispose() + { + _services.Dispose(); + } +} diff --git a/src/Artemis.Core.Tests/TestAssembly.cs b/src/Artemis.Core.Tests/TestAssembly.cs new file mode 100644 index 000000000..89c5455f6 --- /dev/null +++ b/src/Artemis.Core.Tests/TestAssembly.cs @@ -0,0 +1,6 @@ +using Xunit.Sdk; +using Xunit.v3; + +// Artemis.Core keeps brush and effect registrations in static stores and every profile element subscribes to their +// events, so tests registering test plugins would affect profiles of tests running in parallel +[assembly: Parallelization(Mode = ParallelMode.None)] diff --git a/src/Artemis.Core.Tests/Utilities/ColorExtensionsTests.cs b/src/Artemis.Core.Tests/Utilities/ColorExtensionsTests.cs new file mode 100644 index 000000000..a82320387 --- /dev/null +++ b/src/Artemis.Core.Tests/Utilities/ColorExtensionsTests.cs @@ -0,0 +1,112 @@ +using Artemis.Core.Tests.Infrastructure; +using RGB.NET.Core; +using SkiaSharp; + +namespace Artemis.Core.Tests.Utilities; + +public class ColorExtensionsTests +{ + [Fact] + public void ToRgbColor_KeepsEveryChannel() + { + Color color = new SKColor(10, 20, 30, 40).ToRgbColor(); + + color.GetR().ShouldBe((byte) 10); + color.GetG().ShouldBe((byte) 20); + color.GetB().ShouldBe((byte) 30); + color.GetA().ShouldBe((byte) 40); + } + + [Theory] + [InlineData(0f, 0, 0, 0, 0)] + [InlineData(0.5f, 50, 100, 127, 127)] + [InlineData(1f, 100, 200, 255, 255)] + public void Interpolate_BlendsEveryChannel(float progress, byte red, byte green, byte blue, byte alpha) + { + SKColor from = new(0, 0, 0, 0); + SKColor to = new(100, 200, 255, 255); + + from.Interpolate(to, progress).ShouldBe(new SKColor(red, green, blue, alpha)); + } + + [Fact] + public void Interpolate_ClampsOutsideOfTheRange() + { + SKColor from = new(100, 100, 100); + SKColor to = new(200, 200, 200); + + from.Interpolate(to, 2f).ShouldBe(new SKColor(255, 255, 255)); + from.Interpolate(to, -2f).ShouldBe(new SKColor(0, 0, 0)); + } + + [Fact] + public void Interpolate_CanGoDown() + { + SKColors.White.Interpolate(SKColors.Black, 0.25f).ShouldBe(new SKColor(191, 191, 191)); + } + + [Fact] + public void Sum_AddsEveryChannelAndClamps() + { + new SKColor(100, 200, 50, 100).Sum(new SKColor(100, 100, 10, 100)).ShouldBe(new SKColor(200, 255, 60, 200)); + } + + [Fact] + public void Darken_LowersTheLightness() + { + SKColors.Red.Darken(0.5f).ShouldBeColor(new SKColor(128, 0, 0), 1); + SKColors.Red.Darken(0f).ShouldBe(SKColors.Red); + SKColors.Red.Darken(1f).ShouldBe(SKColors.Black); + } + + [Fact] + public void Darken_MakesTheColorOpaque() + { + // Pinned as-is: the color is rebuilt from its hue, saturation and lightness, which drops the alpha + SKColors.Red.WithAlpha(100).Darken(0.5f).Alpha.ShouldBe((byte) 255); + } + + [Theory] + [InlineData(0.4f, 0)] + [InlineData(0.5f, 1)] + [InlineData(1.5f, 2)] + [InlineData(2.5f, 3)] + [InlineData(-0.5f, -1)] + [InlineData(-1.6f, -2)] + public void RoundToInt_RoundsHalfwayAwayFromZero(float number, int expected) + { + number.RoundToInt().ShouldBe(expected); + ((double) number).RoundToInt().ShouldBe(expected); + } + + [Fact] + public void IndexOf_FindsTheFirstEqualElement() + { + IReadOnlyCollection collection = ["a", "b", null, "b"]; + + collection.IndexOf("b").ShouldBe(1); + collection.IndexOf(null).ShouldBe(2); + collection.IndexOf("c").ShouldBe(-1); + } + + [Fact] + public void ToSKRect_KeepsTheRectangle() + { + Rectangle rectangle = new(1.4f, 2.6f, 3f, 4f); + + rectangle.ToSKRect().ShouldBe(SKRect.Create(1.4f, 2.6f, 3f, 4f)); + rectangle.ToSKRectI().ShouldBe(new SKRectI(1, 3, 4, 7)); + } + + [Fact] + public void DisposeSelfAndProperties_DisposesTheShader() + { + SKShader shader = SKShader.CreateColor(SKColors.Red); + SKPaint paint = new() {Shader = shader}; + + paint.DisposeSelfAndProperties(); + + shader.Handle.ShouldBe(IntPtr.Zero); + paint.Handle.ShouldBe(IntPtr.Zero); + } +} diff --git a/src/Artemis.Core.Tests/Utilities/CoreUtilitiesTests.cs b/src/Artemis.Core.Tests/Utilities/CoreUtilitiesTests.cs new file mode 100644 index 000000000..f6545a536 --- /dev/null +++ b/src/Artemis.Core.Tests/Utilities/CoreUtilitiesTests.cs @@ -0,0 +1,155 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Security.AccessControl; +using System.Security.Principal; +using SkiaSharp; +using CoreUtilities = Artemis.Core.Utilities; + +namespace Artemis.Core.Tests.Utilities; + +public class CoreUtilitiesTests +{ + private class Person + { + public string Name { get; set; } = ""; + public int Age { get; set; } + public Person? Partner { get; set; } + public int Field = 0; + public int GetAge() => Age; + } + + #region Utilities + + [Fact] + public void ApplyUpdate_AsksTheAppToUpdate() + { + List requests = []; + EventHandler handler = (_, e) => requests.Add(e.Silent); + CoreUtilities.UpdateRequested += handler; + try + { + CoreUtilities.ApplyUpdate(true); + CoreUtilities.ApplyUpdate(false); + } + finally + { + CoreUtilities.UpdateRequested -= handler; + } + + requests.ShouldBe([true, false]); + } + + [Fact] + public void CreateAccessibleDirectory_CreatesTheWholePath() + { + string root = Path.Combine(Path.GetTempPath(), "Artemis.Core.Tests", Guid.NewGuid().ToString()); + string path = Path.Combine(root, "nested", "folder"); + try + { + CoreUtilities.CreateAccessibleDirectory(path); + + Directory.Exists(path).ShouldBeTrue(); + if (OperatingSystem.IsWindows()) + { + SecurityIdentifier everyone = new(WellKnownSidType.WorldSid, null); + new DirectoryInfo(path).GetAccessControl() + .GetAccessRules(true, false, typeof(SecurityIdentifier)) + .Cast() + .ShouldContain(r => r.IdentityReference == everyone && r.FileSystemRights.HasFlag(FileSystemRights.Modify)); + } + + // An existing directory is left alone + File.WriteAllText(Path.Combine(path, "file.txt"), "content"); + CoreUtilities.CreateAccessibleDirectory(path); + File.Exists(Path.Combine(path, "file.txt")).ShouldBeTrue(); + } + finally + { + Directory.Delete(root, true); + } + } + + [Fact] + public void GetCurrentLocation_IsTheRunningProgram() + { + File.Exists(CoreUtilities.GetCurrentLocation()).ShouldBeTrue(); + } + + #endregion + + #region JSON + + [Fact] + public void CoreJson_RoundTripsColors() + { + string json = CoreJson.Serialize(new Dictionary {{"Color", new SKColor(0x11, 0x22, 0x33, 0x44)}}); + + json.ShouldBe("{\"Color\":\"#44112233\"}"); + CoreJson.Deserialize>(json)!["Color"].ShouldBe(new SKColor(0x11, 0x22, 0x33, 0x44)); + } + + [Fact] + public void CoreJson_OptionsAreACopy() + { + CoreJson.GetJsonSerializerOptions().ShouldNotBeSameAs(CoreJson.GetJsonSerializerOptions()); + CoreJson.GetJsonSerializerOptions().Converters.Count.ShouldBe(2); + } + + #endregion + + #region Reflection and expressions + + [Fact] + public void GetPropertyInfo_FindsTheProperty() + { + PropertyInfo property = ReflectionUtilities.GetPropertyInfo(new Person(), (Person p) => p.Name); + + property.Name.ShouldBe(nameof(Person.Name)); + } + + [Fact] + public void GetPropertyInfo_OnlyAcceptsProperties() + { + Should.Throw(() => ReflectionUtilities.GetPropertyInfo(new Person(), (Person p) => p.GetAge())) + .Message.ShouldContain("refers to a method"); + Should.Throw(() => ReflectionUtilities.GetPropertyInfo(new Person(), (Person p) => p.Field)) + .Message.ShouldContain("refers to a field"); + } + + [Fact] + public void CreateNullCheckedAccessor_ReadsThePath() + { + Func partnerName = CreateAccessor("Partner.Name"); + Func partnerAge = CreateAccessor("Partner.Age"); + Person person = new() {Partner = new Person {Name = "Sam", Age = 30}}; + + partnerName(person).ShouldBe("Sam"); + partnerAge(person).ShouldBe(30); + } + + [Fact] + public void CreateNullCheckedAccessor_GivesTheDefaultWhenSomethingOnThePathIsMissing() + { + Person single = new() {Name = "Alex"}; + + CreateAccessor("Partner.Name")(single).ShouldBeNull(); + CreateAccessor("Partner.Age")(single).ShouldBe(0); + CreateAccessor("Partner.Partner.Name")(single).ShouldBeNull(); + CreateAccessor("Name")(null!).ShouldBeNull(); + } + + [Fact] + public void CreateNullCheckedAccessor_ThrowsForUnknownMembers() + { + Should.Throw(() => CreateAccessor("Partner.Nickname")); + } + + private static Func CreateAccessor(string path) + { + ParameterExpression parameter = Expression.Parameter(typeof(Person)); + Expression accessor = ExpressionUtilities.CreateNullCheckedAccessor(parameter, path); + return Expression.Lambda>(accessor, parameter).Compile(); + } + + #endregion +} diff --git a/src/Artemis.Core.Tests/Utilities/EasingsTests.cs b/src/Artemis.Core.Tests/Utilities/EasingsTests.cs new file mode 100644 index 000000000..57303aace --- /dev/null +++ b/src/Artemis.Core.Tests/Utilities/EasingsTests.cs @@ -0,0 +1,104 @@ +using System.Reflection; + +namespace Artemis.Core.Tests.Utilities; + +public class EasingsTests +{ + public static TheoryData AllFunctions => new(Enum.GetValues()); + + /// + /// Each ease in function with its matching ease out function + /// + public static TheoryData InAndOutPairs => new() + { + {Easings.Functions.QuadraticEaseIn, Easings.Functions.QuadraticEaseOut}, + {Easings.Functions.CubicEaseIn, Easings.Functions.CubicEaseOut}, + {Easings.Functions.QuarticEaseIn, Easings.Functions.QuarticEaseOut}, + {Easings.Functions.QuinticEaseIn, Easings.Functions.QuinticEaseOut}, + {Easings.Functions.SineEaseIn, Easings.Functions.SineEaseOut}, + {Easings.Functions.CircularEaseIn, Easings.Functions.CircularEaseOut}, + {Easings.Functions.ExponentialEaseIn, Easings.Functions.ExponentialEaseOut}, + {Easings.Functions.ElasticEaseIn, Easings.Functions.ElasticEaseOut}, + {Easings.Functions.BackEaseIn, Easings.Functions.BackEaseOut}, + {Easings.Functions.BounceEaseIn, Easings.Functions.BounceEaseOut} + }; + + [Theory] + [MemberData(nameof(AllFunctions))] + public void Interpolate_StartsAtZeroAndEndsAtOne(Easings.Functions function) + { + Easings.Interpolate(0, function).ShouldBe(0, 0.000001); + Easings.Interpolate(1, function).ShouldBe(1, 0.000001); + } + + [Theory] + [MemberData(nameof(AllFunctions))] + public void Interpolate_UsesTheFunctionOfTheSameName(Easings.Functions function) + { + MethodInfo method = typeof(Easings).GetMethod(function.ToString(), BindingFlags.Public | BindingFlags.Static)!; + + foreach (double p in new[] {0.1, 0.3, 0.5, 0.7, 0.9}) + Easings.Interpolate(p, function).ShouldBe((double) method.Invoke(null, [p])!, $"{function} at {p}"); + } + + [Theory] + [MemberData(nameof(AllFunctions))] + public void InOutFunctions_AreHalfwayHalfway(Easings.Functions function) + { + if (!function.ToString().EndsWith("InOut")) + return; + + Easings.Interpolate(0.5, function).ShouldBe(0.5, 0.000001); + } + + [Theory] + [MemberData(nameof(InAndOutPairs))] + public void EaseIn_IsEaseOutPlayedBackwards(Easings.Functions easeIn, Easings.Functions easeOut) + { + foreach (double p in new[] {0.1, 0.25, 0.5, 0.75, 0.9}) + Easings.Interpolate(p, easeIn).ShouldBe(1 - Easings.Interpolate(1 - p, easeOut), 0.000001, $"{easeIn} at {p}"); + } + + [Theory] + [InlineData(Easings.Functions.Linear, 0.3, 0.3)] + [InlineData(Easings.Functions.QuadraticEaseIn, 0.5, 0.25)] + [InlineData(Easings.Functions.QuadraticEaseOut, 0.5, 0.75)] + [InlineData(Easings.Functions.QuadraticEaseInOut, 0.25, 0.125)] + [InlineData(Easings.Functions.CubicEaseIn, 0.5, 0.125)] + [InlineData(Easings.Functions.CubicEaseInOut, 0.25, 0.0625)] + [InlineData(Easings.Functions.QuarticEaseIn, 0.5, 0.0625)] + [InlineData(Easings.Functions.QuarticEaseInOut, 0.25, 0.03125)] + [InlineData(Easings.Functions.QuinticEaseIn, 0.5, 0.03125)] + [InlineData(Easings.Functions.QuinticEaseInOut, 0.25, 0.015625)] + [InlineData(Easings.Functions.SineEaseOut, 0.5, 0.70710678)] + [InlineData(Easings.Functions.ExponentialEaseIn, 0.5, 0.03125)] + [InlineData(Easings.Functions.BounceEaseOut, 0.5, 0.71875)] + public void Functions_FollowTheirCurve(Easings.Functions function, double p, double expected) + { + Easings.Interpolate(p, function).ShouldBe(expected, 0.000001); + } + + [Fact] + public void BackAndElastic_OvershootTheRange() + { + Easings.BackEaseIn(0.3).ShouldBeLessThan(0); + Easings.BackEaseOut(0.7).ShouldBeGreaterThan(1); + Easings.ElasticEaseOut(0.1).ShouldBeGreaterThan(1); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(0.5, 0)] + [InlineData(0.999, 0)] + [InlineData(1, 1)] + public void Step_JumpsAtTheEnd(double p, double expected) + { + Easings.Step(p).ShouldBe(expected); + } + + [Fact] + public void Interpolate_WithAnUnknownFunctionIsLinear() + { + Easings.Interpolate(0.3, (Easings.Functions) 999).ShouldBe(0.3); + } +} diff --git a/src/Artemis.Core.Tests/Utilities/EnumUtilitiesTests.cs b/src/Artemis.Core.Tests/Utilities/EnumUtilitiesTests.cs new file mode 100644 index 000000000..f2556566f --- /dev/null +++ b/src/Artemis.Core.Tests/Utilities/EnumUtilitiesTests.cs @@ -0,0 +1,46 @@ +namespace Artemis.Core.Tests.Utilities; + +public class EnumUtilitiesTests +{ + private enum PlainEnum + { + FirstValue, + SecondValue + } + + [Fact] + public void GetAllValuesAndDescriptions_HumanizesEveryValue() + { + EnumUtilities.GetAllValuesAndDescriptions().ShouldBe([(PlainEnum.FirstValue, "First value"), (PlainEnum.SecondValue, "Second value")]); + } + + [Fact] + public void GetAllValuesAndDescriptions_ByTypeHumanizesEveryValue() + { + EnumUtilities.GetAllValuesAndDescriptions(typeof(PlainEnum)).ShouldBe([(PlainEnum.FirstValue, "First value"), (PlainEnum.SecondValue, "Second value")]); + } + + [Fact] + public void GetAllValuesAndDescriptions_UsesDescriptionsOnlyWhenGivenTheTypeAsGenericArgument() + { + (KeyboardSection, string) generic = EnumUtilities.GetAllValuesAndDescriptions().Single(v => v.Item1 == KeyboardSection.LedStrips); + (Enum, string) byType = EnumUtilities.GetAllValuesAndDescriptions(typeof(KeyboardSection)).Single(v => v.Item1.Equals(KeyboardSection.LedStrips)); + + // Pinned as-is: the generic version uses the [Description] attribute, the version taking a type ignores it + generic.Item2.ShouldBe("LED Strips"); + byType.Item2.ShouldBe("Led strips"); + } + + [Fact] + public void GetAllValuesAndDescriptions_ByTypeOnlyAcceptsEnums() + { + Should.Throw(() => EnumUtilities.GetAllValuesAndDescriptions(typeof(int))); + } + + [Fact] + public void HumanizeValue_HumanizesTheName() + { + EnumUtilities.HumanizeValue(PlainEnum.SecondValue).ShouldBe("Second value"); + EnumUtilities.HumanizeValue(KeyboardSection.MacroKeys).ShouldBe("Macro keys"); + } +} diff --git a/src/Artemis.Core.Tests/Utilities/StreamExtensionsTests.cs b/src/Artemis.Core.Tests/Utilities/StreamExtensionsTests.cs new file mode 100644 index 000000000..fc5e11932 --- /dev/null +++ b/src/Artemis.Core.Tests/Utilities/StreamExtensionsTests.cs @@ -0,0 +1,96 @@ +namespace Artemis.Core.Tests.Utilities; + +public class StreamExtensionsTests +{ + [Fact] + public async Task CopyToAsync_CopiesEverythingAndReportsProgress() + { + byte[] data = Enumerable.Range(0, 10).Select(i => (byte) i).ToArray(); + using MemoryStream source = new(data); + using MemoryStream destination = new(); + RecordedProgress progress = new(); + + await source.CopyToAsync(10, destination, 4, progress, TestContext.Current.CancellationToken); + + destination.ToArray().ShouldBe(data); + // The final total is reported twice, once for the last block and once when done + progress.Reports.ShouldBe([(4L, 10L), (8L, 10L), (10L, 10L), (10L, 10L)]); + } + + [Fact] + public async Task CopyToAsync_WithoutALengthReportsZeroAsTheLength() + { + using MemoryStream source = new(new byte[5]); + using MemoryStream destination = new(); + RecordedProgress progress = new(); + + await source.CopyToAsync(destination, progress, TestContext.Current.CancellationToken); + + progress.Reports.ShouldBe([(5L, 0L), (5L, 0L)]); + } + + [Fact] + public async Task CopyToAsync_UsesALargeBufferByDefault() + { + using MemoryStream source = new(new byte[100_000]); + using MemoryStream destination = new(); + RecordedProgress progress = new(); + + await source.CopyToAsync(100_000, destination, progress, TestContext.Current.CancellationToken); + + progress.Reports.First().ShouldBe((81920L, 100_000L)); + destination.Length.ShouldBe(100_000); + } + + [Fact] + public async Task CopyToAsync_OfAnEmptyStreamReportsOnce() + { + using MemoryStream source = new(); + using MemoryStream destination = new(); + RecordedProgress progress = new(); + + await source.CopyToAsync(destination, progress, TestContext.Current.CancellationToken); + + progress.Reports.ShouldBe([(0L, 0L)]); + } + + [Fact] + public async Task CopyToAsync_ChecksTheStreams() + { + using MemoryStream readable = new(new byte[1]); + using MemoryStream readOnly = new(new byte[1], false); + using MemoryStream closed = new(); + await closed.DisposeAsync(); + RecordedProgress progress = new(); + + (await Should.ThrowAsync(() => ((Stream) null!).CopyToAsync(1, readable, progress))).ParamName.ShouldBe("source"); + (await Should.ThrowAsync(() => closed.CopyToAsync(1, readable, progress))).ParamName.ShouldBe("source"); + (await Should.ThrowAsync(() => readable.CopyToAsync(1, null!, progress))).ParamName.ShouldBe("destination"); + (await Should.ThrowAsync(() => readable.CopyToAsync(1, readOnly, progress))).ParamName.ShouldBe("destination"); + } + + [Fact] + public async Task CopyToAsync_StopsWhenCancelled() + { + using MemoryStream source = new(new byte[10]); + using MemoryStream destination = new(); + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + + await Should.ThrowAsync(() => source.CopyToAsync(10, destination, new RecordedProgress(), cancellation.Token)); + destination.Length.ShouldBe(0); + } + + /// + /// Records progress as it is reported, unlike which reports on another thread + /// + private class RecordedProgress : IProgress<(long, long)> + { + public List<(long, long)> Reports { get; } = []; + + public void Report((long, long) value) + { + Reports.Add(value); + } + } +} diff --git a/src/Artemis.Core.Tests/Utilities/StringUtilitiesTests.cs b/src/Artemis.Core.Tests/Utilities/StringUtilitiesTests.cs new file mode 100644 index 000000000..b6abfbc8c --- /dev/null +++ b/src/Artemis.Core.Tests/Utilities/StringUtilitiesTests.cs @@ -0,0 +1,77 @@ +namespace Artemis.Core.Tests.Utilities; + +public class StringUtilitiesTests +{ + [Theory] + [InlineData("Hello World", "hello-world")] + [InlineData("my-profile_v2.final", "my-profile-v2-final")] + [InlineData(" Leading, and trailing. ", "leading-and-trailing")] + [InlineData("a -- b", "a-b")] + [InlineData("C# & .NET!", "c-net")] + [InlineData("Ünïcödé Çafé", "unicode-cafe")] + [InlineData("Straße", "strasse")] + [InlineData("Price: 5€", "price-5")] + [InlineData("日本", "")] + [InlineData("", "")] + public void UrlFriendly_TurnsTitlesIntoLowercaseWordsSeparatedByDashes(string title, string expected) + { + StringUtilities.UrlFriendly(title).ShouldBe(expected); + } + + [Fact] + public void UrlFriendly_OfNothingIsEmpty() + { + StringUtilities.UrlFriendly(null).ShouldBe(""); + } + + [Fact] + public void UrlFriendly_CutsLongTitlesShort() + { + // Pinned as-is: the limit is meant to be 80 characters, but the character at position 80 is still included + StringUtilities.UrlFriendly(new string('a', 100)).Length.ShouldBe(81); + } + + [Fact] + public void UrlFriendly_CountsSkippedCharactersTowardsTheLimit() + { + StringUtilities.UrlFriendly(new string('!', 81) + "abc").ShouldBe(""); + } + + [Theory] + [InlineData('é', "e")] + [InlineData('É', "e")] + [InlineData('å', "a")] + [InlineData('ø', "o")] + [InlineData('ü', "u")] + [InlineData('ç', "c")] + [InlineData('ž', "z")] + [InlineData('š', "s")] + [InlineData('ñ', "n")] + [InlineData('ÿ', "y")] + [InlineData('ğ', "g")] + [InlineData('ř', "r")] + [InlineData('ł', "l")] + [InlineData('đ', "d")] + [InlineData('ß', "ss")] + [InlineData('Þ', "th")] + [InlineData('ĥ', "h")] + [InlineData('ĵ', "j")] + [InlineData('€', "")] + [InlineData('a', "")] + public void RemapInternationalCharToAscii_GivesTheClosestPlainLetter(char c, string expected) + { + StringUtilities.RemapInternationalCharToAscii(c).ShouldBe(expected); + } + + [Theory] + [InlineData('Ř')] + [InlineData('Ł')] + [InlineData('Đ')] + [InlineData('þ')] + public void RemapInternationalCharToAscii_OnlyKnowsOneCaseOfSomeLetters(char c) + { + // Pinned as-is: most letters are lowercased before they are looked up, but ř, ł, đ, þ, ß, ĥ and ĵ are compared + // as typed, so their other case isn't recognized. For the thorn only the uppercase Þ is known + StringUtilities.RemapInternationalCharToAscii(c).ShouldBe(""); + } +} diff --git a/src/Artemis.Core.Tests/Utilities/TypeExtensionsTests.cs b/src/Artemis.Core.Tests/Utilities/TypeExtensionsTests.cs new file mode 100644 index 000000000..8a35dd6ef --- /dev/null +++ b/src/Artemis.Core.Tests/Utilities/TypeExtensionsTests.cs @@ -0,0 +1,233 @@ +using System.Collections; +using SkiaSharp; + +namespace Artemis.Core.Tests.Utilities; + +public class TypeExtensionsTests +{ + private class GenericBase; + + private class DerivedFromGeneric : GenericBase; + + private class DerivedTwice : DerivedFromGeneric; + + private class Celsius + { + public static implicit operator Fahrenheit(Celsius c) => new(); + } + + private class Fahrenheit + { + public static explicit operator Fahrenheit(Kelvin k) => new(); + } + + private class Kelvin; + + #region Kinds of types + + [Fact] + public void IsGenericType_ChecksTheBaseType() + { + typeof(DerivedFromGeneric).IsGenericType(typeof(GenericBase<>)).ShouldBeTrue(); + typeof(object).IsGenericType(typeof(GenericBase<>)).ShouldBeFalse(); + ((Type?) null).IsGenericType(typeof(GenericBase<>)).ShouldBeFalse(); + } + + [Fact] + public void IsGenericType_ThrowsWhenTheBaseTypeIsNotGeneric() + { + // Pinned as-is: asking for the generic definition of a base type that isn't generic throws instead of returning false. + // This includes types further down the line, only the direct base type is looked at + Should.Throw(() => typeof(string).IsGenericType(typeof(GenericBase<>))); + Should.Throw(() => typeof(DerivedTwice).IsGenericType(typeof(GenericBase<>))); + } + + [Theory] + [InlineData(typeof(SKColor), true)] + [InlineData(typeof(DateTime), true)] + [InlineData(typeof(int), false)] + [InlineData(typeof(KeyboardSection), false)] + [InlineData(typeof(string), false)] + public void IsStruct_OnlyForStructsThatAreNotNumbersOrEnums(Type type, bool expected) + { + type.IsStruct().ShouldBe(expected); + } + + [Theory] + [InlineData(typeof(sbyte), true)] + [InlineData(typeof(byte), true)] + [InlineData(typeof(short), true)] + [InlineData(typeof(ushort), true)] + [InlineData(typeof(int), true)] + [InlineData(typeof(uint), true)] + [InlineData(typeof(long), true)] + [InlineData(typeof(ulong), true)] + [InlineData(typeof(float), true)] + [InlineData(typeof(double), true)] + [InlineData(typeof(decimal), true)] + [InlineData(typeof(char), false)] + [InlineData(typeof(bool), false)] + [InlineData(typeof(int?), false)] + [InlineData(typeof(string), false)] + public void TypeIsNumber_ForEveryNumberType(Type type, bool expected) + { + type.TypeIsNumber().ShouldBe(expected); + } + + [Fact] + public void IsNumber_ForNumberValues() + { + ((object) 1).IsNumber().ShouldBeTrue(); + ((object) 1.5m).IsNumber().ShouldBeTrue(); + ((object) (byte) 1).IsNumber().ShouldBeTrue(); + ((object) 'a').IsNumber().ShouldBeFalse(); + ((object) "1").IsNumber().ShouldBeFalse(); + ((object?) null).IsNumber().ShouldBeFalse(); + } + + [Fact] + public void GetDefault_IsWhatDefaultGives() + { + typeof(int).GetDefault().ShouldBe(0); + typeof(SKColor).GetDefault().ShouldBe(default(SKColor)); + typeof(bool).GetDefault().ShouldBe(false); + typeof(string).GetDefault().ShouldBeNull(); + typeof(int?).GetDefault().ShouldBeNull(); + } + + #endregion + + #region Casting + + [Theory] + [InlineData(typeof(double), typeof(int), true)] + [InlineData(typeof(int), typeof(double), true)] + [InlineData(typeof(object), typeof(string), true)] + [InlineData(typeof(IEnumerable), typeof(string), true)] + [InlineData(typeof(int), typeof(char), true)] + [InlineData(typeof(char), typeof(int), false)] + [InlineData(typeof(string), typeof(int), false)] + [InlineData(typeof(string), typeof(object), false)] + public void IsCastableFrom_ForNumbersAssignableTypesAndKnownConversions(Type to, Type from, bool expected) + { + to.IsCastableFrom(from).ShouldBe(expected); + } + + [Fact] + public void IsCastableFrom_OnlyFindsOperatorsDeclaredOnTheSourceType() + { + typeof(Fahrenheit).IsCastableFrom(typeof(Celsius)).ShouldBeTrue(); + + // Pinned as-is: Fahrenheit declares a conversion from Kelvin, but only the operators of Kelvin are looked at + typeof(Fahrenheit).IsCastableFrom(typeof(Kelvin)).ShouldBeFalse(); + } + + [Theory] + [InlineData(typeof(int), typeof(int), 5)] + [InlineData(typeof(double), typeof(int), 4)] + [InlineData(typeof(int), typeof(double), 4)] + [InlineData(typeof(int), typeof(char), 3)] + [InlineData(typeof(object), typeof(string), 1)] + [InlineData(typeof(string), typeof(int), 0)] + [InlineData(typeof(string), null, 0)] + public void ScoreCastability_RatesHowWellTypesConvert(Type to, Type? from, int expected) + { + to.ScoreCastability(from).ShouldBe(expected); + } + + [Fact] + public void ScoreCastability_RatesOperatorsAboveInheritance() + { + typeof(Fahrenheit).ScoreCastability(typeof(Celsius)).ShouldBe(2); + typeof(GenericBase).ScoreCastability(typeof(DerivedFromGeneric)).ShouldBe(1); + } + + #endregion + + #region Enumerables and generics + + [Theory] + [InlineData(typeof(List), true)] + [InlineData(typeof(IEnumerable), true)] + [InlineData(typeof(int[]), true)] + [InlineData(typeof(Dictionary), true)] + [InlineData(typeof(ArrayList), false)] + [InlineData(typeof(int), false)] + [InlineData(typeof(string), false)] + public void IsGenericEnumerable_ForGenericCollectionsButNotText(Type type, bool expected) + { + type.IsGenericEnumerable().ShouldBe(expected); + } + + [Theory] + [InlineData(typeof(List), typeof(int))] + [InlineData(typeof(IEnumerable), typeof(string))] + [InlineData(typeof(SKColor[]), typeof(SKColor))] + [InlineData(typeof(Dictionary), typeof(KeyValuePair))] + [InlineData(typeof(ArrayList), null)] + [InlineData(typeof(int), null)] + public void GetGenericEnumerableType_GivesTheItemType(Type type, Type? expected) + { + type.GetGenericEnumerableType().ShouldBe(expected); + } + + [Fact] + public void GetGenericEnumerableType_OfTextIsCharacters() + { + // Unlike IsGenericEnumerable this doesn't make an exception for text + typeof(string).GetGenericEnumerableType().ShouldBe(typeof(char)); + } + + [Theory] + [InlineData(typeof(List), typeof(List<>), true)] + [InlineData(typeof(List), typeof(IEnumerable<>), true)] + [InlineData(typeof(List<>), typeof(List<>), true)] + [InlineData(typeof(DerivedTwice), typeof(GenericBase<>), true)] + [InlineData(typeof(int[]), typeof(IReadOnlyList<>), true)] + [InlineData(typeof(List), typeof(Dictionary<,>), false)] + [InlineData(typeof(string), typeof(List<>), false)] + [InlineData(typeof(object), typeof(List<>), false)] + public void IsOfGenericType_LooksAtBaseTypesAndInterfaces(Type type, Type genericType, bool expected) + { + type.IsOfGenericType(genericType).ShouldBe(expected); + } + + [Fact] + public void IsOfGenericType_NeedsAGenericDefinition() + { + Should.Throw(() => typeof(List).IsOfGenericType(typeof(List))); + } + + [Theory] + [InlineData(typeof(int), "int")] + [InlineData(typeof(string), "string")] + [InlineData(typeof(object), "object")] + [InlineData(typeof(int?), "int?")] + [InlineData(typeof(SKColor), "SKColor")] + [InlineData(typeof(List), "List")] + [InlineData(typeof(Dictionary>), "Dictionary>")] + public void GetDisplayName_UsesKeywordsAndReadableGenerics(Type type, string expected) + { + type.GetDisplayName().ShouldBe(expected); + } + + [Theory] + [InlineData(typeof(ColorGradient), "Color gradient")] + [InlineData(typeof(List), "List")] + [InlineData(typeof(int), "Int")] + public void GetDisplayName_CanBeHumanized(Type type, string expected) + { + type.GetDisplayName(true).ShouldBe(expected); + } + + [Fact] + public void GetDisplayName_OfAnArrayLosesItsBracketsWhenHumanized() + { + typeof(ColorGradient[]).GetDisplayName().ShouldBe("ColorGradient[]"); + + // Pinned as-is: humanizing drops the brackets, so a list of gradients reads the same as a single gradient + typeof(ColorGradient[]).GetDisplayName(true).ShouldBe("Color gradient"); + } + + #endregion +} diff --git a/src/Artemis.Core.Tests/VisualScripting/DataBindingNodeScriptTests.cs b/src/Artemis.Core.Tests/VisualScripting/DataBindingNodeScriptTests.cs new file mode 100644 index 000000000..380d3ae19 --- /dev/null +++ b/src/Artemis.Core.Tests/VisualScripting/DataBindingNodeScriptTests.cs @@ -0,0 +1,152 @@ +using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes; +using Artemis.Core.Tests.Infrastructure.Rendering; +using Artemis.Core.Tests.Infrastructure.VisualScripting; +using SkiaSharp; + +namespace Artemis.Core.Tests.VisualScripting; + +/// +/// The basics of the scripts behind data bindings. Data bindings themselves are tested with the rest of the profile. +/// +public class DataBindingNodeScriptTests : ScriptTestBase +{ + private readonly RenderHarness _harness = new(); + private readonly Profile _profile; + private readonly Layer _layer; + + public DataBindingNodeScriptTests() + { + _profile = _harness.CreateProfile(); + (_layer, _) = _harness.AddLayer(_profile.GetRootFolder(), []); + } + + private DataBinding PositionBinding => _layer.Transform.Position.DataBinding; + + [Fact] + public void Script_HasAnInputPerDataBindingProperty() + { + INodeScript script = PositionBinding.Script; + + script.ShouldBeOfType>(); + script.ResultType.ShouldBe(typeof(object)); + script.Name.ShouldBe(_layer.Transform.Position.PropertyDescription.Name); + script.Context.ShouldBe(_profile); + + INode exitNode = ExitNode(script); + exitNode.Pins.Select(p => (p.Name, p.Type, p.Direction)).ShouldBe([ + ("X", typeof(Numeric), PinDirection.Input), + ("Y", typeof(Numeric), PinDirection.Input) + ]); + } + + [Fact] + public void Script_ForAPropertyThatIsNotANumber_UsesThePropertyType() + { + SolidColorTestBrush brush = (SolidColorTestBrush) _layer.LayerBrush!; + + INode exitNode = ExitNode(brush.Properties.Color.DataBinding.Script); + + exitNode.Pins.ShouldHaveSingleItem().Type.ShouldBe(typeof(SKColor)); + } + + [Fact] + public void ExitNodeConnected_IsTrueOnceAnyInputIsConnected() + { + NodeScript script = (NodeScript) PositionBinding.Script; + script.ExitNodeConnected.ShouldBeFalse(); + + TestNumericNode number = Nodes.AddNode(script); + ExitNode(script).Pins.Last().ConnectTo(number.Output); + + script.ExitNodeConnected.ShouldBeTrue(); + } + + [Fact] + public void RunningTheScript_SetsOnlyTheConnectedProperties() + { + _layer.Transform.Position.CurrentValue = new SKPoint(1, 2); + TestNumericNode number = Nodes.AddNode(PositionBinding.Script); + number.Value = 42; + ExitNode(PositionBinding.Script).Pins.First().ConnectTo(number.Output); + PositionBinding.IsEnabled = true; + + PositionBinding.Update(); + PositionBinding.Apply(); + + _layer.Transform.Position.CurrentValue.ShouldBe(new SKPoint(42, 2)); + } + + [Fact] + public void DisabledDataBinding_DoesNotRunItsScript() + { + _layer.Transform.Position.CurrentValue = new SKPoint(1, 2); + TestNumericNode number = Nodes.AddNode(PositionBinding.Script); + number.Value = 42; + ExitNode(PositionBinding.Script).Pins.First().ConnectTo(number.Output); + + PositionBinding.Update(); + PositionBinding.Apply(); + + number.Evaluations.ShouldBe(0); + _layer.Transform.Position.CurrentValue.ShouldBe(new SKPoint(1, 2)); + } + + [Fact] + public void ClearingTheDataBindingProperties_RemovesTheInputs() + { + INode exitNode = ExitNode(PositionBinding.Script); + IPin x = exitNode.Pins.First(); + TestNumericNode number = Nodes.AddNode(PositionBinding.Script); + x.ConnectTo(number.Output); + + PositionBinding.ClearDataBindingProperties(); + + exitNode.Pins.ShouldBeEmpty(); + number.Output.ConnectedTo.ShouldBeEmpty(); + PositionBinding.Script.ShouldBeAssignableTo()!.ExitNodeConnected.ShouldBeFalse(); + } + + [Fact] + public void RegisteringADataBindingProperty_RecreatesTheInputs() + { + INode exitNode = ExitNode(PositionBinding.Script); + + PositionBinding.RegisterDataBindingProperty(() => 1, _ => { }, "Extra"); + + exitNode.Pins.Select(p => p.Name).ShouldBe(["X", "Y", "Extra"]); + exitNode.Pins.Last().Type.ShouldBe(typeof(Numeric)); + } + + [Fact] + public void Script_SurvivesSavingAndLoadingTheProfile() + { + TestNumericNode number = Nodes.AddNode(PositionBinding.Script); + number.Value = 42; + number.X = 10; + ExitNode(PositionBinding.Script).Pins.Last().ConnectTo(number.Output); + PositionBinding.IsEnabled = true; + + Profile loaded = _harness.SaveAndReload(_profile); + Layer loadedLayer = loaded.GetAllLayers().Single(); + DataBinding loadedBinding = loadedLayer.Transform.Position.DataBinding; + loadedBinding.Update(); + loadedBinding.Apply(); + + TestNumericNode loadedNumber = loadedBinding.Script.Nodes.OfType().Single(); + loadedNumber.Value.ShouldBe(42); + loadedNumber.X.ShouldBe(10); + loadedBinding.IsEnabled.ShouldBeTrue(); + loadedLayer.Transform.Position.CurrentValue.Y.ShouldBe(42); + } + + public override void Dispose() + { + _harness.Dispose(); + base.Dispose(); + } + + private static INode ExitNode(INodeScript script) + { + return script.Nodes.Single(n => n.IsExitNode); + } +} diff --git a/src/Artemis.Core.Tests/VisualScripting/EventConditionStartNodeTests.cs b/src/Artemis.Core.Tests/VisualScripting/EventConditionStartNodeTests.cs new file mode 100644 index 000000000..8e539a52a --- /dev/null +++ b/src/Artemis.Core.Tests/VisualScripting/EventConditionStartNodeTests.cs @@ -0,0 +1,137 @@ +using Artemis.Core.Internal; +using Artemis.Core.Tests.Infrastructure.Plugins.Modules; +using Artemis.Core.Tests.Infrastructure.VisualScripting; + +namespace Artemis.Core.Tests.VisualScripting; + +/// +/// The start nodes event conditions put in their scripts, tested on their own +/// +public class EventConditionStartNodeTests : ScriptTestBase +{ + private readonly TestModule _module; + + public EventConditionStartNodeTests() + { + _module = Plugin.AddFeature(new TestModule()); + } + + [Fact] + public void EventStartNode_IsADefaultNode() + { + EventConditionEventStartNode node = new(); + + node.IsDefaultNode.ShouldBeTrue(); + node.Id.ShouldBe(EventConditionEventStartNode.NodeId); + node.Name.ShouldBe("Event Arguments"); + node.Pins.ShouldBeEmpty(); + } + + [Fact] + public void EventStartNode_CreatesAPinPerEventArgument() + { + EventConditionEventStartNode node = new(); + + node.CreatePins(_module.DataModel.EventWithArgs); + + // The trigger time of the arguments is ignored + OutputPin pin = node.Pins.ShouldHaveSingleItem().ShouldBeOfType(); + pin.Name.ShouldBe("Value"); + pin.Type.ShouldBe(typeof(Numeric)); + } + + [Fact] + public void EventStartNode_OutputsTheArgumentsOfTheLastEvent() + { + EventConditionEventStartNode node = new(); + node.CreatePins(_module.DataModel.EventWithArgs); + OutputPin pin = (OutputPin) node.Pins.Single(); + pin.ConnectTo(new TestNode().CreateInputPin()); + + _module.DataModel.EventWithArgs.Trigger(new TestEventArgs {Value = 4}); + node.Evaluate(); + + pin.Value.ShouldBe(new Numeric(4)); + } + + [Fact] + public void EventStartNode_BeforeAnyEvent_OutputsNothing() + { + EventConditionEventStartNode node = new(); + node.CreatePins(_module.DataModel.EventWithArgs); + + node.Evaluate(); + + node.Pins.Single().IsEvaluated.ShouldBeFalse(); + } + + [Fact] + public void EventStartNode_ForAnotherEvent_ReplacesItsPins() + { + EventConditionEventStartNode node = new(); + node.CreatePins(_module.DataModel.EventWithArgs); + + node.CreatePins(_module.DataModel.Event); + node.Pins.ShouldBeEmpty(); + + node.CreatePins(null); + node.Pins.ShouldBeEmpty(); + } + + [Fact] + public void ValueChangedStartNode_IsADefaultNodeWithTwoObjectPins() + { + EventConditionValueChangedStartNode node = new(); + + node.IsDefaultNode.ShouldBeTrue(); + node.Id.ShouldBe(EventConditionValueChangedStartNode.NodeId); + node.Pins.ShouldBe([node.NewValue, node.OldValue]); + node.NewValue.Type.ShouldBe(typeof(object)); + node.OldValue.Type.ShouldBe(typeof(object)); + } + + [Theory] + [InlineData("Integer", typeof(Numeric))] + [InlineData("Float", typeof(Numeric))] + [InlineData("Text", typeof(string))] + [InlineData("Boolean", typeof(bool))] + [InlineData("DoesNotExist", typeof(object))] + public void ValueChangedStartNode_TakesTheTypeOfThePath(string path, Type expected) + { + EventConditionValueChangedStartNode node = new(); + using DataModelPath dataModelPath = new(_module.DataModel, path); + + node.UpdateOutputPins(dataModelPath); + + node.NewValue.Type.ShouldBe(expected); + node.OldValue.Type.ShouldBe(expected); + } + + [Fact] + public void ValueChangedStartNode_OutputsNumbersAsNumeric() + { + EventConditionValueChangedStartNode node = new(); + using DataModelPath dataModelPath = new(_module.DataModel, "Integer"); + node.UpdateOutputPins(dataModelPath); + + node.UpdateValues(5, 3); + node.Evaluate(); + + node.NewValue.Value.ShouldBe(new Numeric(5)); + node.OldValue.Value.ShouldBe(new Numeric(3)); + } + + [Fact] + public void ValueChangedStartNode_OutputsOtherValuesAsTheyAre() + { + EventConditionValueChangedStartNode node = new(); + using DataModelPath dataModelPath = new(_module.DataModel, "Text"); + node.UpdateOutputPins(dataModelPath); + + node.UpdateValues("new", null); + node.Evaluate(); + + node.NewValue.Value.ShouldBe("new"); + node.OldValue.Value.ShouldBeNull(); + } +} diff --git a/src/Artemis.Core.Tests/VisualScripting/NodeDataTests.cs b/src/Artemis.Core.Tests/VisualScripting/NodeDataTests.cs new file mode 100644 index 000000000..b977a2a76 --- /dev/null +++ b/src/Artemis.Core.Tests/VisualScripting/NodeDataTests.cs @@ -0,0 +1,160 @@ +using Artemis.Core.Tests.Infrastructure.Plugins; +using Artemis.Core.Tests.Infrastructure.VisualScripting; +using Artemis.Storage.Entities.Profile.Nodes; + +namespace Artemis.Core.Tests.VisualScripting; + +public class NodeDataTests : ScriptTestBase +{ + [Fact] + public void Registering_ReadsTheNodeAttribute() + { + NodeData sum = Data(); + sum.Provider.ShouldBe(ScriptNodes); + sum.Type.ShouldBe(typeof(TestSumNode)); + sum.Name.ShouldBe("Test sum"); + sum.Description.ShouldBe("Adds up its inputs"); + sum.Category.ShouldBe("Tests"); + sum.HelpUrl.ShouldBeEmpty(); + sum.InputType.ShouldBe(typeof(Numeric)); + sum.OutputType.ShouldBe(typeof(Numeric)); + + NodeData settings = Data(); + settings.HelpUrl.ShouldBe("https://example.com/node-from-attribute"); + settings.InputType.ShouldBeNull(); + settings.OutputType.ShouldBeNull(); + + NodeData text = Data(); + text.Name.ShouldBe("Test text"); + text.Description.ShouldBe("Outputs text controlled by the test"); + text.Category.ShouldBeEmpty(); + + NodeData viewModel = Data(); + viewModel.Name.ShouldBe("Test view model"); + viewModel.Description.ShouldBeEmpty(); + } + + [Fact] + public void Registering_AddsTheNodeToTheNodeTypeStore() + { + NodeTypeStore.GetAll().ShouldContain(Data()); + } + + [Fact] + public void DisablingTheProvider_RemovesItsNodesFromTheNodeTypeStore() + { + TestPlugin otherPlugin = new(); + ScriptTestNodeProvider otherNodes = otherPlugin.AddFeature(new ScriptTestNodeProvider()); + NodeData sum = otherNodes.NodeDescriptors.Single(d => d.Type == typeof(TestSumNode)); + NodeTypeStore.GetAll().ShouldContain(sum); + + otherPlugin.Dispose(); + + NodeTypeStore.GetAll().ShouldNotContain(sum); + otherNodes.NodeDescriptors.ShouldBeEmpty(); + } + + [Fact] + public void CreateNode_FillsInWhatTheNodeDoesNotSetItself() + { + NodeScript script = CreateScript(); + + TestSettingsNode settings = (TestSettingsNode) Data().CreateNode(script, null); + TestSumNode sum = (TestSumNode) Data().CreateNode(script, null); + + settings.Name.ShouldBe("Own name"); + settings.Description.ShouldBe("Stores settings"); + settings.HelpUrl.ShouldBe("https://example.com/own-help"); + settings.NodeData.ShouldBe(Data()); + sum.Name.ShouldBe("Test sum"); + sum.Description.ShouldBe("Adds up its inputs"); + } + + [Fact] + public void CreateNode_DoesNotAddTheNodeToTheScript() + { + NodeScript script = CreateScript(); + + INode node = Data().CreateNode(script, null); + + script.Nodes.ShouldNotContain(node); + node.IsLoading.ShouldBeFalse(); + } + + [Fact] + public void CreateNode_FromAnEntity_AppliesPositionAndStorage() + { + NodeScript script = CreateScript(); + NodeEntity entity = new() {X = 3, Y = 4, Storage = "\"Hello\""}; + + TestTextNode node = (TestTextNode) Data().CreateNode(script, entity); + + node.X.ShouldBe(3); + node.Y.ShouldBe(4); + node.Value.ShouldBe("Hello"); + node.IsLoading.ShouldBeTrue(); + } + + [Fact] + public void CreateNode_FromAnEntity_DoesNotTakeTheIdOfTheEntity() + { + // The script applies the ID afterwards + NodeEntity entity = new() {Id = Guid.NewGuid()}; + + INode node = Data().CreateNode(CreateScript(), entity); + + node.Id.ShouldNotBe(entity.Id); + } + + [Fact] + public void IsCompatibleWithPin_WithoutAPin_IsAlwaysTrue() + { + Data().IsCompatibleWithPin(null).ShouldBeTrue(); + } + + [Fact] + public void IsCompatibleWithPin_ComparesTheOutputOfTheNodeWithAnInputPin() + { + TestNode node = new(); + InputPin numericInput = node.CreateInputPin(); + InputPin textInput = node.CreateInputPin(); + + Data().IsCompatibleWithPin(numericInput).ShouldBeTrue(); + Data().IsCompatibleWithPin(textInput).ShouldBeFalse(); + Data().IsCompatibleWithPin(numericInput).ShouldBeFalse(); + } + + [Fact] + public void IsCompatibleWithPin_ComparesTheInputOfTheNodeWithAnOutputPin() + { + TestNode node = new(); + OutputPin numericOutput = node.CreateOutputPin(); + OutputPin textOutput = node.CreateOutputPin(); + + Data().IsCompatibleWithPin(numericOutput).ShouldBeTrue(); + Data().IsCompatibleWithPin(textOutput).ShouldBeFalse(); + Data().IsCompatibleWithPin(numericOutput).ShouldBeFalse(); + } + + [Theory] + [InlineData("sum", true)] + [InlineData("SUM", true)] + [InlineData(" sum ", true)] + [InlineData("adds up", true)] + [InlineData("tests", true)] + [InlineData("multiply", false)] + [InlineData("!test", true)] + [InlineData("!sum", false)] + [InlineData("!!test sum", true)] + [InlineData("!!test", false)] + public void MatchesSearch_LooksAtNameDescriptionAndCategory(string search, bool matches) + { + // A single ! only matches the start of the name, !! only the whole name + Data().MatchesSearch(search).ShouldBe(matches); + } + + private NodeData Data() + { + return NodeTypeStore.GetAll().Single(d => d.Type == typeof(T) && (d.Provider == ScriptNodes || d.Provider == Nodes)); + } +} diff --git a/src/Artemis.Core.Tests/VisualScripting/NodeExtensionTests.cs b/src/Artemis.Core.Tests/VisualScripting/NodeExtensionTests.cs new file mode 100644 index 000000000..649a91659 --- /dev/null +++ b/src/Artemis.Core.Tests/VisualScripting/NodeExtensionTests.cs @@ -0,0 +1,139 @@ +using Artemis.Core.Tests.Infrastructure.VisualScripting; + +namespace Artemis.Core.Tests.VisualScripting; + +public class NodeExtensionTests : ScriptTestBase +{ + #region Size estimates + + [Fact] + public void EstimateHeight_OfANodeWithoutPins_IsTheTitle() + { + new TestNode().EstimateHeight().ShouldBe(46); + } + + [Fact] + public void EstimateHeight_CountsTheSideWithTheMostPins() + { + TestNode node = new(); + node.CreateInputPin(); + node.CreateOutputPin(); + node.CreateOutputPin(); + + node.EstimateHeight().ShouldBe(46 + 2 * 26); + } + + [Fact] + public void EstimateHeight_CountsAnExtraRowPerPinCollection() + { + // Two pins in the collection plus a row for the collection itself + new TestSumNode().EstimateHeight().ShouldBe(46 + 3 * 26); + } + + [Fact] + public void EstimateWidth_IsTheSameForEveryNode() + { + new TestNode().EstimateWidth().ShouldBe(120); + new TestSumNode().EstimateWidth().ShouldBe(120); + } + + #endregion + + #region Loops + + [Fact] + public void IsInLoop_ConnectingANodeToItself_IsTrue() + { + TestSumNode node = new(); + + node.IsInLoop(node).ShouldBeTrue(); + } + + [Fact] + public void IsInLoop_ForUnrelatedNodes_IsFalse() + { + TestSumNode first = new(); + TestSumNode second = new(); + + first.IsInLoop(second).ShouldBeFalse(); + } + + [Fact] + public void IsInLoop_LooksThroughSeveralNodes() + { + // a -> b -> c, connecting c back into a would make a loop + TestSumNode a = new(); + TestSumNode b = new(); + TestSumNode c = new(); + b.Values.Pins.First().ConnectTo(a.Result); + c.Values.Pins.First().ConnectTo(b.Result); + + a.IsInLoop(c).ShouldBeTrue(); + c.IsInLoop(a).ShouldBeFalse(); + } + + [Fact] + public void IsInLoop_LooksThroughPlainInputPinsToo() + { + TestNode a = new(); + OutputPin output = a.CreateOutputPin(); + TestNode b = new(); + b.CreateInputPin().ConnectTo(output); + + a.IsInLoop(b).ShouldBeTrue(); + b.IsInLoop(a).ShouldBeFalse(); + } + + #endregion + + #region Organize + + [Fact] + public void Organize_PutsNodesInColumnsLeftOfTheExitNode() + { + NodeScript script = CreateScript(); + TestNumericNode first = Nodes.AddNode(script); + TestNumericNode second = Nodes.AddNode(script); + TestSumNode sum = ScriptNodes.AddNode(script); + TestNumericNode loose = Nodes.AddNode(script); + sum.Values.Pins.ElementAt(0).ConnectTo(first.Output); + sum.Values.Pins.ElementAt(1).ConnectTo(second.Output); + script.GetExitNode().Input.ConnectTo(sum.Result); + + script.Organize(); + + // Columns are 120 wide with 160 between them, each column is centered vertically around 0 with 20 between nodes + Position(script.GetExitNode()).ShouldBe((0, -36)); + Position(sum).ShouldBe((-280, -62)); + Position(first).ShouldBe((-560, -82)); + Position(second).ShouldBe((-560, 10)); + // Nodes that don't lead to the exit node go to its right + Position(loose).ShouldBe((200, -36)); + } + + [Fact] + public void Organize_PutsANodeInTheColumnOfItsFurthestUse() + { + NodeScript script = CreateScript(); + TestNumericNode number = Nodes.AddNode(script); + TestSumNode inner = ScriptNodes.AddNode(script); + TestSumNode outer = ScriptNodes.AddNode(script); + inner.Values.Pins.ElementAt(0).ConnectTo(number.Output); + outer.Values.Pins.ElementAt(0).ConnectTo(inner.Result); + outer.Values.Pins.ElementAt(1).ConnectTo(number.Output); + script.GetExitNode().Input.ConnectTo(outer.Result); + + script.Organize(); + + outer.X.ShouldBe(-280); + inner.X.ShouldBe(-560); + number.X.ShouldBe(-840); + } + + private static (double X, double Y) Position(INode node) + { + return (node.X, node.Y); + } + + #endregion +} diff --git a/src/Artemis.Core.Tests/VisualScripting/NodeScriptStorageTests.cs b/src/Artemis.Core.Tests/VisualScripting/NodeScriptStorageTests.cs new file mode 100644 index 000000000..c418ea974 --- /dev/null +++ b/src/Artemis.Core.Tests/VisualScripting/NodeScriptStorageTests.cs @@ -0,0 +1,432 @@ +using Artemis.Core.Tests.Infrastructure.Plugins; +using Artemis.Core.Tests.Infrastructure.VisualScripting; +using Artemis.Storage.Entities.Profile.Nodes; + +namespace Artemis.Core.Tests.VisualScripting; + +public class NodeScriptStorageTests : ScriptTestBase +{ + [Fact] + public void Save_DescribesEveryNode() + { + NodeScript script = CreateScript("My script"); + TestBooleanNode node = Nodes.AddNode(script); + node.X = 10; + node.Y = 20; + node.Value = true; + + script.Save(); + + script.Entity.Name.ShouldBe("My script"); + script.Entity.Description.ShouldBe("A test script"); + script.Entity.Nodes.Count.ShouldBe(2); + + NodeEntity exitEntity = script.Entity.Nodes.Single(n => n.IsExitNode); + exitEntity.Id.ShouldBe(script.GetExitNode().Id); + exitEntity.ProviderId.ShouldBe(Constants.CorePluginFeature.Id); + exitEntity.Type.ShouldBe("ExitNode`1"); + + NodeEntity nodeEntity = script.Entity.Nodes.Single(n => !n.IsExitNode); + nodeEntity.Id.ShouldBe(node.Id); + nodeEntity.ProviderId.ShouldBe(Nodes.Id); + nodeEntity.Type.ShouldBe(nameof(TestBooleanNode)); + nodeEntity.Name.ShouldBe("Test boolean"); + nodeEntity.Description.ShouldBe("Outputs a boolean controlled by the test"); + nodeEntity.X.ShouldBe(10); + nodeEntity.Y.ShouldBe(20); + nodeEntity.Storage.ShouldBe("true"); + } + + [Fact] + public void Save_DescribesConnectionsFromTheInputSide() + { + NodeScript script = CreateScript(); + TestNumericNode number = Nodes.AddNode(script); + TestSumNode sum = ScriptNodes.AddNode(script); + sum.Values.Pins.ElementAt(1).ConnectTo(number.Output); + script.GetExitNode().Input.ConnectTo(sum.Result); + + script.Save(); + + script.Entity.Connections.Count.ShouldBe(2); + + // The input pin is the source, the output pin it reads from is the target. -1 means the pin isn't in a collection. + NodeConnectionEntity intoExit = script.Entity.Connections.Single(c => c.SourceNode == script.GetExitNode().Id); + intoExit.SourcePinCollectionId.ShouldBe(-1); + intoExit.SourcePinId.ShouldBe(0); + intoExit.SourceType.ShouldBe(nameof(Numeric)); + intoExit.TargetNode.ShouldBe(sum.Id); + intoExit.TargetPinCollectionId.ShouldBe(-1); + intoExit.TargetPinId.ShouldBe(0); + + NodeConnectionEntity intoSum = script.Entity.Connections.Single(c => c.SourceNode == sum.Id); + intoSum.SourcePinCollectionId.ShouldBe(0); + intoSum.SourcePinId.ShouldBe(1); + intoSum.TargetNode.ShouldBe(number.Id); + intoSum.TargetPinCollectionId.ShouldBe(-1); + intoSum.TargetPinId.ShouldBe(0); + } + + [Fact] + public void Save_StoresHowManyPinsEachCollectionHas() + { + NodeScript script = CreateScript(); + TestSumNode sum = ScriptNodes.AddNode(script); + sum.Values.Add(sum.Values.CreatePin()); + + script.Save(); + + NodePinCollectionEntity collection = script.Entity.Nodes.Single(n => n.Id == sum.Id).PinCollections.ShouldHaveSingleItem(); + collection.Id.ShouldBe(0); + collection.Direction.ShouldBe((int) PinDirection.Input); + collection.Amount.ShouldBe(3); + } + + [Fact] + public void NodePositions_SurviveSavingAndLoading() + { + NodeScript script = CreateScript(); + TestBooleanNode node = Nodes.AddNode(script); + node.X = 120.5; + node.Y = -40; + script.GetExitNode().X = 300; + script.GetExitNode().Y = 15; + + NodeScript loaded = SaveAndLoad(script); + + TestBooleanNode loadedNode = loaded.Nodes.OfType().Single(); + loadedNode.Id.ShouldBe(node.Id); + loadedNode.X.ShouldBe(120.5); + loadedNode.Y.ShouldBe(-40); + loaded.GetExitNode().X.ShouldBe(300); + loaded.GetExitNode().Y.ShouldBe(15); + } + + [Fact] + public void LoadedNodes_AreNoLongerMarkedAsLoading() + { + NodeScript script = CreateScript(); + Nodes.AddNode(script); + + NodeScript loaded = SaveAndLoad(script); + + loaded.Nodes.ShouldAllBe(n => !n.IsLoading); + } + + [Fact] + public void LoadedNodes_GetTheirNodeData() + { + NodeScript script = CreateScript(); + Nodes.AddNode(script); + + NodeScript loaded = SaveAndLoad(script); + + NodeData nodeData = loaded.Nodes.OfType().Single().NodeData.ShouldNotBeNull(); + nodeData.Provider.ShouldBe(Nodes); + } + + [Fact] + public void PinCollections_AndTheirConnections_SurviveSavingAndLoading() + { + NodeScript script = CreateScript(); + TestNumericNode number = Nodes.AddNode(script); + TestSumNode sum = ScriptNodes.AddNode(script); + sum.Values.Add(sum.Values.CreatePin()); + sum.Values.Add(sum.Values.CreatePin()); + sum.Values.Pins.ElementAt(3).ConnectTo(number.Output); + script.GetExitNode().Input.ConnectTo(sum.Result); + number.Value = 6; + + NodeScript loaded = SaveAndLoad(script); + loaded.Run(); + + TestSumNode loadedSum = loaded.Nodes.OfType().Single(); + loadedSum.Values.Count().ShouldBe(4); + loadedSum.Values.Pins.ElementAt(3).ConnectedTo.ShouldHaveSingleItem().Node.ShouldBeOfType(); + loaded.Result.ShouldBe(new Numeric(6)); + } + + [Fact] + public void ObjectStorage_SurvivesSavingAndLoading() + { + NodeScript script = CreateScript(); + TestSettingsNode node = ScriptNodes.AddNode(script); + node.Storage = new TestNodeSettings {Name = "Settings", Amount = 1.5, Numbers = [1, 2, 3]}; + + NodeScript loaded = SaveAndLoad(script); + + TestNodeSettings settings = loaded.Nodes.OfType().Single().Storage.ShouldNotBeNull(); + settings.Name.ShouldBe("Settings"); + settings.Amount.ShouldBe(1.5); + settings.Numbers.ShouldBe([1, 2, 3]); + } + + [Fact] + public void NodeWithUnreadableStorage_StillLoads_WithEmptyStorage() + { + NodeScript script = CreateScript(); + TestBooleanNode node = Nodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + node.Value = true; + script.Save(); + script.Entity.Nodes.Single(n => n.Id == node.Id).Storage = "{not json"; + + NodeScript loaded = Load(script.Entity); + loaded.Run(); + + loaded.Nodes.OfType().Single().Value.ShouldBeFalse(); + loaded.ExitNodeConnected.ShouldBeTrue(); + loaded.Result.ShouldBeFalse(); + } + + [Fact] + public void NodeThatFailsToInitialize_StillLoads_ButIsBroken() + { + NodeScript script = CreateScript(); + TestThrowingNode node = ScriptNodes.AddNode(script); + node.FailsOn = TestThrowingNode.FailureMode.Initialize; + + NodeScript loaded = SaveAndLoad(script); + + TestThrowingNode loadedNode = loaded.Nodes.OfType().Single(); + loadedNode.BrokenState.ShouldBe("Failed to initialize"); + loadedNode.BrokenStateException.ShouldBeOfType(); + } + + [Fact] + public void NodesOfAMissingPlugin_AreLeftOut_AndTheirConnectionsDropped() + { + TestPlugin missingPlugin = new(); + ScriptTestNodeProvider missingNodes = missingPlugin.AddFeature(new ScriptTestNodeProvider()); + NodeScript script = CreateScript(); + TestTextNode node = missingNodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + node.Value = "Hello"; + script.Save(); + missingPlugin.Dispose(); + + NodeScript loaded = Load(script.Entity); + loaded.Run(); + + loaded.Nodes.ShouldHaveSingleItem().IsExitNode.ShouldBeTrue(); + loaded.ExitNodeConnected.ShouldBeFalse(); + loaded.Result.ShouldBeNull(); + } + + [Fact] + public void NodesOfAMissingPlugin_AreLostWhenTheScriptIsSavedAgain() + { + // Pinned as-is: a script loaded while a plugin is missing forgets that plugin's nodes the next time it is saved. + // Saving a profile while a plugin is disabled permanently removes its nodes from the profile's scripts. + TestPlugin missingPlugin = new(); + ScriptTestNodeProvider missingNodes = missingPlugin.AddFeature(new ScriptTestNodeProvider()); + NodeScript script = CreateScript(); + TestTextNode node = missingNodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + script.Save(); + missingPlugin.Dispose(); + + NodeScript loaded = Load(script.Entity); + loaded.Entity.Nodes.Count.ShouldBe(2); + loaded.Save(); + + loaded.Entity.Nodes.ShouldHaveSingleItem().IsExitNode.ShouldBeTrue(); + loaded.Entity.Connections.ShouldBeEmpty(); + } + + [Fact] + public void NodesOfAMissingPlugin_AppearOnceThePluginIsEnabled() + { + // Save the script with nodes from one plugin, then point the saved nodes at a plugin that isn't enabled yet, as + // if the profile came from a machine where that plugin is installed + TestPlugin savingPlugin = new(); + ScriptTestNodeProvider savingNodes = savingPlugin.AddFeature(new ScriptTestNodeProvider()); + NodeScript script = CreateScript(); + TestTextNode node = savingNodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + node.Value = "Hello"; + script.Save(); + savingPlugin.Dispose(); + + TestPlugin laterPlugin = new(); + foreach (NodeEntity nodeEntity in script.Entity.Nodes.Where(n => n.ProviderId == ScriptTestNodeProvider.IdFor(savingPlugin))) + nodeEntity.ProviderId = ScriptTestNodeProvider.IdFor(laterPlugin); + NodeScript loaded = Load(script.Entity); + loaded.Nodes.Count().ShouldBe(1); + + try + { + laterPlugin.AddFeature(new ScriptTestNodeProvider()); + loaded.Run(); + + loaded.Nodes.OfType().ShouldHaveSingleItem().Value.ShouldBe("Hello"); + loaded.ExitNodeConnected.ShouldBeTrue(); + loaded.Result.ShouldBe("Hello"); + } + finally + { + laterPlugin.Dispose(); + } + } + + [Fact] + public void UnregisteredNodes_AreLeftOutWhenLoading() + { + NodeScript script = CreateScript(); + TestNode node = new(); + node.CreateOutputPin(); + script.AddNode(node); + script.GetExitNode().Input.ConnectTo(node.Pins.Single()); + + NodeScript loaded = SaveAndLoad(script); + + script.Entity.Nodes.Single(n => n.Id == node.Id).ProviderId.ShouldBe(Constants.CorePluginFeature.Id); + loaded.Nodes.ShouldHaveSingleItem().IsExitNode.ShouldBeTrue(); + loaded.ExitNodeConnected.ShouldBeFalse(); + } + + [Fact] + public void IncompatibleConnections_AreDroppedWhenLoading() + { + // In code any two pins can be connected, loading only restores connections between compatible pins + NodeScript script = CreateScript(); + TestNumericNode node = Nodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + script.ExitNodeConnected.ShouldBeTrue(); + + NodeScript loaded = SaveAndLoad(script); + + loaded.ExitNodeConnected.ShouldBeFalse(); + loaded.Nodes.OfType().Single().Output.ConnectedTo.ShouldBeEmpty(); + } + + [Fact] + public void InputConnectedToTwoOutputs_KeepsOnlyTheLastConnectionWhenLoading() + { + // Pinned as-is: in code an input can be connected to several outputs, it then reads from the first one. Loading + // keeps only the last connection, so after loading the script reads from a different node than before. + NodeScript script = CreateScript(); + TestNumericNode first = Nodes.AddNode(script); + TestNumericNode second = Nodes.AddNode(script); + first.Value = 1; + second.Value = 2; + script.GetExitNode().Input.ConnectTo(first.Output); + script.GetExitNode().Input.ConnectTo(second.Output); + script.Run(); + script.Result.ShouldBe(new Numeric(1)); + + NodeScript loaded = SaveAndLoad(script); + loaded.Run(); + + loaded.GetExitNode().Input.ConnectedTo.ShouldHaveSingleItem().Node.Id.ShouldBe(second.Id); + loaded.Result.ShouldBe(new Numeric(2)); + } + + [Fact] + public void LoadConnections_CanRunAgainWithoutDuplicatingConnections() + { + NodeScript script = CreateScript(); + TestBooleanNode node = Nodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + + NodeScript loaded = SaveAndLoad(script); + loaded.LoadConnections(); + loaded.LoadConnections(); + + loaded.GetExitNode().Input.ConnectedTo.Count.ShouldBe(1); + loaded.Nodes.OfType().Single().Output.ConnectedTo.Count.ShouldBe(1); + } + + [Fact] + public void Load_AppliesTheEntityToExistingNodes() + { + NodeScript script = CreateScript(); + TestSumNode sum = ScriptNodes.AddNode(script); + sum.X = 5; + script.Save(); + + sum.X = 50; + sum.Values.Add(sum.Values.CreatePin()); + sum.Values.Add(sum.Values.CreatePin()); + script.Load(); + + script.Nodes.OfType().ShouldHaveSingleItem().ShouldBeSameAs(sum); + sum.X.ShouldBe(5); + sum.Values.Count().ShouldBe(2); + } + + [Fact] + public void Load_AddsPinsToCollectionsThatHaveTooFew() + { + NodeScript script = CreateScript(); + TestSumNode sum = ScriptNodes.AddNode(script); + script.Save(); + script.Entity.Nodes.Single(n => n.Id == sum.Id).PinCollections[0].Amount = 5; + + script.Load(); + + sum.Values.Count().ShouldBe(5); + } + + [Fact] + public void Load_RemovesAndDisposesNodesThatAreNotInTheEntity() + { + NodeScript script = CreateScript(); + script.Save(); + TestDisposableNode node = new(); + script.AddNode(node); + List removed = []; + script.NodeRemoved += (_, e) => removed.Add(e.Value); + + script.Load(); + + script.Nodes.ShouldNotContain(node); + removed.ShouldBe([node]); + node.IsDisposed.ShouldBeTrue(); + } + + [Fact] + public void DefaultNodes_AreAddedToNewScripts() + { + TestDefaultNode defaultNode = new(); + + NodeScript script = CreateScript(defaultNodes: [defaultNode]); + + script.Nodes.ShouldBe([defaultNode, script.GetExitNode()]); + defaultNode.IsDefaultNode.ShouldBeTrue(); + defaultNode.IsExitNode.ShouldBeFalse(); + defaultNode.Id.ShouldBe(TestDefaultNode.NodeId); + defaultNode.Name.ShouldBe("Test default node"); + defaultNode.Description.ShouldBe("A node every script starts with"); + } + + [Fact] + public void DefaultNodes_KeepTheirPositionAndConnections_WhenLoadedWithTheScript() + { + TestDefaultNode defaultNode = new() {X = 25, Y = 35}; + NodeScript script = CreateScript(defaultNodes: [defaultNode]); + script.GetExitNode().Input.ConnectTo(defaultNode.Output); + + TestDefaultNode loadedDefaultNode = new() {Value = 9}; + NodeScript loaded = SaveAndLoad(script, [loadedDefaultNode]); + loaded.Run(); + + loadedDefaultNode.X.ShouldBe(25); + loadedDefaultNode.Y.ShouldBe(35); + loaded.Result.ShouldBe(new Numeric(9)); + } + + [Fact] + public void DefaultNodes_AreLeftOut_WhenTheScriptIsLoadedWithoutThem() + { + // Default nodes aren't registered, the script only finds them when it is given them again + TestDefaultNode defaultNode = new(); + NodeScript script = CreateScript(defaultNodes: [defaultNode]); + script.GetExitNode().Input.ConnectTo(defaultNode.Output); + + NodeScript loaded = SaveAndLoad(script); + + loaded.Nodes.ShouldHaveSingleItem().IsExitNode.ShouldBeTrue(); + loaded.ExitNodeConnected.ShouldBeFalse(); + } +} diff --git a/src/Artemis.Core.Tests/VisualScripting/NodeScriptTests.cs b/src/Artemis.Core.Tests/VisualScripting/NodeScriptTests.cs new file mode 100644 index 000000000..ecc59a0c7 --- /dev/null +++ b/src/Artemis.Core.Tests/VisualScripting/NodeScriptTests.cs @@ -0,0 +1,357 @@ +using Artemis.Core.Tests.Infrastructure.Plugins; +using Artemis.Core.Tests.Infrastructure.Plugins.Nodes; +using Artemis.Core.Tests.Infrastructure.VisualScripting; + +namespace Artemis.Core.Tests.VisualScripting; + +public class NodeScriptTests : ScriptTestBase +{ + [Fact] + public void Script_ProducesTheValueConnectedToTheExitNode() + { + NodeScript script = CreateScript(); + TestBooleanNode node = Nodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + + node.Value = true; + script.Run(); + script.Result.ShouldBeTrue(); + + node.Value = false; + script.Run(); + script.Result.ShouldBeFalse(); + } + + [Fact] + public void Script_WithRegisteredNodes_SurvivesSavingAndLoading() + { + NodeScript script = CreateScript(); + TestBooleanNode node = Nodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + node.Value = true; + script.Save(); + + NodeScript loaded = Track(new NodeScript("Test", "A test script", script.Entity)); + loaded.Run(); + + loaded.Nodes.OfType().Single().Value.ShouldBeTrue(); + loaded.ExitNodeConnected.ShouldBeTrue(); + loaded.Result.ShouldBeTrue(); + } + + [Fact] + public void NewScript_ContainsOnlyItsExitNode() + { + NodeScript script = CreateScript("My script"); + + INode exitNode = script.Nodes.ShouldHaveSingleItem(); + exitNode.IsExitNode.ShouldBeTrue(); + exitNode.IsDefaultNode.ShouldBeFalse(); + exitNode.Name.ShouldBe("My script"); + exitNode.Description.ShouldBe("A test script"); + script.Name.ShouldBe("My script"); + script.Description.ShouldBe("A test script"); + script.ResultType.ShouldBe(typeof(bool)); + script.ExitNodeConnected.ShouldBeFalse(); + } + + [Fact] + public void Script_KeepsItsContext() + { + object context = new(); + using NodeScript script = new("Test", "A test script", context); + + script.Context.ShouldBeSameAs(context); + } + + [Fact] + public void Script_WithoutAConnectedExitNode_ProducesTheDefaultValue() + { + NodeScript numericScript = CreateScript(); + NodeScript textScript = CreateScript(); + NodeScript booleanScript = CreateScript(); + + numericScript.Run(); + textScript.Run(); + booleanScript.Run(); + + numericScript.Result.ShouldBe(new Numeric(0)); + textScript.Result.ShouldBeNull(); + booleanScript.Result.ShouldBeFalse(); + } + + [Fact] + public void Script_ResultOnlyChangesWhenTheScriptRuns() + { + NodeScript script = CreateScript(); + TestNumericNode node = Nodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + node.Value = 1; + script.Run(); + + node.Value = 2; + + script.Result.ShouldBe(new Numeric(1)); + script.Run(); + script.Result.ShouldBe(new Numeric(2)); + } + + [Fact] + public void NumericScript_ProducesTheNumber() + { + NodeScript script = CreateScript(); + TestNumericNode node = Nodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + node.Value = 2.5f; + + script.Run(); + + script.Result.ShouldBe(new Numeric(2.5f)); + } + + [Fact] + public void TextScript_ProducesTheText() + { + NodeScript script = CreateScript(); + TestTextNode node = ScriptNodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + node.Value = "Hello"; + + script.Run(); + + script.Result.ShouldBe("Hello"); + } + + [Fact] + public void ObjectScript_ProducesWhateverIsConnected() + { + NodeScript script = CreateScript(); + TestNumericNode node = Nodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + node.Value = 3; + + script.Run(); + + script.Result.ShouldBe(new Numeric(3)); + } + + [Fact] + public void FloatScript_DoesNotTurnANumericIntoAFloat() + { + // Connecting pins in code isn't type checked, the editor only offers compatible pins. A float input doesn't accept + // a Numeric, so the result is the default value. + NodeScript script = CreateScript(); + TestNumericNode node = Nodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + node.Value = 3; + + script.Run(); + + script.ExitNodeConnected.ShouldBeTrue(); + script.Result.ShouldBe(0f); + } + + [Fact] + public void Script_CombinesTheValuesOfSeveralNodes() + { + NodeScript script = CreateScript(); + TestNumericNode first = Nodes.AddNode(script); + TestNumericNode second = Nodes.AddNode(script); + TestSumNode sum = ScriptNodes.AddNode(script); + sum.Values.Pins.ElementAt(0).ConnectTo(first.Output); + sum.Values.Pins.ElementAt(1).ConnectTo(second.Output); + script.GetExitNode().Input.ConnectTo(sum.Result); + first.Value = 2; + second.Value = 5; + + script.Run(); + + script.Result.ShouldBe(new Numeric(7)); + } + + [Fact] + public void Run_EvaluatesANodeOnce_EvenWhenItFeedsSeveralInputs() + { + NodeScript script = CreateScript(); + TestNumericNode number = Nodes.AddNode(script); + TestSumNode sum = ScriptNodes.AddNode(script); + sum.Values.Pins.ElementAt(0).ConnectTo(number.Output); + sum.Values.Pins.ElementAt(1).ConnectTo(number.Output); + script.GetExitNode().Input.ConnectTo(sum.Result); + number.Value = 4; + + script.Run(); + script.Result.ShouldBe(new Numeric(8)); + number.Evaluations.ShouldBe(1); + sum.Evaluations.ShouldBe(1); + + script.Run(); + number.Evaluations.ShouldBe(2); + sum.Evaluations.ShouldBe(2); + } + + [Fact] + public void Run_SkipsNodesThatDoNotLeadToTheExitNode() + { + NodeScript script = CreateScript(); + TestNumericNode connected = Nodes.AddNode(script); + TestNumericNode loose = Nodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(connected.Output); + + script.Run(); + + connected.Evaluations.ShouldBe(1); + loose.Evaluations.ShouldBe(0); + } + + [Fact] + public void Run_ResetsEveryNodeFirst() + { + NodeScript script = CreateScript(); + TestNode loose = new(); + script.AddNode(loose); + int resets = 0; + loose.Resetting += (_, _) => resets++; + + script.Run(); + script.Run(); + + resets.ShouldBe(2); + loose.Evaluations.ShouldBe(0); + } + + [Fact] + public void AddNode_AddsTheNodeAndRaisesNodeAdded() + { + NodeScript script = CreateScript(); + TestNode node = new(); + List added = []; + script.NodeAdded += (_, e) => added.Add(e.Value); + + script.AddNode(node); + + script.Nodes.ShouldContain(node); + added.ShouldBe([node]); + } + + [Fact] + public void RemoveNode_RemovesTheNodeAndRaisesNodeRemoved() + { + NodeScript script = CreateScript(); + TestNode node = new(); + script.AddNode(node); + List removed = []; + script.NodeRemoved += (_, e) => removed.Add(e.Value); + + script.RemoveNode(node); + + script.Nodes.ShouldNotContain(node); + removed.ShouldBe([node]); + } + + [Fact] + public void RemoveNode_LeavesTheConnectionsOfTheNodeAlone() + { + // The editor disconnects the pins itself before it removes a node (see DeleteNode in Artemis.UI.Shared), so the + // script still reads from the removed node + NodeScript script = CreateScript(); + TestBooleanNode node = Nodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + node.Value = true; + + script.RemoveNode(node); + script.Run(); + + script.ExitNodeConnected.ShouldBeTrue(); + script.Result.ShouldBeTrue(); + } + + [Fact] + public void RemoveNode_RaisesNodeRemoved_EvenForANodeThatIsNotInTheScript() + { + // Pinned as-is: removing a node that was never added still tells listeners it was removed, an editor listening + // to the event could remove a view model that belongs to another script + NodeScript script = CreateScript(); + int raised = 0; + script.NodeRemoved += (_, _) => raised++; + + script.RemoveNode(new TestNode()); + + raised.ShouldBe(1); + } + + [Fact] + public void Dispose_DisposesNodesThatCanBeDisposed() + { + NodeScript script = new("Test", "A test script"); + TestDisposableNode node = new(); + script.AddNode(node); + + script.Dispose(); + + node.IsDisposed.ShouldBeTrue(); + } + + [Fact] + public void DisablingTheNodeProvider_RemovesItsNodesFromTheScript() + { + TestPlugin otherPlugin = new(); + TestNodeProvider otherNodes = otherPlugin.AddFeature(new TestNodeProvider()); + NodeScript script = CreateScript(); + TestBooleanNode node = otherNodes.AddNode(script); + TestSumNode keptNode = ScriptNodes.AddNode(script); + List removed = []; + script.NodeRemoved += (_, e) => removed.Add(e.Value); + + otherPlugin.Dispose(); + + removed.ShouldBe([node]); + script.Nodes.ShouldNotContain(node); + script.Nodes.ShouldContain(keptNode); + } + + [Fact] + public void DisablingTheNodeProvider_AlsoRemovesNodesOfTheSameTypeFromAnotherProvider() + { + // Pinned as-is: the script removes nodes by their type, not by the provider that created them. When two enabled + // providers register the same node type, disabling one also removes the nodes created by the other. + TestPlugin otherPlugin = new(); + TestNodeProvider otherNodes = otherPlugin.AddFeature(new TestNodeProvider()); + NodeScript script = CreateScript(); + TestNumericNode fromOtherProvider = otherNodes.AddNode(script); + TestNumericNode fromStillEnabledProvider = Nodes.AddNode(script); + + otherPlugin.Dispose(); + + script.Nodes.ShouldNotContain(fromOtherProvider); + script.Nodes.ShouldNotContain(fromStillEnabledProvider); + } + + [Fact] + public void DisposedScript_NoLongerReactsToNodeProviders() + { + TestPlugin otherPlugin = new(); + TestNodeProvider otherNodes = otherPlugin.AddFeature(new TestNodeProvider()); + NodeScript script = new("Test", "A test script"); + TestBooleanNode node = otherNodes.AddNode(script); + + script.Dispose(); + otherPlugin.Dispose(); + + script.Nodes.ShouldContain(node); + } + + [Fact] + public void FeatureDependencies_AreTheProvidersOfEveryNode() + { + NodeScript script = CreateScript(); + Nodes.AddNode(script); + ScriptNodes.AddNode(script); + ScriptNodes.AddNode(script); + script.AddNode(new TestNode()); + + // One entry per node rather than per provider, nodes without node data (the exit node and the unregistered + // node) add nothing + script.GetFeatureDependencies().ShouldBe([Nodes, ScriptNodes, ScriptNodes]); + } +} diff --git a/src/Artemis.Core.Tests/VisualScripting/NodeTests.cs b/src/Artemis.Core.Tests/VisualScripting/NodeTests.cs new file mode 100644 index 000000000..3ca83762f --- /dev/null +++ b/src/Artemis.Core.Tests/VisualScripting/NodeTests.cs @@ -0,0 +1,402 @@ +using Artemis.Core.Tests.Infrastructure.VisualScripting; + +namespace Artemis.Core.Tests.VisualScripting; + +public class NodeTests : ScriptTestBase +{ + #region Pins + + [Fact] + public void CreatingPins_AddsThemToTheNode_AndRaisesPinAdded() + { + TestNode node = new(); + List added = []; + node.PinAdded += (_, e) => added.Add(e.Value); + + InputPin typedInput = node.CreateInputPin("A"); + InputPin input = node.CreateInputPin(typeof(string), "B"); + OutputPin typedOutput = node.CreateOutputPin("C"); + OutputPin output = node.CreateOutputPin(typeof(string), "D"); + + node.Pins.ShouldBe([typedInput, input, typedOutput, output]); + added.ShouldBe([typedInput, input, typedOutput, output]); + } + + [Fact] + public void RemovePin_RemovesAndDisconnectsThePin_AndRaisesPinRemoved() + { + TestNode node = new(); + TestNode other = new(); + InputPin input = node.CreateInputPin(); + OutputPin output = other.CreateOutputPin(); + input.ConnectTo(output); + List removed = []; + node.PinRemoved += (_, e) => removed.Add(e.Value); + + node.RemovePin(input).ShouldBeTrue(); + + node.Pins.ShouldBeEmpty(); + output.ConnectedTo.ShouldBeEmpty(); + removed.ShouldBe([input]); + } + + [Fact] + public void RemovePin_OfAnotherNode_ReturnsFalse() + { + TestNode node = new(); + TestNode other = new(); + OutputPin output = other.CreateOutputPin(); + + node.RemovePin(output).ShouldBeFalse(); + other.Pins.ShouldBe([output]); + } + + [Fact] + public void AddPin_PutsARemovedPinBack() + { + TestNode node = new(); + InputPin input = node.CreateInputPin(); + node.RemovePin(input); + List added = []; + node.PinAdded += (_, e) => added.Add(e.Value); + + node.AddPin(input); + node.AddPin(input); + + node.Pins.ShouldBe([input]); + added.ShouldBe([input]); + } + + [Fact] + public void AddPin_RejectsPinsOfAnotherNode() + { + TestNode node = new(); + TestNode other = new(); + + Should.Throw(() => node.AddPin(other.CreateInputPin())); + } + + [Fact] + public void CreateOrAddInputPin_TurnsNumberTypesIntoNumeric() + { + TestNode node = new(); + + InputPin pin = node.CreateOrAddInputPin(typeof(int), "Amount"); + + pin.Type.ShouldBe(typeof(Numeric)); + pin.Name.ShouldBe("Amount"); + node.Pins.ShouldBe([pin]); + } + + [Fact] + public void CreateOrAddInputPin_ReusesARemovedPin() + { + TestNode node = new(); + InputPin first = node.CreateOrAddInputPin(typeof(string), "Text"); + node.RemovePin(first); + + InputPin second = node.CreateOrAddInputPin(typeof(bool), "Flag"); + InputPin third = node.CreateOrAddInputPin(typeof(bool), "Other flag"); + + second.ShouldBeSameAs(first); + second.Type.ShouldBe(typeof(bool)); + second.Name.ShouldBe("Flag"); + third.ShouldNotBeSameAs(first); + node.Pins.ShouldBe([second, third]); + } + + [Fact] + public void CreateOrAddOutputPin_TurnsNumberTypesIntoNumeric_AndReusesARemovedPin() + { + TestNode node = new(); + OutputPin first = node.CreateOrAddOutputPin(typeof(double), "Amount"); + first.Type.ShouldBe(typeof(Numeric)); + node.RemovePin(first); + + OutputPin second = node.CreateOrAddOutputPin(typeof(string), "Text"); + + second.ShouldBeSameAs(first); + second.Type.ShouldBe(typeof(string)); + second.Name.ShouldBe("Text"); + node.Pins.ShouldBe([second]); + } + + [Fact] + public void CreateOrAddPin_OnlyReusesPinsItCreatedItself() + { + TestNode node = new(); + InputPin created = node.CreateInputPin(typeof(string)); + node.RemovePin(created); + + InputPin pin = node.CreateOrAddInputPin(typeof(string), "Text"); + + pin.ShouldNotBeSameAs(created); + } + + #endregion + + #region Evaluating + + [Fact] + public void Reset_ResetsEveryPin_AndRaisesResetting() + { + TestNode node = new(); + OutputPin output = node.CreateOutputPin(); + output.Value = new Numeric(1); + int resetting = 0; + node.Resetting += (_, _) => resetting++; + + node.Reset(); + + output.IsEvaluated.ShouldBeFalse(); + resetting.ShouldBe(1); + } + + [Fact] + public void ReadingAnOutput_EvaluatesTheNodeOnlyOnce() + { + TestNode node = new(); + OutputPin output = node.CreateOutputPin(); + node.OnEvaluate = _ => output.Value = new Numeric(2); + + output.Value.ShouldBe(new Numeric(2)); + output.Value.ShouldBe(new Numeric(2)); + + node.Evaluations.ShouldBe(1); + } + + [Fact] + public void ReadingAnOutput_ThatTheNodeDoesNotSet_EvaluatesTheNodeEveryTime() + { + TestNode node = new(); + OutputPin output = node.CreateOutputPin(); + + _ = output.Value; + _ = output.Value; + + node.Evaluations.ShouldBe(2); + } + + [Fact] + public void TryEvaluate_WhenEvaluatingFails_BreaksTheNode() + { + TestNode node = new() {OnEvaluate = _ => throw new InvalidOperationException("Oops")}; + int changes = 0; + node.BrokenStateChanged += (_, _) => changes++; + + node.TryEvaluate(); + + node.BrokenState.ShouldBe("Failed to evaluate"); + node.BrokenStateException.ShouldBeOfType().Message.ShouldBe("Oops"); + node.BrokenDisplayName.ShouldBe("Test node"); + node.GetBrokenHierarchy().ShouldBe([node]); + changes.ShouldBe(1); + } + + [Fact] + public void TryEvaluate_WhenEvaluatingWorksAgain_RepairsTheNode() + { + bool fail = true; + TestNode node = new() {OnEvaluate = _ => { if (fail) throw new InvalidOperationException(); }}; + node.TryEvaluate(); + + fail = false; + node.TryEvaluate(); + + node.BrokenState.ShouldBeNull(); + node.BrokenStateException.ShouldBeNull(); + } + + [Fact] + public void TryInitialize_WhenInitializingFails_BreaksTheNode() + { + TestThrowingNode node = new() {FailsOn = TestThrowingNode.FailureMode.Initialize}; + + node.TryInitialize(CreateScript()); + + node.BrokenState.ShouldBe("Failed to initialize"); + } + + [Fact] + public void FailingNode_InAScript_LeavesTheResultAtTheLastValueItProduced() + { + // Pinned as-is: when a node fails, its output keeps the value from the last run that worked. The script carries on + // with that old value and only the broken state of the node shows something is wrong. + NodeScript script = CreateScript(); + TestThrowingNode node = ScriptNodes.AddNode(script); + script.GetExitNode().Input.ConnectTo(node.Output); + node.Value = 5; + script.Run(); + + node.Value = 6; + node.FailsOn = TestThrowingNode.FailureMode.Evaluate; + script.Run(); + + script.Result.ShouldBe(new Numeric(5)); + node.BrokenState.ShouldBe("Failed to evaluate"); + script.GetExitNode().BrokenState.ShouldBeNull(); + } + + [Fact] + public void FailingNode_InAScript_IsEvaluatedAgainForEveryInputReadingIt() + { + // A failing node never marks its outputs as evaluated, so every pin reading from it tries again within one run + NodeScript script = CreateScript(); + TestNode failing = new() {OnEvaluate = _ => throw new InvalidOperationException()}; + OutputPin output = failing.CreateOutputPin(); + script.AddNode(failing); + TestSumNode sum = ScriptNodes.AddNode(script); + sum.Values.Pins.ElementAt(0).ConnectTo(output); + sum.Values.Pins.ElementAt(1).ConnectTo(output); + script.GetExitNode().Input.ConnectTo(sum.Result); + + script.Run(); + + failing.Evaluations.ShouldBe(2); + sum.BrokenState.ShouldBeNull(); + } + + [Fact] + public void ReadingAnInputOfTheWrongType_BreaksTheNodeReadingIt() + { + TestNode source = new(); + OutputPin output = source.CreateOutputPin(); + source.OnEvaluate = _ => output.Value = 1f; + TestNode reader = new(); + InputPin input = reader.CreateInputPin(typeof(Numeric)); + reader.OnEvaluate = _ => input.Value.ShouldNotBeNull(); + input.ConnectTo(output); + + reader.TryEvaluate(); + + reader.BrokenState.ShouldBe("Failed to evaluate"); + reader.BrokenStateException.ShouldBeOfType(); + } + + #endregion + + #region Storage + + [Fact] + public void Storage_RaisesStorageModifiedOnlyWhenItChanges() + { + TestBooleanNode node = new(); + int modified = 0; + List changed = []; + node.StorageModified += (_, _) => modified++; + node.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + node.Value = true; + node.Value = true; + node.Value = false; + + modified.ShouldBe(2); + changed.ShouldBe([nameof(Node.Storage), nameof(Node.Storage)]); + } + + [Fact] + public void Storage_IsSerializedAsJson() + { + TestSettingsNode node = new() {Storage = new TestNodeSettings {Name = "A", Amount = 2, Numbers = [3]}}; + + TestSettingsNode copy = new(); + copy.DeserializeStorage(node.SerializeStorage()); + + copy.Storage.ShouldNotBeNull().Name.ShouldBe("A"); + copy.Storage.Amount.ShouldBe(2); + copy.Storage.Numbers.ShouldBe([3]); + } + + [Fact] + public void DeserializingNull_ClearsTheStorage() + { + TestTextNode node = new() {Value = "Hello"}; + + node.DeserializeStorage("null"); + + node.Value.ShouldBeNull(); + } + + [Fact] + public void DeserializingInvalidJson_Throws() + { + TestBooleanNode node = new(); + + Should.Throw(() => node.DeserializeStorage("{not json")); + } + + [Fact] + public void NodeWithoutStorage_StoresNothing() + { + TestNode node = new(); + + node.SerializeStorage().ShouldBeEmpty(); + node.DeserializeStorage("anything"); + } + + #endregion + + #region Other + + [Fact] + public void NewNodes_GetTheirOwnId() + { + new TestNode().Id.ShouldNotBe(new TestNode().Id); + new TestNode().Id.ShouldNotBe(Guid.Empty); + } + + [Fact] + public void Node_RaisesPropertyChangedForItsProperties() + { + TestNode node = new(); + List changed = []; + node.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + node.Name = "Name"; + node.Description = "Description"; + node.HelpUrl = "https://example.com"; + node.X = 1; + node.Y = 2; + node.Id = Guid.NewGuid(); + + changed.ShouldBe([nameof(INode.Name), nameof(INode.Description), nameof(INode.HelpUrl), nameof(INode.X), nameof(INode.Y), nameof(INode.Id)]); + } + + [Fact] + public void Node_WithoutNodeData_DependsOnNoFeatures() + { + new TestNode().GetFeatureDependencies().ShouldBeEmpty(); + } + + [Fact] + public void RegisteredNode_DependsOnItsProvider() + { + TestSumNode node = ScriptNodes.AddNode(CreateScript()); + + node.GetFeatureDependencies().ShouldBe([ScriptNodes]); + } + + [Fact] + public void ViewModel_IsCreatedThroughThePluginContainer() + { + NodeScript script = CreateScript(); + TestViewModelNode node = ScriptNodes.AddNode(script); + + ICustomNodeViewModel viewModel = node.GetCustomViewModel(script); + + TestNodeViewModel testViewModel = viewModel.ShouldBeOfType(); + testViewModel.Node.ShouldBe(node); + testViewModel.Script.ShouldBe(script); + node.ViewModelPosition.ShouldBe(CustomNodeViewModelPosition.BetweenPinsTop); + } + + [Fact] + public void ViewModel_OfANodeWithoutNodeData_CannotBeCreated() + { + TestViewModelNode node = new(); + + Should.Throw(() => node.GetViewModel(CreateScript())); + } + + #endregion +} diff --git a/src/Artemis.Core.Tests/VisualScripting/NumericTests.cs b/src/Artemis.Core.Tests/VisualScripting/NumericTests.cs new file mode 100644 index 000000000..c5136ccbe --- /dev/null +++ b/src/Artemis.Core.Tests/VisualScripting/NumericTests.cs @@ -0,0 +1,281 @@ +using System.Globalization; + +namespace Artemis.Core.Tests.VisualScripting; + +public class NumericTests +{ + #region Creating + + [Fact] + public void Numeric_CanBeCreatedFromEveryNumberType() + { + ((float) new Numeric(1.5f)).ShouldBe(1.5f); + ((float) new Numeric(2)).ShouldBe(2f); + ((float) new Numeric(2.5d)).ShouldBe(2.5f); + ((float) new Numeric((byte) 200)).ShouldBe(200f); + ((float) new Numeric(3L)).ShouldBe(3f); + } + + [Theory] + [InlineData(1.5f, 1.5f)] + [InlineData(2, 2f)] + [InlineData(2.5d, 2.5f)] + [InlineData((byte) 7, 7f)] + [InlineData(4L, 4f)] + [InlineData("1.25", 1.25f)] + [InlineData("1,000", 1000f)] + [InlineData("not a number", 0f)] + [InlineData(null, 0f)] + [InlineData(true, 0f)] + public void Numeric_FromAnObject_UsesNumbersAndParsesTheRest(object? value, float expected) + { + ((float) new Numeric(value)).ShouldBe(expected); + } + + [Fact] + public void Numeric_FromAnObject_CopiesANumeric() + { + ((float) new Numeric((object) new Numeric(3.5f))).ShouldBe(3.5f); + } + + [Fact] + public void Numeric_FromText_IgnoresTheCurrentCulture() + { + CultureInfo culture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("nl-NL"); + + ((float) new Numeric("1.5")).ShouldBe(1.5f); + new Numeric(1.5f).ToString().ShouldBe("1.5"); + } + finally + { + CultureInfo.CurrentCulture = culture; + } + } + + [Fact] + public void TryParse_ReadsNumbers() + { + Numeric.TryParse("2.5", out Numeric parsed).ShouldBeTrue(); + parsed.ShouldBe(new Numeric(2.5f)); + + Numeric.TryParse("abc", out Numeric failed).ShouldBeFalse(); + failed.ShouldBe(new Numeric(0)); + + Numeric.TryParse(null, out _).ShouldBeFalse(); + } + + [Theory] + [InlineData(typeof(Numeric), true)] + [InlineData(typeof(float), true)] + [InlineData(typeof(double), true)] + [InlineData(typeof(int), true)] + [InlineData(typeof(long), true)] + [InlineData(typeof(byte), true)] + [InlineData(typeof(decimal), false)] + [InlineData(typeof(short), false)] + [InlineData(typeof(string), false)] + [InlineData(null, false)] + public void IsTypeCompatible_KnowsWhichTypesCanBeNumeric(Type? type, bool compatible) + { + Numeric.IsTypeCompatible(type).ShouldBe(compatible); + } + + #endregion + + #region Converting + + [Theory] + [InlineData(2.4f, 2)] + [InlineData(2.5f, 3)] + [InlineData(-2.5f, -3)] + public void ToInt_RoundsHalfAwayFromZero(float value, int expected) + { + int converted = new Numeric(value); + converted.ShouldBe(expected); + AsConvertible(value).ToInt32(null).ShouldBe(expected); + } + + [Fact] + public void ToLong_CutsOffTheDecimals_UnlikeTheOtherWholeNumberConversions() + { + // Pinned as-is: turning a Numeric into a long cuts off the decimals, while turning it into an int rounds, and so + // does the IConvertible conversion to a long + long cast = new Numeric(2.7f); + int castToInt = new Numeric(2.7f); + + cast.ShouldBe(2); + castToInt.ShouldBe(3); + AsConvertible(2.7f).ToInt64(null).ShouldBe(3); + } + + [Theory] + [InlineData(-5f, (byte) 0)] + [InlineData(100.7f, (byte) 100)] + [InlineData(300f, (byte) 255)] + public void ToByte_KeepsTheValueBetween0And255(float value, byte expected) + { + byte converted = new Numeric(value); + converted.ShouldBe(expected); + AsConvertible(value).ToByte(null).ShouldBe(expected); + } + + [Fact] + public void ToSByte_KeepsTheValueBetween0And255_WhichDoesNotFitAnSByte() + { + // Pinned as-is: an sbyte holds -128 to 127, but the conversion clamps to 0-255 like a byte does. Anything above + // 127 wraps around (200 becomes -56) and negative numbers become 0. + AsConvertible(200f).ToSByte(null).ShouldBe((sbyte) -56); + AsConvertible(-5f).ToSByte(null).ShouldBe((sbyte) 0); + AsConvertible(100f).ToSByte(null).ShouldBe((sbyte) 100); + } + + [Fact] + public void Numeric_ConvertsToOtherNumberTypes() + { + Numeric numeric = new(2.5f); + + float asFloat = numeric; + double asDouble = numeric; + decimal asDecimal = numeric; + asFloat.ShouldBe(2.5f); + asDouble.ShouldBe(2.5d); + asDecimal.ShouldBe(2.5m); + } + + [Fact] + public void Numeric_IsConvertible() + { + IConvertible numeric = AsConvertible(2.5f); + + numeric.GetTypeCode().ShouldBe(TypeCode.Single); + numeric.ToSingle(null).ShouldBe(2.5f); + numeric.ToDouble(null).ShouldBe(2.5d); + numeric.ToDecimal(null).ShouldBe(2.5m); + numeric.ToInt16(null).ShouldBe((short) 3); + numeric.ToUInt16(null).ShouldBe((ushort) 3); + numeric.ToUInt32(null).ShouldBe(3u); + numeric.ToUInt64(null).ShouldBe(3ul); + numeric.ToBoolean(null).ShouldBeTrue(); + AsConvertible(0).ToBoolean(null).ShouldBeFalse(); + numeric.ToString(CultureInfo.InvariantCulture).ShouldBe("2.5"); + numeric.ToType(typeof(int), null).ShouldBe(2); + Convert.ChangeType(new Numeric(2.5f), typeof(double)).ShouldBe(2.5d); + } + + [Fact] + public void Numeric_CannotBecomeACharacterOrDate() + { + Should.Throw(() => AsConvertible(65).ToChar(null)); + Should.Throw(() => AsConvertible(1).ToDateTime(null)); + } + + [Fact] + public void PassingANumericToConvert_PicksTheByteVersion() + { + // Pinned as-is: Numeric converts implicitly to byte, int, long, float, double and decimal, so the compiler sends + // Convert.ToInt32(numeric) and friends to their byte versions. The value is clamped to 0-255 and loses its + // decimals before it is converted. Passing it as an object uses IConvertible instead. + Numeric numeric = new(2.5f); + + Convert.ToSingle(numeric).ShouldBe(2f); + Convert.ToInt32(new Numeric(-10)).ShouldBe(0); + Convert.ToDouble(new Numeric(1000)).ShouldBe(255d); + Convert.ToSingle((object) numeric).ShouldBe(2.5f); + } + + [Fact] + public void NumberTypes_ConvertToNumeric() + { + Numeric fromDouble = 1.5d; + Numeric fromFloat = 2.5f; + Numeric fromInt = 3; + Numeric fromByte = (byte) 4; + + fromDouble.ShouldBe(new Numeric(1.5f)); + fromFloat.ShouldBe(new Numeric(2.5f)); + fromInt.ShouldBe(new Numeric(3)); + fromByte.ShouldBe(new Numeric(4)); + } + + private static IConvertible AsConvertible(float value) + { + return new Numeric(value); + } + + #endregion + + #region Math and comparing + + [Fact] + public void Numeric_SupportsArithmetic() + { + Numeric a = new(7); + Numeric b = new(2); + + (a + b).ShouldBe(new Numeric(9)); + (a - b).ShouldBe(new Numeric(5)); + (a * b).ShouldBe(new Numeric(14)); + (a / b).ShouldBe(new Numeric(3.5f)); + (a % b).ShouldBe(new Numeric(1)); + (-a).ShouldBe(new Numeric(-7)); + (+a).ShouldBe(new Numeric(7)); + } + + [Fact] + public void Numeric_CanBeIncrementedAndDecremented() + { + Numeric value = new(1); + + value++; + value.ShouldBe(new Numeric(2)); + value--; + value--; + value.ShouldBe(new Numeric(0)); + } + + [Fact] + public void DividingByZero_Throws() + { + Should.Throw(() => new Numeric(1) / new Numeric(0)); + } + + [Fact] + public void Numeric_CanBeCompared() + { + Numeric small = new(1); + Numeric large = new(2); + + (small < large).ShouldBeTrue(); + (large > small).ShouldBeTrue(); + (small <= new Numeric(1)).ShouldBeTrue(); + (large >= new Numeric(2)).ShouldBeTrue(); + (small == new Numeric(1f)).ShouldBeTrue(); + (small != large).ShouldBeTrue(); + small.CompareTo(large).ShouldBeLessThan(0); + small.Equals((object) new Numeric(1)).ShouldBeTrue(); + small.Equals((object) 1f).ShouldBeFalse(); + small.GetHashCode().ShouldBe(new Numeric(1).GetHashCode()); + } + + [Fact] + public void Sum_AddsUpEveryNumeric() + { + new[] {new Numeric(1), new Numeric(2.5f), new Numeric(-1)}.Sum().ShouldBe(new Numeric(2.5f)); + Array.Empty().Sum().ShouldBe(new Numeric(0)); + Should.Throw(() => ((IEnumerable) null!).Sum()); + } + + [Fact] + public void Subtract_TakesTheRestOffTheFirstNumeric() + { + new[] {new Numeric(10), new Numeric(3), new Numeric(2)}.Subtract().ShouldBe(new Numeric(5)); + new[] {new Numeric(4)}.Subtract().ShouldBe(new Numeric(4)); + Array.Empty().Subtract().ShouldBe(new Numeric(0)); + Should.Throw(() => ((IEnumerable) null!).Subtract()); + } + + #endregion +} diff --git a/src/Artemis.Core.Tests/VisualScripting/ObjectOutputPinsTests.cs b/src/Artemis.Core.Tests/VisualScripting/ObjectOutputPinsTests.cs new file mode 100644 index 000000000..d0a72c4e3 --- /dev/null +++ b/src/Artemis.Core.Tests/VisualScripting/ObjectOutputPinsTests.cs @@ -0,0 +1,184 @@ +using Artemis.Core.Modules; +using Artemis.Core.Tests.Infrastructure.VisualScripting; + +namespace Artemis.Core.Tests.VisualScripting; + +/// +/// registers a type color for text, so text properties get a pin +/// +public class ObjectOutputPinsTests : ScriptTestBase +{ + private readonly TestNode _node = new(); + private readonly TestNode _reader = new("Reader"); + + [Fact] + public void ChangeType_ToAnObject_CreatesAPinPerUsableProperty() + { + ObjectOutputPins pins = new(_node); + + pins.ChangeType(typeof(TestObject)); + + pins.CurrentType.ShouldBe(typeof(TestObject)); + pins.Pins.Select(p => (p.Name, p.Type)).ShouldBe([ + ("Number", typeof(Numeric)), + ("Some amount", typeof(Numeric)), + ("Text", typeof(string)) + ]); + _node.Pins.ShouldBe(pins.Pins); + } + + [Fact] + public void ChangeType_SkipsIgnoredProperties_AndPropertiesWithoutATypeColor() + { + ObjectOutputPins pins = new(_node); + + pins.ChangeType(typeof(TestObject)); + + pins.Pins.ShouldNotContain(p => p.Name == "Hidden"); + pins.Pins.ShouldNotContain(p => p.Name == "Flag"); + } + + [Fact] + public void ChangeType_ToANumberType_CreatesASingleNumericPin() + { + ObjectOutputPins pins = new(_node); + + pins.ChangeType(typeof(int)); + + OutputPin pin = pins.Pins.ShouldHaveSingleItem(); + pin.Name.ShouldBe("Item"); + pin.Type.ShouldBe(typeof(Numeric)); + } + + [Fact] + public void ChangeType_ToText_CreatesASingleTextPin() + { + ObjectOutputPins pins = new(_node); + + pins.ChangeType(typeof(string)); + + pins.Pins.ShouldHaveSingleItem().Type.ShouldBe(typeof(string)); + } + + [Fact] + public void ChangeType_ToNothing_RemovesThePins() + { + ObjectOutputPins pins = new(_node); + pins.ChangeType(typeof(TestObject)); + + pins.ChangeType(null); + + pins.CurrentType.ShouldBeNull(); + pins.Pins.ShouldBeEmpty(); + _node.Pins.ShouldBeEmpty(); + } + + [Fact] + public void ChangeType_ToTheSameType_KeepsThePins() + { + ObjectOutputPins pins = new(_node); + pins.ChangeType(typeof(TestObject)); + List before = pins.Pins.ToList(); + + pins.ChangeType(typeof(TestObject)); + + pins.Pins.ShouldBe(before); + } + + [Fact] + public void ChangeType_ReusesThePinsItRemoved() + { + ObjectOutputPins pins = new(_node); + pins.ChangeType(typeof(TestObject)); + OutputPin first = pins.Pins[0]; + + pins.ChangeType(typeof(string)); + + pins.Pins.ShouldHaveSingleItem().ShouldBeSameAs(first); + first.Name.ShouldBe("Item"); + first.Type.ShouldBe(typeof(string)); + } + + [Fact] + public void SetCurrentValue_ForANumberType_OutputsANumeric() + { + ObjectOutputPins pins = new(_node); + pins.ChangeType(typeof(int)); + + pins.SetCurrentValue(5); + + pins.Pins[0].Value.ShouldBe(new Numeric(5)); + } + + [Fact] + public void SetCurrentValue_ForANumberType_TurnsNullIntoZero() + { + ObjectOutputPins pins = new(_node); + pins.ChangeType(typeof(int)); + pins.SetCurrentValue(5); + + pins.SetCurrentValue(null); + + pins.Pins[0].Value.ShouldBe(new Numeric(0)); + } + + [Fact] + public void SetCurrentValue_ForAnObject_OnlyFillsConnectedPins() + { + ObjectOutputPins pins = new(_node); + pins.ChangeType(typeof(TestObject)); + OutputPin number = pins.Pins.Single(p => p.Name == "Number"); + OutputPin text = pins.Pins.Single(p => p.Name == "Text"); + number.ConnectTo(_reader.CreateInputPin()); + + pins.SetCurrentValue(new TestObject {Number = 3, Text = "Hello"}); + + number.Value.ShouldBe(new Numeric(3)); + text.Value.ShouldBeNull(); + } + + [Fact] + public void SetCurrentValue_ForAnObject_TurnsNullIntoDefaultValues() + { + ObjectOutputPins pins = new(_node); + pins.ChangeType(typeof(TestObject)); + OutputPin number = pins.Pins.Single(p => p.Name == "Number"); + OutputPin text = pins.Pins.Single(p => p.Name == "Text"); + number.ConnectTo(_reader.CreateInputPin()); + text.ConnectTo(_reader.CreateInputPin()); + pins.SetCurrentValue(new TestObject {Number = 3, Text = "Hello"}); + + pins.SetCurrentValue(null); + + number.Value.ShouldBe(new Numeric(0)); + text.Value.ShouldBeNull(); + } + + [Fact] + public void SetCurrentValue_BeforeChoosingAType_Throws() + { + ObjectOutputPins pins = new(_node); + + Should.Throw(() => pins.SetCurrentValue(5)); + } + + [Fact] + public void SetCurrentValue_OfAnotherType_Throws() + { + ObjectOutputPins pins = new(_node); + pins.ChangeType(typeof(TestObject)); + + Should.Throw(() => pins.SetCurrentValue("Hello")); + } + + public class TestObject + { + public int Number { get; set; } + public double SomeAmount { get; set; } + public string? Text { get; set; } + public bool Flag { get; set; } + + [DataModelIgnore] + public int Hidden { get; set; } + } +} diff --git a/src/Artemis.Core.Tests/VisualScripting/PinCollectionTests.cs b/src/Artemis.Core.Tests/VisualScripting/PinCollectionTests.cs new file mode 100644 index 000000000..a8e144264 --- /dev/null +++ b/src/Artemis.Core.Tests/VisualScripting/PinCollectionTests.cs @@ -0,0 +1,267 @@ +using Artemis.Core.Tests.Infrastructure.VisualScripting; + +namespace Artemis.Core.Tests.VisualScripting; + +public class PinCollectionTests +{ + private readonly TestNode _node = new(); + private readonly TestNode _source = new("Source"); + + [Fact] + public void InputPinCollection_StartsWithTheRequestedNumberOfPins() + { + InputPinCollection collection = _node.CreateInputPinCollection("Values", 3); + + collection.Name.ShouldBe("Values"); + collection.Node.ShouldBe(_node); + collection.Direction.ShouldBe(PinDirection.Input); + collection.Type.ShouldBe(typeof(Numeric)); + collection.Count().ShouldBe(3); + collection.Pins.ShouldAllBe(p => p.Node == _node && p.Direction == PinDirection.Input); + } + + [Fact] + public void OutputPinCollection_StartsWithTheRequestedNumberOfPins() + { + OutputPinCollection collection = _node.CreateOutputPinCollection("Texts", 2); + + collection.Direction.ShouldBe(PinDirection.Output); + collection.Type.ShouldBe(typeof(string)); + collection.Pins.Count.ShouldBe(2); + collection.Pins.ShouldAllBe(p => p is OutputPin); + } + + [Fact] + public void CreatingACollection_AddsItToTheNode_AndRaisesPinCollectionAdded() + { + List added = []; + _node.PinCollectionAdded += (_, e) => added.Add(e.Value); + + InputPinCollection first = _node.CreateInputPinCollection(); + InputPinCollection second = _node.CreateInputPinCollection(typeof(object)); + OutputPinCollection third = _node.CreateOutputPinCollection(); + + _node.PinCollections.ShouldBe([first, second, third]); + added.ShouldBe([first, second, third]); + first.Count().ShouldBe(1); + } + + [Fact] + public void Add_AddsThePin_AndRaisesPinAdded() + { + InputPinCollection collection = _node.CreateInputPinCollection(initialCount: 0); + List added = []; + collection.PinAdded += (_, e) => added.Add(e.Value); + + IPin pin = collection.CreatePin(); + collection.Add(pin); + + collection.Pins.ShouldBe([pin]); + added.ShouldBe([pin]); + } + + [Fact] + public void CreatePin_DoesNotAddThePin() + { + InputPinCollection collection = _node.CreateInputPinCollection(initialCount: 0); + + IPin pin = collection.CreatePin(); + + pin.ShouldBeOfType>(); + collection.ShouldBeEmpty(); + } + + [Fact] + public void Add_APinThatIsAlreadyInTheCollection_DoesNothing() + { + InputPinCollection collection = _node.CreateInputPinCollection(); + int added = 0; + collection.PinAdded += (_, _) => added++; + + collection.Add(collection.Pins.First()); + + collection.Count().ShouldBe(1); + added.ShouldBe(0); + } + + [Fact] + public void Add_RejectsPinsOfTheOtherDirection() + { + InputPinCollection collection = _node.CreateInputPinCollection(); + + Should.Throw(() => collection.Add(_node.CreateOutputPin())); + } + + [Fact] + public void Add_RejectsPinsOfAnotherType() + { + InputPinCollection collection = _node.CreateInputPinCollection(); + + Should.Throw(() => collection.Add(_node.CreateInputPin())); + } + + [Fact] + public void Remove_RemovesThePin_AndRaisesPinRemoved() + { + InputPinCollection collection = _node.CreateInputPinCollection(initialCount: 2); + IPin pin = collection.Pins.ElementAt(1); + List removed = []; + collection.PinRemoved += (_, e) => removed.Add(e.Value); + + collection.Remove(pin).ShouldBeTrue(); + + collection.Count().ShouldBe(1); + removed.ShouldBe([pin]); + } + + [Fact] + public void Remove_APinThatIsNotInTheCollection_ReturnsFalse() + { + InputPinCollection collection = _node.CreateInputPinCollection(); + int removed = 0; + collection.PinRemoved += (_, _) => removed++; + + collection.Remove(collection.CreatePin()).ShouldBeFalse(); + + removed.ShouldBe(0); + } + + [Fact] + public void Remove_LeavesTheConnectionsOfThePinAlone() + { + // The editor disconnects a pin itself before removing it (see RemovePin in Artemis.UI.Shared) + InputPinCollection collection = _node.CreateInputPinCollection(); + OutputPin output = _source.CreateOutputPin(); + IPin pin = collection.Pins.First(); + pin.ConnectTo(output); + + collection.Remove(pin); + + pin.ConnectedTo.ShouldBe([output]); + output.ConnectedTo.ShouldBe([pin]); + } + + [Fact] + public void Reset_ResetsEveryPin() + { + OutputPinCollection collection = _node.CreateOutputPinCollection(initialCount: 2); + foreach (OutputPin pin in collection.Cast>()) + pin.Value = new Numeric(1); + + collection.Reset(); + + collection.ShouldAllBe(p => !p.IsEvaluated); + } + + [Fact] + public void Node_Reset_ResetsItsPinCollections() + { + OutputPinCollection collection = _node.CreateOutputPinCollection(); + ((OutputPin) collection.Pins[0]).Value = new Numeric(1); + + _node.Reset(); + + collection.Pins[0].IsEvaluated.ShouldBeFalse(); + } + + [Fact] + public void TypedValues_LeaveOutPinsWithoutAValue() + { + InputPinCollection collection = _node.CreateInputPinCollection(initialCount: 3); + OutputPin output = _source.CreateOutputPin(); + collection.Pins.ElementAt(1).ConnectTo(output); + output.Value = "Hello"; + + _node.Reset(); + collection.Values.ShouldBe(["Hello"]); + } + + [Fact] + public void TypedValues_ForAValueType_IncludeDefaultsOfUnconnectedPins() + { + InputPinCollection collection = _node.CreateInputPinCollection(initialCount: 2); + OutputPin output = _source.CreateOutputPin(); + collection.Pins.ElementAt(0).ConnectTo(output); + output.Value = new Numeric(4); + + _node.Reset(); + collection.Values.ShouldBe([new Numeric(4), new Numeric(0)]); + } + + [Fact] + public void UntypedCollection_CreatesPinsOfItsType() + { + InputPinCollection collection = _node.CreateInputPinCollection(typeof(string), "Texts", 2); + + collection.Type.ShouldBe(typeof(string)); + collection.Pins.ShouldAllBe(p => p is InputPin && p.Type == typeof(string)); + } + + [Fact] + public void UntypedValues_LeaveOutPinsWithoutAValue() + { + InputPinCollection collection = _node.CreateInputPinCollection(typeof(object), initialCount: 2); + OutputPin output = _source.CreateOutputPin(); + collection.Pins[1].ConnectTo(output); + output.Value = new Numeric(4); + + collection.Values.Cast().ShouldBe([new Numeric(4)]); + } + + [Fact] + public void UntypedCollection_ChangeType_ChangesEveryPin_AndDropsIncompatibleConnections() + { + InputPinCollection collection = _node.CreateInputPinCollection(typeof(object), initialCount: 2); + OutputPin number = _source.CreateOutputPin(); + OutputPin text = _source.CreateOutputPin(); + collection.Pins[0].ConnectTo(number); + collection.Pins[1].ConnectTo(text); + List changed = []; + collection.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + collection.ChangeType(typeof(Numeric)); + + collection.Type.ShouldBe(typeof(Numeric)); + collection.ShouldAllBe(p => p.Type == typeof(Numeric)); + collection.Pins[0].ConnectedTo.ShouldBe([number]); + collection.Pins[1].ConnectedTo.ShouldBeEmpty(); + collection.CreatePin().Type.ShouldBe(typeof(Numeric)); + changed.ShouldBe([nameof(IPinCollection.Type)]); + } + + [Fact] + public void UntypedCollection_ChangeType_ToTheSameType_DoesNothing() + { + InputPinCollection collection = _node.CreateInputPinCollection(typeof(object)); + int changed = 0; + collection.PropertyChanged += (_, _) => changed++; + + collection.ChangeType(typeof(object)); + + changed.ShouldBe(0); + } + + [Fact] + public void RemovePinCollection_DisconnectsItsPins_AndRaisesPinCollectionRemoved() + { + InputPinCollection collection = _node.CreateInputPinCollection(); + OutputPin output = _source.CreateOutputPin(); + collection.Pins.Single().ConnectTo(output); + List removed = []; + _node.PinCollectionRemoved += (_, e) => removed.Add(e.Value); + + _node.RemovePinCollection(collection).ShouldBeTrue(); + + _node.PinCollections.ShouldBeEmpty(); + output.ConnectedTo.ShouldBeEmpty(); + removed.ShouldBe([collection]); + } + + [Fact] + public void RemovePinCollection_OfAnotherNode_ReturnsFalse() + { + InputPinCollection collection = _source.CreateInputPinCollection(); + + _node.RemovePinCollection(collection).ShouldBeFalse(); + } +} diff --git a/src/Artemis.Core.Tests/VisualScripting/PinTests.cs b/src/Artemis.Core.Tests/VisualScripting/PinTests.cs new file mode 100644 index 000000000..07a967493 --- /dev/null +++ b/src/Artemis.Core.Tests/VisualScripting/PinTests.cs @@ -0,0 +1,544 @@ +using System.ComponentModel; +using Artemis.Core.Tests.Infrastructure.Plugins.Modules; +using Artemis.Core.Tests.Infrastructure.VisualScripting; + +namespace Artemis.Core.Tests.VisualScripting; + +public class PinTests +{ + private readonly TestNode _source = new("Source"); + private readonly TestNode _target = new("Target"); + + #region Connecting + + [Fact] + public void ConnectTo_ConnectsBothPins() + { + OutputPin output = _source.CreateOutputPin(); + InputPin input = _target.CreateInputPin(); + + input.ConnectTo(output); + + input.ConnectedTo.ShouldBe([output]); + output.ConnectedTo.ShouldBe([input]); + } + + [Fact] + public void ConnectTo_RaisesPinConnectedOnBothPins() + { + OutputPin output = _source.CreateOutputPin(); + InputPin input = _target.CreateInputPin(); + List inputConnected = []; + List outputConnected = []; + input.PinConnected += (_, e) => inputConnected.Add(e.Value); + output.PinConnected += (_, e) => outputConnected.Add(e.Value); + + input.ConnectTo(output); + + inputConnected.ShouldBe([output]); + outputConnected.ShouldBe([input]); + } + + [Fact] + public void ConnectTo_RaisesPropertyChangedForConnectedTo() + { + OutputPin output = _source.CreateOutputPin(); + InputPin input = _target.CreateInputPin(); + List changed = []; + input.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + input.ConnectTo(output); + + changed.ShouldContain(nameof(IPin.ConnectedTo)); + } + + [Fact] + public void Output_CanFeedSeveralInputs() + { + OutputPin output = _source.CreateOutputPin(); + InputPin first = _target.CreateInputPin(); + InputPin second = _target.CreateInputPin(); + _source.OnEvaluate = _ => output.Value = new Numeric(3); + + first.ConnectTo(output); + second.ConnectTo(output); + + output.ConnectedTo.ShouldBe([first, second]); + _target.Reset(); + first.Value.ShouldBe(new Numeric(3)); + second.Value.ShouldBe(new Numeric(3)); + } + + [Fact] + public void ConnectTo_DoesNotCheckTypesOrDirections() + { + // The editor decides what may be connected, in code anything goes + InputPin text = _source.CreateInputPin(); + InputPin number = _target.CreateInputPin(); + + text.ConnectTo(number); + + text.ConnectedTo.ShouldBe([number]); + number.ConnectedTo.ShouldBe([text]); + } + + [Fact] + public void ConnectTo_DoesNotLimitInputsToOneConnection() + { + // The editor disconnects an input before connecting it again (see ConnectPins in Artemis.UI.Shared). An input + // with several connections reads from the first. + OutputPin first = _source.CreateOutputPin(); + OutputPin second = _source.CreateOutputPin(); + InputPin input = _target.CreateInputPin(); + _source.OnEvaluate = _ => + { + first.Value = new Numeric(1); + second.Value = new Numeric(2); + }; + + input.ConnectTo(first); + input.ConnectTo(second); + + input.ConnectedTo.ShouldBe([first, second]); + _target.Reset(); + input.Value.ShouldBe(new Numeric(1)); + } + + [Fact] + public void ConnectTo_TheSamePinTwice_AddsTheConnectionTwiceOnOneSide() + { + // Pinned as-is: connecting pins that are already connected adds a second connection to the pin it is called on, + // while the other pin keeps one. Disconnecting once removes both again. + OutputPin output = _source.CreateOutputPin(); + InputPin input = _target.CreateInputPin(); + + input.ConnectTo(output); + input.ConnectTo(output); + + input.ConnectedTo.ShouldBe([output, output]); + output.ConnectedTo.ShouldBe([input]); + + input.DisconnectFrom(output); + + input.ConnectedTo.ShouldBeEmpty(); + output.ConnectedTo.ShouldBeEmpty(); + } + + [Fact] + public void DisconnectFrom_DisconnectsBothPins_AndRaisesPinDisconnectedOnBoth() + { + OutputPin output = _source.CreateOutputPin(); + InputPin input = _target.CreateInputPin(); + input.ConnectTo(output); + List inputDisconnected = []; + List outputDisconnected = []; + input.PinDisconnected += (_, e) => inputDisconnected.Add(e.Value); + output.PinDisconnected += (_, e) => outputDisconnected.Add(e.Value); + + input.DisconnectFrom(output); + + input.ConnectedTo.ShouldBeEmpty(); + output.ConnectedTo.ShouldBeEmpty(); + inputDisconnected.ShouldBe([output]); + outputDisconnected.ShouldBe([input]); + } + + [Fact] + public void DisconnectAll_DisconnectsEveryConnection() + { + OutputPin output = _source.CreateOutputPin(); + InputPin first = _target.CreateInputPin(); + InputPin second = _target.CreateInputPin(); + output.ConnectTo(first); + output.ConnectTo(second); + List outputDisconnected = []; + int firstDisconnected = 0; + output.PinDisconnected += (_, e) => outputDisconnected.Add(e.Value); + first.PinDisconnected += (_, _) => firstDisconnected++; + + output.DisconnectAll(); + + output.ConnectedTo.ShouldBeEmpty(); + first.ConnectedTo.ShouldBeEmpty(); + second.ConnectedTo.ShouldBeEmpty(); + outputDisconnected.ShouldBe([first, second]); + firstDisconnected.ShouldBe(1); + } + + [Fact] + public void DisconnectAll_OnAPinWithoutConnections_RaisesNothing() + { + InputPin input = _target.CreateInputPin(); + int raised = 0; + input.PinDisconnected += (_, _) => raised++; + input.PropertyChanged += (_, _) => raised++; + + input.DisconnectAll(); + + raised.ShouldBe(0); + } + + #endregion + + #region Type compatibility + + [Theory] + [InlineData(typeof(Numeric), typeof(Numeric), true)] + [InlineData(typeof(Numeric), typeof(float), false)] + [InlineData(typeof(Numeric), typeof(int), false)] + [InlineData(typeof(object), typeof(string), true)] + [InlineData(typeof(object), typeof(Numeric), true)] + [InlineData(typeof(string), typeof(object), false)] + [InlineData(typeof(Enum), typeof(TestEnum), true)] + [InlineData(typeof(TestEnum), typeof(Enum), false)] + [InlineData(typeof(TestEnum), typeof(DayOfWeek), false)] + public void InputPin_AcceptsValuesThatFitItsType(Type pinType, Type valueType, bool compatible) + { + InputPin pin = _target.CreateInputPin(pinType); + + pin.IsTypeCompatible(valueType).ShouldBe(compatible); + } + + [Theory] + [InlineData(typeof(Numeric), typeof(Numeric), true)] + [InlineData(typeof(Numeric), typeof(float), false)] + [InlineData(typeof(string), typeof(object), true)] + [InlineData(typeof(object), typeof(string), false)] + [InlineData(typeof(TestEnum), typeof(Enum), true)] + [InlineData(typeof(Enum), typeof(TestEnum), false)] + [InlineData(typeof(TestEnum), typeof(DayOfWeek), false)] + public void OutputPin_FitsInputsThatTakeItsType(Type pinType, Type inputType, bool compatible) + { + OutputPin pin = _source.CreateOutputPin(pinType); + + pin.IsTypeCompatible(inputType).ShouldBe(compatible); + } + + [Fact] + public void IsTypeCompatible_MatchesEnums_EvenWhenNotForgiving() + { + // Pinned as-is: the forgiving enum check has no effect, a specific enum type already counts as an Enum without it + InputPin input = _target.CreateInputPin(typeof(Enum)); + OutputPin output = _source.CreateOutputPin(typeof(TestEnum)); + + input.IsTypeCompatible(typeof(TestEnum), false).ShouldBeTrue(); + output.IsTypeCompatible(typeof(Enum), false).ShouldBeTrue(); + } + + [Fact] + public void Pins_KnowWhetherTheyAreNumeric() + { + _target.CreateInputPin().IsNumeric.ShouldBeTrue(); + _target.CreateInputPin(typeof(Numeric)).IsNumeric.ShouldBeTrue(); + _source.CreateOutputPin().IsNumeric.ShouldBeTrue(); + _source.CreateOutputPin(typeof(Numeric)).IsNumeric.ShouldBeTrue(); + _target.CreateInputPin().IsNumeric.ShouldBeFalse(); + _source.CreateOutputPin(typeof(int)).IsNumeric.ShouldBeFalse(); + } + + #endregion + + #region Values + + [Fact] + public void InputPin_WithoutConnection_HasTheDefaultValue() + { + _target.CreateInputPin().Value.ShouldBe(new Numeric(0)); + _target.CreateInputPin().Value.ShouldBeNull(); + _target.CreateInputPin(typeof(Numeric)).Value.ShouldBe(new Numeric(0)); + _target.CreateInputPin(typeof(bool)).Value.ShouldBe(false); + _target.CreateInputPin(typeof(string)).Value.ShouldBeNull(); + } + + [Fact] + public void ReadingAnInput_EvaluatesTheNodeOfTheConnectedOutput() + { + OutputPin output = _source.CreateOutputPin(); + InputPin input = _target.CreateInputPin(); + input.ConnectTo(output); + _source.OnEvaluate = _ => output.Value = new Numeric(5); + + _target.Reset(); + input.Value.ShouldBe(new Numeric(5)); + _source.Evaluations.ShouldBe(1); + } + + [Fact] + public void Values_AreKeptUntilThePinsAreReset() + { + OutputPin output = _source.CreateOutputPin(); + InputPin input = _target.CreateInputPin(); + input.ConnectTo(output); + float value = 1; + _source.OnEvaluate = _ => output.Value = new Numeric(value); + _target.Reset(); + input.Value.ShouldBe(new Numeric(1)); + + value = 2; + input.Value.ShouldBe(new Numeric(1)); + _source.Evaluations.ShouldBe(1); + + _source.Reset(); + _target.Reset(); + input.Value.ShouldBe(new Numeric(2)); + _source.Evaluations.ShouldBe(2); + } + + [Fact] + public void Reset_MarksThePinAsNotEvaluated() + { + OutputPin output = _source.CreateOutputPin(); + output.Value = new Numeric(1); + output.IsEvaluated.ShouldBeTrue(); + + output.Reset(); + + output.IsEvaluated.ShouldBeFalse(); + } + + [Fact] + public void SettingAValue_RaisesPropertyChangedForPinValue() + { + OutputPin output = _source.CreateOutputPin(); + List changed = []; + output.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + output.Value = new Numeric(1); + + changed.ShouldContain(nameof(IPin.PinValue)); + output.PinValue.ShouldBe(new Numeric(1)); + } + + [Fact] + public void TypedInput_DoesNotConvertOtherNumberTypes() + { + // Numbers only flow between pins as Numeric, a Numeric input connected to a float output reads the default value + OutputPin output = _source.CreateOutputPin(); + InputPin input = _target.CreateInputPin(); + input.ConnectTo(output); + output.Value = 5f; + + input.Value.ShouldBe(new Numeric(0)); + } + + [Fact] + public void TypedInput_ReadsAMatchingValueFromAnObjectOutput() + { + OutputPin output = _source.CreateOutputPin(typeof(object)); + InputPin input = _target.CreateInputPin(); + input.ConnectTo(output); + output.Value = new Numeric(5); + + _target.Reset(); + input.Value.ShouldBe(new Numeric(5)); + + _target.Reset(); + output.Value = 5; + input.Value.ShouldBe(new Numeric(0)); + } + + [Fact] + public void ObjectInput_ReadsAnyValue() + { + OutputPin output = _source.CreateOutputPin(); + InputPin input = _target.CreateInputPin(typeof(object)); + input.ConnectTo(output); + output.Value = new Numeric(5); + + input.Value.ShouldBe(new Numeric(5)); + } + + [Fact] + public void UntypedInput_ReadingAValueOfTheWrongType_Throws() + { + OutputPin output = _source.CreateOutputPin(); + InputPin input = _target.CreateInputPin(typeof(Numeric)); + input.ConnectTo(output); + output.Value = 5f; + + Should.Throw(() => input.Value); + } + + [Fact] + public void UntypedInput_ForAValueType_TurnsNullIntoTheDefaultValue() + { + OutputPin output = _source.CreateOutputPin(typeof(object)); + InputPin input = _target.CreateInputPin(typeof(int)); + input.ConnectTo(output); + output.Value = null; + + input.Value.ShouldBe(0); + } + + [Fact] + public void UntypedInput_ForAReferenceType_PassesNullOn() + { + OutputPin output = _source.CreateOutputPin(typeof(string)); + InputPin input = _target.CreateInputPin(typeof(string)); + input.ConnectTo(output); + output.Value = null; + + input.Value.ShouldBeNull(); + } + + [Fact] + public void UntypedOutput_RejectsValuesOfTheWrongType() + { + OutputPin output = _source.CreateOutputPin(typeof(Numeric)); + + Should.Throw(() => output.Value = 5f); + } + + [Fact] + public void UntypedOutput_ForAValueType_AcceptsNull() + { + // Pinned as-is: the setter means to turn null into the default value for value types (see its comment), but + // then stores null anyway. A typed input reading the pin gets its default value, an untyped one turns the null + // into the default value itself. + OutputPin output = _source.CreateOutputPin(typeof(Numeric)); + output.Value = new Numeric(3); + + output.Value = null; + + output.Value.ShouldBeNull(); + } + + [Fact] + public void NewTypedInput_IgnoresItsConnectionUntilItIsReset() + { + // Pinned as-is: a new InputPin counts as evaluated with its default value, so it only reads the pin it is + // connected to after a reset. Running a script resets every pin first, so this only shows outside a script. + // Untyped input pins don't do this. + OutputPin output = _source.CreateOutputPin(); + InputPin typed = _target.CreateInputPin(); + InputPin untyped = _target.CreateInputPin(typeof(Numeric)); + typed.ConnectTo(output); + untyped.ConnectTo(output); + output.Value = new Numeric(5); + + typed.IsEvaluated.ShouldBeTrue(); + typed.Value.ShouldBe(new Numeric(0)); + untyped.Value.ShouldBe(new Numeric(5)); + + typed.Reset(); + typed.Value.ShouldBe(new Numeric(5)); + } + + [Fact] + public void UntypedOutput_StartsWithTheDefaultValueOfItsType() + { + _source.CreateOutputPin(typeof(Numeric)).Value.ShouldBe(new Numeric(0)); + _source.CreateOutputPin(typeof(string)).Value.ShouldBeNull(); + } + + #endregion + + #region Changing types + + [Fact] + public void InputPin_ChangeType_KeepsCompatibleConnectionsOnly() + { + OutputPin number = _source.CreateOutputPin(); + OutputPin text = _source.CreateOutputPin(); + InputPin first = _target.CreateInputPin(typeof(object)); + InputPin second = _target.CreateInputPin(typeof(object)); + first.ConnectTo(number); + second.ConnectTo(text); + + first.ChangeType(typeof(Numeric)); + second.ChangeType(typeof(Numeric)); + + first.Type.ShouldBe(typeof(Numeric)); + first.IsNumeric.ShouldBeTrue(); + first.ConnectedTo.ShouldBe([number]); + second.ConnectedTo.ShouldBeEmpty(); + text.ConnectedTo.ShouldBeEmpty(); + } + + [Fact] + public void InputPin_ChangeType_RaisesPropertyChangedForType() + { + InputPin input = _target.CreateInputPin(typeof(object)); + List changed = []; + input.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + input.ChangeType(typeof(Numeric)); + + changed.ShouldContain(nameof(IPin.Type)); + changed.ShouldContain(nameof(IPin.IsNumeric)); + } + + [Fact] + public void ChangeType_ToTheSameType_LeavesConnectionsAlone() + { + OutputPin output = _source.CreateOutputPin(typeof(Numeric)); + InputPin input = _target.CreateInputPin(); + input.ConnectTo(output); + int disconnects = 0; + output.PinDisconnected += (_, _) => disconnects++; + + output.ChangeType(typeof(Numeric)); + + disconnects.ShouldBe(0); + output.ConnectedTo.ShouldBe([input]); + } + + [Fact] + public void OutputPin_ChangeType_KeepsCompatibleConnectionsOnly_AndResetsItsValue() + { + OutputPin output = _source.CreateOutputPin(typeof(object)); + InputPin objectInput = _target.CreateInputPin(); + InputPin numericInput = _target.CreateInputPin(); + output.ConnectTo(objectInput); + output.ConnectTo(numericInput); + output.Value = "Hello"; + + output.ChangeType(typeof(string)); + + output.Type.ShouldBe(typeof(string)); + output.ConnectedTo.ShouldBe([objectInput]); + numericInput.ConnectedTo.ShouldBeEmpty(); + output.Value.ShouldBeNull(); + } + + [Fact] + public void OutputPin_ChangeType_ToAValueType_UsesItsDefaultValue() + { + OutputPin output = _source.CreateOutputPin(typeof(object)); + + output.ChangeType(typeof(Numeric)); + + output.Value.ShouldBe(new Numeric(0)); + output.IsNumeric.ShouldBeTrue(); + } + + #endregion + + [Fact] + public void Pin_KnowsItsNodeNameAndDirection() + { + InputPin input = _target.CreateInputPin("Amount"); + OutputPin output = _source.CreateOutputPin(typeof(string), "Text"); + + input.Node.ShouldBe(_target); + input.Name.ShouldBe("Amount"); + input.Direction.ShouldBe(PinDirection.Input); + input.Type.ShouldBe(typeof(Numeric)); + output.Node.ShouldBe(_source); + output.Name.ShouldBe("Text"); + output.Direction.ShouldBe(PinDirection.Output); + output.Type.ShouldBe(typeof(string)); + } + + [Fact] + public void Pin_RaisesPropertyChangedWhenRenamed() + { + InputPin input = _target.CreateInputPin("Amount"); + PropertyChangedEventArgs? args = null; + input.PropertyChanged += (_, e) => args = e; + + input.Name = "Total"; + + args.ShouldNotBeNull().PropertyName.ShouldBe(nameof(IPin.Name)); + } +} diff --git a/src/Artemis.Core.Tests/coverage.settings.xml b/src/Artemis.Core.Tests/coverage.settings.xml new file mode 100644 index 000000000..5976f068f --- /dev/null +++ b/src/Artemis.Core.Tests/coverage.settings.xml @@ -0,0 +1,12 @@ + + + + + + + + ^Microsoft\.EntityFrameworkCore\.Infrastructure\.DbContextAttribute$ + + + + diff --git a/src/Artemis.Core/Artemis.Core.csproj b/src/Artemis.Core/Artemis.Core.csproj index e61ffec1e..4c98505ed 100644 --- a/src/Artemis.Core/Artemis.Core.csproj +++ b/src/Artemis.Core/Artemis.Core.csproj @@ -19,6 +19,10 @@ + + + + diff --git a/src/Artemis.Core/RGB.NET/SKTexture.cs b/src/Artemis.Core/RGB.NET/SKTexture.cs index 0337d2b3c..e2b486181 100644 --- a/src/Artemis.Core/RGB.NET/SKTexture.cs +++ b/src/Artemis.Core/RGB.NET/SKTexture.cs @@ -28,17 +28,18 @@ public sealed class SKTexture : ITexture, IDisposable foreach (ArtemisDevice artemisDevice in devices) { + // Sample the same render-grid-aligned rectangle that layers use to clip and draw the LED foreach (ArtemisLed artemisLed in artemisDevice.Leds) - _ledRects[artemisLed.RgbLed] = CreateSampleRect(artemisLed.RgbLed.AbsoluteBoundary, scale, width, height); + _ledRects[artemisLed.RgbLed] = CreateSampleRect(artemisLed.AbsoluteRectangle, scale, width, height); } } #endregion - private static SKRectI CreateSampleRect(Rectangle boundary, float scale, int width, int height) + private static SKRectI CreateSampleRect(SKRect boundary, float scale, int width, int height) { - (int left, int right) = ScaleSpan(boundary.Location.X, boundary.Size.Width, scale, width); - (int top, int bottom) = ScaleSpan(boundary.Location.Y, boundary.Size.Height, scale, height); + (int left, int right) = ScaleSpan(boundary.Left, boundary.Width, scale, width); + (int top, int bottom) = ScaleSpan(boundary.Top, boundary.Height, scale, height); return new SKRectI(left, top, right, bottom); } diff --git a/src/Artemis.Core/Utilities/RenderScale.cs b/src/Artemis.Core/Utilities/RenderScale.cs index 43f27d54d..c72ae6fb7 100644 --- a/src/Artemis.Core/Utilities/RenderScale.cs +++ b/src/Artemis.Core/Utilities/RenderScale.cs @@ -28,8 +28,15 @@ internal static class RenderScale return SKRectI.Create( roundX - roundX % RenderScaleMultiplier, roundY - roundY % RenderScaleMultiplier, - roundWidth - roundWidth % RenderScaleMultiplier, - roundHeight - roundHeight % RenderScaleMultiplier + SnapSize(roundWidth), + SnapSize(roundHeight) ); } + + private static int SnapSize(int size) + { + // Keep at least one render pixel so LEDs smaller than a pixel don't collapse and remain addressable + int snapped = size - size % RenderScaleMultiplier; + return snapped == 0 && size > 0 ? RenderScaleMultiplier : snapped; + } } \ No newline at end of file diff --git a/src/Artemis.Storage/Artemis.Storage.csproj b/src/Artemis.Storage/Artemis.Storage.csproj index c37f5b716..a82843ea6 100644 --- a/src/Artemis.Storage/Artemis.Storage.csproj +++ b/src/Artemis.Storage/Artemis.Storage.csproj @@ -11,4 +11,8 @@ + + + + \ No newline at end of file diff --git a/src/Artemis.UI.Shared.Tests/Artemis.UI.Shared.Tests.csproj b/src/Artemis.UI.Shared.Tests/Artemis.UI.Shared.Tests.csproj new file mode 100644 index 000000000..1f3af6d6a --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/Artemis.UI.Shared.Tests.csproj @@ -0,0 +1,34 @@ + + + net10.0 + x64 + enable + enable + Exe + false + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Artemis.UI.Shared.Tests/Infrastructure/ProfileEditorTestBase.cs b/src/Artemis.UI.Shared.Tests/Infrastructure/ProfileEditorTestBase.cs new file mode 100644 index 000000000..6abdc0fcb --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/Infrastructure/ProfileEditorTestBase.cs @@ -0,0 +1,176 @@ +using System.Text.Json; +using Artemis.Core.LayerBrushes; +using Artemis.Core.LayerEffects; +using Artemis.Core.Tests.Infrastructure.Rendering; +using Artemis.UI.Shared.Services.ProfileEditor; +using Artemis.UI.Shared.Tests.Infrastructure.Services; +using Serilog.Core; + +namespace Artemis.UI.Shared.Tests.Infrastructure; + +/// +/// Base class for profile editor tests. Builds on , so +/// and a single ANSI keyboard are available, and adds a profile, a backed by test +/// services and helpers for comparing profiles before and after a command. +/// +public abstract class ProfileEditorTestBase : RenderTestBase +{ + private readonly Profile _scratchProfile; + + protected ProfileEditorTestBase() + { + Profile = Harness.CreateProfile(); + _scratchProfile = Harness.CreateProfile("Scratch profile"); + DeviceService.EnabledDeviceList.Add(Keyboard); + + Service = new ProfileEditorService(Logger.None, DeviceService, ProfileService, ModuleService, LayerBrushService, MainWindowService, WindowService); + } + + /// + /// Gets the profile the test works on, created with + /// + protected Profile Profile { get; } + + /// + /// Gets the root folder of + /// + protected Folder Root => Profile.GetRootFolder(); + + /// + /// Gets the profile configuration of + /// + protected ProfileConfiguration Configuration => Profile.Configuration; + + protected IProfileEditorService Service { get; } + protected TestProfileService ProfileService { get; } = new(); + protected TestModuleService ModuleService { get; } = new(); + protected TestDeviceService DeviceService { get; } = new(); + protected TestLayerBrushService LayerBrushService { get; } = new(); + protected TestMainWindowService MainWindowService { get; } = new(); + protected TestWindowService WindowService { get; } = new(); + + /// + /// Saves the way Artemis stores it and returns the stored JSON. Comparing the snapshot taken + /// before a command with the one taken after undoing it shows whether undo restored everything that gets saved. + /// + protected string Snapshot() + { + return JsonSerializer.Serialize(Harness.Save(Profile)); + } + + /// + /// Adds a layer with a covering the + /// given keyboard LEDs to the provided folder, or the root folder. + /// + protected Layer AddLayer(string name, Folder? parent = null, params RGB.NET.Core.LedId[] leds) + { + return Harness.AddLayer(parent ?? Root, Leds(leds), name).Layer; + } + + /// + /// Gets the descriptor of a test brush, used by commands that create brushes + /// + protected LayerBrushDescriptor BrushDescriptor() where TBrush : BaseLayerBrush + { + Layer scratchLayer = new(_scratchProfile.GetRootFolder(), "Scratch layer"); + _scratchProfile.GetRootFolder().AddChild(scratchLayer); + return Harness.ChangeBrush(scratchLayer).Descriptor; + } + + /// + /// Creates a new test effect for the provided element without adding it, the way the editor creates effects before + /// handing them to + /// + protected TEffect NewEffect(RenderProfileElement element) where TEffect : BaseLayerEffect + { + Layer scratchLayer = new(_scratchProfile.GetRootFolder(), "Scratch layer"); + _scratchProfile.GetRootFolder().AddChild(scratchLayer); + LayerEffectDescriptor descriptor = Harness.AddEffect(scratchLayer).Descriptor!; + return (TEffect) descriptor.CreateInstance(element, null); + } + + /// + /// Gets whether the properties of a brush or effect were disposed, which happens when the brush or effect is disposed + /// + protected static bool IsDisposed(LayerPropertyGroup properties) + { + try + { + properties.GetAllLayerProperties(); + return false; + } + catch (ObjectDisposedException) + { + return true; + } + } + + /// + /// Makes the profile being edited, like opening it in the profile editor + /// + protected async Task StartEditing() + { + await Service.ChangeCurrentProfileConfiguration(Configuration); + } + + /// + /// Gets the value an observable of the service currently holds + /// + protected static T Current(IObservable observable) + { + T value = default!; + bool received = false; + using (observable.Subscribe(v => + { + if (received) + return; + value = v; + received = true; + })) + { + } + + if (!received) + throw new InvalidOperationException("The observable has no current value"); + return value; + } + + /// + /// Gets the history of the profile configuration currently being edited + /// + protected ProfileEditorHistory CurrentHistory => Current(Service.History) ?? throw new InvalidOperationException("No profile is being edited"); + + /// + /// Undoes the last command of the profile being edited + /// + protected IProfileEditorCommand? Undo() + { + IProfileEditorCommand? result = null; + CurrentHistory.Undo.Execute().Subscribe(c => result = c); + return result; + } + + /// + /// Redoes the last undone command of the profile being edited + /// + protected IProfileEditorCommand? Redo() + { + IProfileEditorCommand? result = null; + CurrentHistory.Redo.Execute().Subscribe(c => result = c); + return result; + } + + /// + /// Waits until the condition is met, for things the service does on another thread + /// + protected static async Task WaitUntil(Func condition, int timeoutMilliseconds = 5000) + { + DateTime end = DateTime.UtcNow.AddMilliseconds(timeoutMilliseconds); + while (!condition()) + { + if (DateTime.UtcNow > end) + throw new TimeoutException("The condition was not met in time"); + await Task.Delay(20, TestContext.Current.CancellationToken); + } + } +} diff --git a/src/Artemis.UI.Shared.Tests/Infrastructure/RecordingCommand.cs b/src/Artemis.UI.Shared.Tests/Infrastructure/RecordingCommand.cs new file mode 100644 index 000000000..1692b35e2 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/Infrastructure/RecordingCommand.cs @@ -0,0 +1,38 @@ +using Artemis.UI.Shared.Services.ProfileEditor; + +namespace Artemis.UI.Shared.Tests.Infrastructure; + +/// +/// A command that only writes down what happened to it, in a log that can be shared between commands to check the +/// order they ran in. +/// +public class RecordingCommand(string name, List? log = null) : IProfileEditorCommand, IDisposable +{ + public List Log { get; } = log ?? []; + public bool Disposed { get; private set; } + + /// + /// Gets or sets an exception should throw + /// + public Exception? ExecuteException { get; set; } + + public string DisplayName => name; + + public void Execute() + { + if (ExecuteException != null) + throw ExecuteException; + Log.Add($"execute {name}"); + } + + public void Undo() + { + Log.Add($"undo {name}"); + } + + public void Dispose() + { + Disposed = true; + Log.Add($"dispose {name}"); + } +} diff --git a/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestDeviceService.cs b/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestDeviceService.cs new file mode 100644 index 000000000..b1ea54dd2 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestDeviceService.cs @@ -0,0 +1,41 @@ +using Artemis.Core.DeviceProviders; +using Artemis.Core.Services; + +namespace Artemis.UI.Shared.Tests.Infrastructure.Services; + +/// +/// Hands the profile editor a fixed list of enabled devices. +/// +public class TestDeviceService : IDeviceService +{ + /// + /// Gets the devices reported as enabled, add the devices of the render harness here + /// + public List EnabledDeviceList { get; } = []; + + public IReadOnlyCollection EnabledDevices => EnabledDeviceList; + public IReadOnlyCollection Devices => EnabledDeviceList; + public IReadOnlyCollection SuspendedDeviceProviders { get; } = []; + + public void IdentifyDevice(ArtemisDevice device) => throw new NotSupportedException(); + public void AddDeviceProvider(DeviceProvider deviceProvider) => throw new NotSupportedException(); + public void RemoveDeviceProvider(DeviceProvider deviceProvider) => throw new NotSupportedException(); + public void AutoArrangeDevices(bool leftHanded) => throw new NotSupportedException(); + public void LoadDeviceLayout(ArtemisDevice device) => throw new NotSupportedException(); + public void EnableDevice(ArtemisDevice device) => throw new NotSupportedException(); + public void DisableDevice(ArtemisDevice device) => throw new NotSupportedException(); + public void SaveDevice(ArtemisDevice artemisDevice) => throw new NotSupportedException(); + public void SaveDevices() => throw new NotSupportedException(); + public void SuspendDeviceProviders() => throw new NotSupportedException(); + public void ResumeDeviceProviders() => throw new NotSupportedException(); + +#pragma warning disable CS0067 // Events are part of the interface but never raised here + public event EventHandler? DeviceAdded; + public event EventHandler? DeviceRemoved; + public event EventHandler? DeviceEnabled; + public event EventHandler? DeviceDisabled; + public event EventHandler? DeviceProviderAdded; + public event EventHandler? DeviceProviderRemoved; + public event EventHandler? LedsChanged; +#pragma warning restore CS0067 +} diff --git a/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestLayerBrushService.cs b/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestLayerBrushService.cs new file mode 100644 index 000000000..959ef0411 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestLayerBrushService.cs @@ -0,0 +1,31 @@ +using Artemis.Core.LayerBrushes; +using Artemis.Core.Services; + +namespace Artemis.UI.Shared.Tests.Infrastructure.Services; + +/// +/// Applies a brush of the test's choosing as the default brush. +/// +public class TestLayerBrushService : ILayerBrushService +{ + /// + /// Gets or sets the brush gives layers, none if + /// + public LayerBrushDescriptor? DefaultBrush { get; set; } + + public LayerBrushDescriptor? GetDefaultLayerBrush() + { + return DefaultBrush; + } + + public void ApplyDefaultBrush(Layer layer) + { + // Same as LayerBrushService + if (DefaultBrush != null) + layer.ChangeLayerBrush(DefaultBrush.CreateInstance(layer, null)); + } + + public LayerBrushRegistration RegisterLayerBrush(LayerBrushDescriptor descriptor) => throw new NotSupportedException(); + public void RemoveLayerBrush(LayerBrushRegistration registration) => throw new NotSupportedException(); + public List GetLayerBrushes() => throw new NotSupportedException(); +} diff --git a/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestMainWindowService.cs b/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestMainWindowService.cs new file mode 100644 index 000000000..73a9271a6 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestMainWindowService.cs @@ -0,0 +1,29 @@ +using Artemis.UI.Shared.Services.MainWindow; + +namespace Artemis.UI.Shared.Tests.Infrastructure.Services; + +/// +/// A main window service without a window, pretends the window was closed. +/// +public class TestMainWindowService : IMainWindowService +{ + public bool IsMainWindowOpen => true; + public bool IsMainWindowFocused => true; + + public void RaiseMainWindowClosed() + { + MainWindowClosed?.Invoke(this, EventArgs.Empty); + } + + public void ConfigureMainWindowProvider(IMainWindowProvider mainWindowProvider) => throw new NotSupportedException(); + public void OpenMainWindow() => throw new NotSupportedException(); + public void CloseMainWindow() => throw new NotSupportedException(); + + public event EventHandler? MainWindowClosed; + +#pragma warning disable CS0067 // Events are part of the interface but never raised here + public event EventHandler? MainWindowOpened; + public event EventHandler? MainWindowFocused; + public event EventHandler? MainWindowUnfocused; +#pragma warning restore CS0067 +} diff --git a/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestModuleService.cs b/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestModuleService.cs new file mode 100644 index 000000000..70dd30e97 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestModuleService.cs @@ -0,0 +1,28 @@ +using Artemis.Core.Modules; +using Artemis.Core.Services; + +namespace Artemis.UI.Shared.Tests.Infrastructure.Services; + +/// +/// Records the activation overrides the profile editor sets. +/// +public class TestModuleService : IModuleService +{ + /// + /// Gets every module passed to , in order + /// + public List ActivationOverrides { get; } = []; + + public void SetActivationOverride(Module? module) + { + ActivationOverrides.Add(module); + } + + public void UpdateActiveModules(double deltaTime) => throw new NotSupportedException(); + public void UpdateModuleActivation() => throw new NotSupportedException(); + +#pragma warning disable CS0067 // Events are part of the interface but never raised here + public event EventHandler? ModuleActivated; + public event EventHandler? ModuleDeactivated; +#pragma warning restore CS0067 +} diff --git a/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestProfileService.cs b/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestProfileService.cs new file mode 100644 index 000000000..b91b85265 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestProfileService.cs @@ -0,0 +1,89 @@ +using System.Collections.ObjectModel; +using Artemis.Core.Services; +using SkiaSharp; + +namespace Artemis.UI.Shared.Tests.Infrastructure.Services; + +/// +/// Records what the profile editor asks of the profile service, without a database or render loop. +/// +public class TestProfileService : IProfileService +{ + private ProfileElement? _focusProfileElement; + + /// + /// Gets every value was set to, in order + /// + public List FocusProfileElementChanges { get; } = []; + + /// + /// Gets every profile configuration passed to , in order + /// + public List ActivatedProfiles { get; } = []; + + /// + /// Gets every call to , in order. Saving may happen on another thread. + /// + public List<(Profile Profile, bool IncludeChildren)> SavedProfiles { get; } = []; + + /// + /// Gets or sets an exception should throw + /// + public Exception? SaveException { get; set; } + + public ReadOnlyCollection ProfileCategories { get; } = new([]); + public ProfileConfiguration? FocusProfile { get; set; } + + public ProfileElement? FocusProfileElement + { + get => _focusProfileElement; + set + { + _focusProfileElement = value; + FocusProfileElementChanges.Add(value); + } + } + + public bool UpdateFocusProfile { get; set; } + public bool ProfileRenderingDisabled { get; set; } + + public Profile ActivateProfile(ProfileConfiguration profileConfiguration) + { + // Like ProfileService, a profile that is already active is returned as-is + ActivatedProfiles.Add(profileConfiguration); + return profileConfiguration.Profile ?? throw new InvalidOperationException("Test profile configurations come with a profile"); + } + + public void SaveProfile(Profile profile, bool includeChildren) + { + lock (SavedProfiles) + SavedProfiles.Add((profile, includeChildren)); + if (SaveException != null) + throw SaveException; + } + + public ProfileConfiguration CloneProfileConfiguration(ProfileConfiguration profileConfiguration) => throw new NotSupportedException(); + public void DeactivateProfile(ProfileConfiguration profileConfiguration) => throw new NotSupportedException(); + public void SaveProfileCategory(ProfileCategory profileCategory) => throw new NotSupportedException(); + public ProfileCategory CreateProfileCategory(string name, bool addToTop = false) => throw new NotSupportedException(); + public void DeleteProfileCategory(ProfileCategory profileCategory) => throw new NotSupportedException(); + public ProfileConfiguration CreateProfileConfiguration(ProfileCategory category, string name, string icon) => throw new NotSupportedException(); + public void RemoveProfileConfiguration(ProfileConfiguration profileConfiguration) => throw new NotSupportedException(); + public Task ExportProfile(ProfileConfiguration profileConfiguration) => throw new NotSupportedException(); + + public Task ImportProfile(Stream archiveStream, ProfileCategory category, bool makeUnique, bool markAsFreshImport, string? nameAffix = "imported", + ProfileConfiguration? target = null) => throw new NotSupportedException(); + + public void AdaptProfile(Profile profile) => throw new NotSupportedException(); + public void UpdateProfiles(double deltaTime) => throw new NotSupportedException(); + public void RenderProfiles(SKCanvas canvas) => throw new NotSupportedException(); + +#pragma warning disable CS0067 // Events are part of the interface but never raised here + public event EventHandler? ProfileActivated; + public event EventHandler? ProfileDeactivated; + public event EventHandler? ProfileCategoryAdded; + public event EventHandler? ProfileCategoryRemoved; + public event EventHandler? ProfileRemoved; + public event EventHandler? ProfileAdded; +#pragma warning restore CS0067 +} diff --git a/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestWindowService.cs b/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestWindowService.cs new file mode 100644 index 000000000..4499868ea --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/Infrastructure/Services/TestWindowService.cs @@ -0,0 +1,35 @@ +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.Builders; +using Avalonia.Controls; + +namespace Artemis.UI.Shared.Tests.Infrastructure.Services; + +/// +/// A window service that opens nothing, it only records the exception dialogs it was asked to show. +/// +public class TestWindowService : IWindowService +{ + /// + /// Gets every exception dialog that would have been shown, in order. Dialogs may be requested from another thread. + /// + public List<(string Title, Exception Exception)> ExceptionDialogs { get; } = []; + + public void ShowExceptionDialog(string title, Exception exception, string? customMessage = null) + { + lock (ExceptionDialogs) + ExceptionDialogs.Add((title, exception)); + } + + public Window ShowWindow(out TViewModel viewModel, params object[] parameters) => throw new NotSupportedException(); + public Window ShowWindow(object viewModel) => throw new NotSupportedException(); + public Task ShowDialogAsync(params object[] parameters) => throw new NotSupportedException(); + public Task ShowDialogAsync(object viewModel) => throw new NotSupportedException(); + public Task ShowDialogAsync(DialogViewModelBase viewModel) => throw new NotSupportedException(); + public Task ShowDialogAsync(params object[] parameters) where TViewModel : DialogViewModelBase => throw new NotSupportedException(); + public Task ShowConfirmContentDialog(string title, string message, string confirm = "Confirm", string? cancel = "Cancel") => throw new NotSupportedException(); + public OpenFolderDialogBuilder CreateOpenFolderDialog() => throw new NotSupportedException(); + public OpenFileDialogBuilder CreateOpenFileDialog() => throw new NotSupportedException(); + public SaveFileDialogBuilder CreateSaveFileDialog() => throw new NotSupportedException(); + public ContentDialogBuilder CreateContentDialog() => throw new NotSupportedException(); + public Window? GetCurrentWindow() => throw new NotSupportedException(); +} diff --git a/src/Artemis.UI.Shared.Tests/Infrastructure/TestCondition.cs b/src/Artemis.UI.Shared.Tests/Infrastructure/TestCondition.cs new file mode 100644 index 000000000..cabb0f8d8 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/Infrastructure/TestCondition.cs @@ -0,0 +1,46 @@ +using Artemis.Storage.Entities.Profile.Abstract; +using Artemis.Storage.Entities.Profile.Conditions; + +namespace Artemis.UI.Shared.Tests.Infrastructure; + +/// +/// An always-on condition that remembers whether it was disposed +/// +public class TestCondition(RenderProfileElement profileElement) : ICondition +{ + public bool Disposed { get; private set; } + + public IConditionEntity Entity { get; } = new AlwaysOnConditionEntity(); + public RenderProfileElement ProfileElement { get; } = profileElement; + public bool IsMet => true; + + public void Update() + { + } + + public void UpdateTimeline(double deltaTime) + { + } + + public void OverrideTimeline(TimeSpan position) + { + } + + public void Load() + { + } + + public void Save() + { + } + + public IEnumerable GetFeatureDependencies() + { + return []; + } + + public void Dispose() + { + Disposed = true; + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/CompositeCommandTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/CompositeCommandTests.cs new file mode 100644 index 000000000..542387315 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/CompositeCommandTests.cs @@ -0,0 +1,84 @@ +using Artemis.UI.Shared.Services.ProfileEditor; +using Artemis.UI.Shared.Services.ProfileEditor.Commands; +using Artemis.UI.Shared.Tests.Infrastructure; +using RGB.NET.Core; + +namespace Artemis.UI.Shared.Tests.ProfileEditor.Commands; + +/// +/// Running several commands as one +/// +public class CompositeCommandTests : ProfileEditorTestBase +{ + private readonly List _log = []; + + [Fact] + public void Execute_RunsTheCommandsInOrderAndUndoInReverse() + { + CompositeCommand command = new([new RecordingCommand("A", _log), new RecordingCommand("B", _log)], "Both"); + + command.Execute(); + command.Undo(); + + command.DisplayName.ShouldBe("Both"); + _log.ShouldBe(["execute A", "execute B", "undo B", "undo A"]); + } + + [Fact] + public void Execute_CanSkipTheFirstExecute() + { + // Used by command scopes, whose commands already ran when they were added + CompositeCommand command = new([new RecordingCommand("A", _log)], "Scope", true); + + command.Execute(); + command.Undo(); + command.Execute(); + + _log.ShouldBe(["undo A", "execute A"]); + } + + [Fact] + public void Dispose_DisposesEveryCommand() + { + RecordingCommand a = new("A", _log); + RecordingCommand b = new("B", _log); + + new CompositeCommand([a, b], "Both").Dispose(); + + a.Disposed.ShouldBeTrue(); + b.Disposed.ShouldBeTrue(); + } + + [Fact] + public void Constructor_NeedsCommandsAndAName() + { + Should.Throw(() => new CompositeCommand(null!, "Name")); + Should.Throw(() => new CompositeCommand([], null!)); + Should.Throw(() => new CompositeCommand(null!, "Name", true)); + Should.Throw(() => new CompositeCommand([], null!, true)); + } + + [Fact] + public void Undo_RestoresTheProfileAfterSeveralRealCommands() + { + Folder folder = Harness.AddFolder(Root, "Folder"); + Layer layer = AddLayer("Layer", folder, LedId.Keyboard_A); + string before = Snapshot(); + Layer newLayer = new(Root, "New layer"); + CompositeCommand command = new( + [ + new AddProfileElement(newLayer, Root, 0), + new MoveProfileElement(Root, layer, 0), + new RenameProfileElement(folder, "Renamed"), + new ChangeLayerLeds(layer, [Led(LedId.Keyboard_Z)]), + new UpdateLayerProperty(layer.Transform.Opacity, 30, null) + ], "Lots of changes"); + + command.Execute(); + Root.Children.ShouldBe([layer, newLayer, folder]); + + command.Undo(); + Root.Children.ShouldBe([folder]); + Snapshot().ShouldBe(before); + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/ConditionCommandTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/ConditionCommandTests.cs new file mode 100644 index 000000000..386fb8c35 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/ConditionCommandTests.cs @@ -0,0 +1,242 @@ +using Artemis.Core.Tests.Infrastructure.Plugins; +using Artemis.Core.Tests.Infrastructure.Plugins.Modules; +using Artemis.UI.Shared.Services.ProfileEditor; +using Artemis.UI.Shared.Services.ProfileEditor.Commands; +using Artemis.UI.Shared.Tests.Infrastructure; +using RGB.NET.Core; + +namespace Artemis.UI.Shared.Tests.ProfileEditor.Commands; + +/// +/// Changing display conditions and their settings +/// +public class ConditionCommandTests : ProfileEditorTestBase +{ + private readonly Layer _layer; + + public ConditionCommandTests() + { + _layer = AddLayer("Layer", null, LedId.Keyboard_A); + } + + public static TheoryData ConditionTypeCommands => ["type", "mode"]; + + // ChangeConditionType and ChangeElementDisplayCondition do the same thing, only their names differ + private static IProfileEditorCommand ChangeCondition(string command, RenderProfileElement element, ICondition condition) + { + return command == "type" ? new ChangeConditionType(element, condition) : new ChangeElementDisplayCondition(element, condition); + } + + [Fact] + public void ChangeCondition_DisplayNames() + { + new ChangeConditionType(_layer, new TestCondition(_layer)).DisplayName.ShouldBe("Change element display condition type"); + new ChangeElementDisplayCondition(_layer, new TestCondition(_layer)).DisplayName.ShouldBe("Change display condition mode"); + } + + [Theory] + [MemberData(nameof(ConditionTypeCommands))] + public void ChangeCondition_ReplacesTheConditionAndUndoPutsTheSameOneBack(string commandType) + { + ICondition original = _layer.DisplayCondition; + string before = Snapshot(); + StaticCondition condition = new(_layer); + IProfileEditorCommand command = ChangeCondition(commandType, _layer, condition); + + command.Execute(); + _layer.DisplayCondition.ShouldBe(condition); + + command.Undo(); + _layer.DisplayCondition.ShouldBe(original); + Snapshot().ShouldBe(before); + } + + [Theory] + [MemberData(nameof(ConditionTypeCommands))] + public void ChangeCondition_Dispose_DisposesTheConditionNoLongerInUse(string commandType) + { + TestCondition original = new(_layer); + _layer.DisplayCondition = original; + TestCondition executedCondition = new(_layer); + IProfileEditorCommand executed = ChangeCondition(commandType, _layer, executedCondition); + executed.Execute(); + + ((IDisposable) executed).Dispose(); + + original.Disposed.ShouldBeTrue(); + executedCondition.Disposed.ShouldBeFalse(); + } + + [Theory] + [MemberData(nameof(ConditionTypeCommands))] + public void ChangeCondition_Dispose_AfterUndo_DisposesTheNewCondition(string commandType) + { + TestCondition original = new(_layer); + _layer.DisplayCondition = original; + TestCondition undoneCondition = new(_layer); + IProfileEditorCommand undone = ChangeCondition(commandType, _layer, undoneCondition); + undone.Execute(); + undone.Undo(); + + ((IDisposable) undone).Dispose(); + + original.Disposed.ShouldBeFalse(); + undoneCondition.Disposed.ShouldBeTrue(); + } + + [Fact] + public void UpdateStaticPlayModeAndStopMode_ChangeAndUndoRestores() + { + StaticCondition condition = new(_layer); + _layer.DisplayCondition = condition; + string before = Snapshot(); + UpdateStaticPlayMode playMode = new(condition, StaticPlayMode.Once); + UpdateStaticStopMode stopMode = new(condition, StaticStopMode.SkipToEnd); + + playMode.Execute(); + stopMode.Execute(); + playMode.DisplayName.ShouldBe("Update condition play mode"); + stopMode.DisplayName.ShouldBe("Update condition stop mode"); + condition.PlayMode.ShouldBe(StaticPlayMode.Once); + condition.StopMode.ShouldBe(StaticStopMode.SkipToEnd); + + stopMode.Undo(); + playMode.Undo(); + condition.PlayMode.ShouldBe(StaticPlayMode.Repeat); + condition.StopMode.ShouldBe(StaticStopMode.Finish); + Snapshot().ShouldBe(before); + } + + [Fact] + public void UpdateEventModes_ChangeAndUndoRestores() + { + EventCondition condition = new(_layer); + _layer.DisplayCondition = condition; + EventTriggerMode triggerMode = condition.TriggerMode; + EventOverlapMode overlapMode = condition.OverlapMode; + EventToggleOffMode toggleOffMode = condition.ToggleOffMode; + string before = Snapshot(); + UpdateEventTriggerMode triggerCommand = new(condition, EventTriggerMode.Toggle); + UpdateEventOverlapMode overlapCommand = new(condition, EventOverlapMode.Copy); + UpdateEventToggleOffMode toggleOffCommand = new(condition, EventToggleOffMode.SkipToEnd); + + triggerCommand.Execute(); + overlapCommand.Execute(); + toggleOffCommand.Execute(); + triggerCommand.DisplayName.ShouldBe("Update event trigger mode"); + overlapCommand.DisplayName.ShouldBe("Update event overlap mode"); + toggleOffCommand.DisplayName.ShouldBe("Update event toggle off mode"); + condition.TriggerMode.ShouldBe(EventTriggerMode.Toggle); + condition.OverlapMode.ShouldBe(EventOverlapMode.Copy); + condition.ToggleOffMode.ShouldBe(EventToggleOffMode.SkipToEnd); + + toggleOffCommand.Undo(); + overlapCommand.Undo(); + triggerCommand.Undo(); + condition.TriggerMode.ShouldBe(triggerMode); + condition.OverlapMode.ShouldBe(overlapMode); + condition.ToggleOffMode.ShouldBe(toggleOffMode); + Snapshot().ShouldBe(before); + } + + [Fact] + public void UpdateEventConditionPath_ChangesThePathAndTheStartNode() + { + TestModule module = AddTestModule(); + EventCondition condition = new(_layer); + _layer.DisplayCondition = condition; + DataModelPath path = new(module.DataModel, "Integer"); + UpdateEventConditionPath command = new(condition, path); + + command.Execute(); + + command.DisplayName.ShouldBe("Update event path"); + condition.EventPath.ShouldBe(path); + // A regular value gets a start node that fires when the value changes + condition.GetStartNode().GetType().Name.ShouldBe("EventConditionValueChangedStartNode"); + condition.Script.Nodes.ShouldContain(condition.GetStartNode()); + } + + [Fact] + public void UpdateEventConditionPath_Undo_RestoresThePreviousPathAndStartNode() + { + TestModule module = AddTestModule(); + EventCondition condition = new(_layer); + _layer.DisplayCondition = condition; + DataModelPath integerPath = new(module.DataModel, "Integer"); + new UpdateEventConditionPath(condition, integerPath).Execute(); + string before = Snapshot(); + DataModelPath eventPath = new(module.DataModel, "Event"); + UpdateEventConditionPath command = new(condition, eventPath); + + command.Execute(); + condition.GetStartNode().GetType().Name.ShouldBe("EventConditionEventStartNode"); + + command.Undo(); + condition.EventPath.ShouldBe(integerPath); + condition.GetStartNode().GetType().Name.ShouldBe("EventConditionValueChangedStartNode"); + condition.Script.Nodes.Count(n => n.GetType().Name.StartsWith("EventCondition")).ShouldBe(1); + Snapshot().ShouldBe(before); + } + + [Fact] + public void UpdateEventConditionPath_Undo_ToNoPath_KeepsTheNewStartNode() + { + TestModule module = AddTestModule(); + EventCondition condition = new(_layer); + _layer.DisplayCondition = condition; + INode originalStartNode = condition.GetStartNode(); + UpdateEventConditionPath command = new(condition, new DataModelPath(module.DataModel, "Integer")); + + command.Execute(); + command.Undo(); + + // Pinned as-is: UpdateEventNode does nothing without a path, so undoing back to "no path" keeps the start node + // made for the path that was undone + condition.EventPath.ShouldBeNull(); + condition.GetStartNode().ShouldNotBe(originalStartNode); + condition.GetStartNode().GetType().Name.ShouldBe("EventConditionValueChangedStartNode"); + } + + [Fact] + public void UpdateEventConditionPath_Dispose_DisposesThePathNoLongerInUse() + { + TestModule module = AddTestModule(); + EventCondition condition = new(_layer); + DataModelPath oldPath = new(module.DataModel, "Integer"); + new UpdateEventConditionPath(condition, oldPath).Execute(); + DataModelPath executedPath = new(module.DataModel, "Float"); + UpdateEventConditionPath executed = new(condition, executedPath); + executed.Execute(); + DataModelPath undonePath = new(module.DataModel, "Double"); + UpdateEventConditionPath undone = new(condition, undonePath); + undone.Execute(); + undone.Undo(); + + executed.Dispose(); + undone.Dispose(); + + IsDisposed(oldPath).ShouldBeTrue(); + IsDisposed(executedPath).ShouldBeFalse(); + IsDisposed(undonePath).ShouldBeTrue(); + } + + private TestModule AddTestModule() + { + TestPlugin plugin = Track(new TestPlugin()); + return plugin.AddFeature(new TestModule()); + } + + private static bool IsDisposed(DataModelPath path) + { + try + { + path.GetValue(); + return false; + } + catch (ObjectDisposedException) + { + return true; + } + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/KeyframeCommandTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/KeyframeCommandTests.cs new file mode 100644 index 000000000..253ca8983 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/KeyframeCommandTests.cs @@ -0,0 +1,233 @@ +using Artemis.UI.Shared.Services.ProfileEditor.Commands; +using Artemis.UI.Shared.Tests.Infrastructure; +using RGB.NET.Core; + +namespace Artemis.UI.Shared.Tests.ProfileEditor.Commands; + +/// +/// Deleting, duplicating, moving and easing keyframes, and turning keyframes on and off +/// +public class KeyframeCommandTests : ProfileEditorTestBase +{ + private readonly FloatLayerProperty _opacity; + private readonly LayerPropertyKeyframe _first; + private readonly LayerPropertyKeyframe _second; + + public KeyframeCommandTests() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + _opacity = layer.Transform.Opacity; + _opacity.KeyframesEnabled = true; + _first = new LayerPropertyKeyframe(20, TimeSpan.FromSeconds(1), Easings.Functions.Linear, _opacity); + _second = new LayerPropertyKeyframe(80, TimeSpan.FromSeconds(2), Easings.Functions.Linear, _opacity); + _opacity.AddKeyframe(_first); + _opacity.AddKeyframe(_second); + } + + [Fact] + public void DeleteKeyframe_RemovesTheKeyframe() + { + DeleteKeyframe command = new(_first); + + command.Execute(); + + command.DisplayName.ShouldBe("Delete keyframe"); + _opacity.Keyframes.ShouldBe([_second]); + } + + [Fact] + public void DeleteKeyframe_Undo_PutsTheSameKeyframeBack() + { + string before = Snapshot(); + DeleteKeyframe command = new(_first); + + command.Execute(); + command.Undo(); + + _opacity.Keyframes.ShouldBe([_first, _second]); + Snapshot().ShouldBe(before); + } + + [Fact] + public void DeleteKeyframe_Undo_OfTheLastKeyframe_TurnsKeyframesBackOn() + { + DeleteKeyframe deleteFirst = new(_first); + DeleteKeyframe deleteSecond = new(_second); + deleteFirst.Execute(); + deleteSecond.Execute(); + _opacity.KeyframesEnabled = false; + + deleteSecond.Undo(); + + // Adding a keyframe always turns keyframes on + _opacity.KeyframesEnabled.ShouldBeTrue(); + } + + [Fact] + public void DuplicateKeyframe_AddsACopyAtThePosition() + { + _first.EasingFunction = Easings.Functions.CubicEaseIn; + DuplicateKeyframe command = new(_first, TimeSpan.FromSeconds(3)); + + command.Execute(); + + command.DisplayName.ShouldBe("Duplicate keyframe"); + LayerPropertyKeyframe copy = command.Duplication.ShouldBeOfType>(); + _opacity.Keyframes.ShouldBe([_first, _second, copy]); + copy.Value.ShouldBe(20); + copy.Position.ShouldBe(TimeSpan.FromSeconds(3)); + copy.EasingFunction.ShouldBe(Easings.Functions.CubicEaseIn); + } + + [Fact] + public void DuplicateKeyframe_UndoAndRedo_ReuseTheSameCopy() + { + string before = Snapshot(); + DuplicateKeyframe command = new(_first, TimeSpan.FromSeconds(3)); + command.Execute(); + ILayerPropertyKeyframe copy = command.Duplication!; + + command.Undo(); + Snapshot().ShouldBe(before); + + command.Execute(); + command.Duplication.ShouldBe(copy); + _opacity.Keyframes.ShouldContain((LayerPropertyKeyframe) copy); + } + + [Fact] + public void DuplicateKeyframe_Undo_BeforeExecute_DoesNothing() + { + new DuplicateKeyframe(_first, TimeSpan.FromSeconds(3)).Undo(); + + _opacity.Keyframes.ShouldBe([_first, _second]); + } + + [Fact] + public void MoveKeyframe_MovesAndUndoRestoresThePosition() + { + string before = Snapshot(); + MoveKeyframe command = new(_first, TimeSpan.FromSeconds(3)); + + command.Execute(); + command.DisplayName.ShouldBe("Move keyframe"); + _first.Position.ShouldBe(TimeSpan.FromSeconds(3)); + // Keyframes stay sorted by position + _opacity.Keyframes.ShouldBe([_second, _first]); + + command.Undo(); + _first.Position.ShouldBe(TimeSpan.FromSeconds(1)); + _opacity.Keyframes.ShouldBe([_first, _second]); + Snapshot().ShouldBe(before); + } + + [Fact] + public void MoveKeyframe_WithAnOriginalPosition_UndoesToThatPosition() + { + // The timeline moves keyframes while dragging and passes where the drag started + _first.Position = TimeSpan.FromSeconds(1.5); + MoveKeyframe command = new(_first, TimeSpan.FromSeconds(1.75), TimeSpan.FromSeconds(1)); + + command.Execute(); + _first.Position.ShouldBe(TimeSpan.FromSeconds(1.75)); + + command.Undo(); + _first.Position.ShouldBe(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ChangeKeyframeEasing_ChangesAndUndoRestoresTheEasing() + { + string before = Snapshot(); + ChangeKeyframeEasing command = new(_first, Easings.Functions.CubicEaseInOut); + + command.Execute(); + command.DisplayName.ShouldBe("Change easing to cubic ease in out"); + _first.EasingFunction.ShouldBe(Easings.Functions.CubicEaseInOut); + + command.Undo(); + _first.EasingFunction.ShouldBe(Easings.Functions.Linear); + Snapshot().ShouldBe(before); + } + + [Fact] + public void ToggleLayerPropertyKeyframes_Enable_AddsAKeyframeWithTheCurrentValue() + { + FloatLayerProperty rotation = ((Layer) _opacity.ProfileElement).Transform.Rotation; + rotation.SetCurrentValue(45); + ToggleLayerPropertyKeyframes command = new(rotation, true, TimeSpan.FromSeconds(2)); + + command.Execute(); + + command.DisplayName.ShouldBe("Enable keyframes"); + rotation.KeyframesEnabled.ShouldBeTrue(); + LayerPropertyKeyframe keyframe = rotation.Keyframes.ShouldHaveSingleItem(); + keyframe.Value.ShouldBe(45); + keyframe.Position.ShouldBe(TimeSpan.FromSeconds(2)); + keyframe.EasingFunction.ShouldBe(Easings.Functions.Linear); + } + + [Fact] + public void ToggleLayerPropertyKeyframes_Enable_UndoAndRedo() + { + FloatLayerProperty rotation = ((Layer) _opacity.ProfileElement).Transform.Rotation; + string before = Snapshot(); + ToggleLayerPropertyKeyframes command = new(rotation, true, TimeSpan.FromSeconds(2)); + command.Execute(); + LayerPropertyKeyframe keyframe = rotation.Keyframes.Single(); + + command.Undo(); + rotation.KeyframesEnabled.ShouldBeFalse(); + rotation.Keyframes.ShouldBeEmpty(); + Snapshot().ShouldBe(before); + + command.Execute(); + rotation.Keyframes.ShouldBe([keyframe]); + } + + [Fact] + public void ToggleLayerPropertyKeyframes_Enable_WithKeyframesAlreadyThere_AddsNone() + { + _opacity.KeyframesEnabled = false; + string before = Snapshot(); + ToggleLayerPropertyKeyframes command = new(_opacity, true, TimeSpan.FromSeconds(3)); + + command.Execute(); + _opacity.KeyframesEnabled.ShouldBeTrue(); + _opacity.Keyframes.ShouldBe([_first, _second]); + + command.Undo(); + _opacity.KeyframesEnabled.ShouldBeFalse(); + Snapshot().ShouldBe(before); + } + + [Fact] + public void ToggleLayerPropertyKeyframes_Disable_UndoTurnsThemBackOn() + { + string before = Snapshot(); + ToggleLayerPropertyKeyframes command = new(_opacity, false, TimeSpan.Zero); + + command.Execute(); + command.DisplayName.ShouldBe("Disable keyframes"); + _opacity.KeyframesEnabled.ShouldBeFalse(); + // The keyframes themselves are kept + _opacity.Keyframes.ShouldBe([_first, _second]); + + command.Undo(); + _opacity.KeyframesEnabled.ShouldBeTrue(); + Snapshot().ShouldBe(before); + } + + [Fact] + public void ToggleLayerPropertyKeyframes_Undo_DoesNotRememberWhatItWasBefore() + { + ToggleLayerPropertyKeyframes enable = new(_opacity, true, TimeSpan.Zero); + + enable.Execute(); + enable.Undo(); + + // Pinned as-is: undo sets the opposite of what the command did rather than restoring the earlier state, so + // "enabling" keyframes that were already enabled turns them off when undone + _opacity.KeyframesEnabled.ShouldBeFalse(); + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/LayerBrushAndLedCommandTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/LayerBrushAndLedCommandTests.cs new file mode 100644 index 000000000..5d1ef376c --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/LayerBrushAndLedCommandTests.cs @@ -0,0 +1,196 @@ +using Artemis.Core.LayerBrushes; +using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes; +using Artemis.UI.Shared.Services.ProfileEditor.Commands; +using Artemis.UI.Shared.Tests.Infrastructure; +using RGB.NET.Core; +using SkiaSharp; + +namespace Artemis.UI.Shared.Tests.ProfileEditor.Commands; + +/// +/// Changing the brush of a layer and the LEDs it covers +/// +public class LayerBrushAndLedCommandTests : ProfileEditorTestBase +{ + private readonly Layer _layer; + private readonly SolidColorTestBrush _brush; + + public LayerBrushAndLedCommandTests() + { + (_layer, _brush) = Harness.AddLayer(Root, Leds(LedId.Keyboard_A, LedId.Keyboard_S), "Layer"); + _layer.Enable(); + } + + [Fact] + public void ChangeLayerBrush_GivesTheLayerANewBrush() + { + ChangeLayerBrush command = new(_layer, BrushDescriptor()); + + command.Execute(); + + command.DisplayName.ShouldBe("Change layer brush"); + GradientTestBrush newBrush = _layer.LayerBrush.ShouldBeOfType(); + newBrush.Enabled.ShouldBeTrue(); + _layer.General.BrushReference.CurrentValue!.BrushType.ShouldBe(nameof(GradientTestBrush)); + // The old brush is only disabled, so undo can put it back + _brush.Enabled.ShouldBeFalse(); + IsDisposed(_brush.Properties).ShouldBeFalse(); + } + + [Fact] + public void ChangeLayerBrush_Undo_PutsTheSameBrushBackWithItsSettings() + { + _brush.Properties.Color.SetCurrentValue(SKColors.Lime); + string before = Snapshot(); + ChangeLayerBrush command = new(_layer, BrushDescriptor()); + + command.Execute(); + command.Undo(); + + _layer.LayerBrush.ShouldBe(_brush); + _brush.Enabled.ShouldBeTrue(); + _brush.Properties.Color.BaseValue.ShouldBe(SKColors.Lime); + Snapshot().ShouldBe(before); + } + + [Fact] + public void ChangeLayerBrush_Redo_ReusesTheSameNewBrush() + { + ChangeLayerBrush command = new(_layer, BrushDescriptor()); + command.Execute(); + BaseLayerBrush newBrush = _layer.LayerBrush!; + + command.Undo(); + command.Execute(); + + _layer.LayerBrush.ShouldBe(newBrush); + } + + [Fact] + public void ChangeLayerBrush_Undo_OnALayerWithoutBrush_KeepsTheNewBrush() + { + Layer layer = new(Root, "Layer without brush"); + Root.AddChild(layer); + ChangeLayerBrush command = new(layer, BrushDescriptor()); + + command.Execute(); + command.Undo(); + + // Pinned as-is: with no brush to go back to, undo leaves the new brush (and the brush reference) in place + layer.LayerBrush.ShouldBeOfType(); + } + + [Fact] + public void ChangeLayerBrush_Dispose_DisposesTheBrushNoLongerInUse() + { + ChangeLayerBrush executed = new(_layer, BrushDescriptor()); + executed.Execute(); + GradientTestBrush newBrush = (GradientTestBrush) _layer.LayerBrush!; + + executed.Dispose(); + + IsDisposed(_brush.Properties).ShouldBeTrue(); + IsDisposed(newBrush.Properties).ShouldBeFalse(); + } + + [Fact] + public void ChangeLayerBrush_Dispose_AfterUndo_DisposesTheNewBrush() + { + ChangeLayerBrush undone = new(_layer, BrushDescriptor()); + undone.Execute(); + GradientTestBrush newBrush = (GradientTestBrush) _layer.LayerBrush!; + undone.Undo(); + + undone.Dispose(); + + IsDisposed(_brush.Properties).ShouldBeFalse(); + IsDisposed(newBrush.Properties).ShouldBeTrue(); + } + + [Fact] + public void ChangeLayerLeds_ReplacesTheLeds() + { + ChangeLayerLeds command = new(_layer, [Led(LedId.Keyboard_Q), Led(LedId.Keyboard_W)]); + + command.Execute(); + + command.DisplayName.ShouldBe("Change layer LEDs"); + _layer.Leds.ShouldBe(Leds(LedId.Keyboard_Q, LedId.Keyboard_W)); + } + + [Fact] + public void ChangeLayerLeds_Undo_RestoresTheOriginalLedsInOrder() + { + string before = Snapshot(); + ChangeLayerLeds command = new(_layer, [Led(LedId.Keyboard_Q)]); + + command.Execute(); + command.Undo(); + + _layer.Leds.ShouldBe(Leds(LedId.Keyboard_A, LedId.Keyboard_S)); + Snapshot().ShouldBe(before); + } + + [Fact] + public void ChangeLayerLeds_ToNone_AndBack() + { + string before = Snapshot(); + ChangeLayerLeds command = new(_layer, []); + + command.Execute(); + _layer.Leds.ShouldBeEmpty(); + + command.Undo(); + Snapshot().ShouldBe(before); + } + + [Fact] + public void ChangeLayerLeds_KeepsItsOwnCopyOfTheOriginalLeds() + { + ChangeLayerLeds command = new(_layer, [Led(LedId.Keyboard_Q)]); + // Changes made after the command was created, for example by an earlier command in a scope, are not undone + _layer.AddLed(Led(LedId.Keyboard_D)); + + command.Execute(); + command.Undo(); + + _layer.Leds.ShouldBe(Leds(LedId.Keyboard_A, LedId.Keyboard_S)); + } + + [Fact] + public void ApplyAdaptionHints_ReplacesTheLedsWithTheOnesTheHintsPick() + { + _layer.Adapter.Add(new SingleLedAdaptionHint {LedId = LedId.Keyboard_Z}); + _layer.Adapter.Add(new SingleLedAdaptionHint {LedId = LedId.Keyboard_X}); + ApplyAdaptionHints command = new(_layer, [Keyboard]); + + command.Execute(); + + command.DisplayName.ShouldBe("Apply adaption hints"); + _layer.Leds.ShouldBe(Leds(LedId.Keyboard_Z, LedId.Keyboard_X)); + } + + [Fact] + public void ApplyAdaptionHints_Undo_RestoresTheOriginalLeds() + { + _layer.Adapter.Add(new SingleLedAdaptionHint {LedId = LedId.Keyboard_Z}); + string before = Snapshot(); + ApplyAdaptionHints command = new(_layer, [Keyboard]); + + command.Execute(); + command.Undo(); + + _layer.Leds.ShouldBe(Leds(LedId.Keyboard_A, LedId.Keyboard_S)); + Snapshot().ShouldBe(before); + } + + [Fact] + public void ApplyAdaptionHints_WithoutDevices_LeavesTheLayerWithoutLeds() + { + _layer.Adapter.Add(new SingleLedAdaptionHint {LedId = LedId.Keyboard_Z}); + + new ApplyAdaptionHints(_layer, []).Execute(); + + _layer.Leds.ShouldBeEmpty(); + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/LayerEffectCommandTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/LayerEffectCommandTests.cs new file mode 100644 index 000000000..9a5c8acfb --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/LayerEffectCommandTests.cs @@ -0,0 +1,202 @@ +using Artemis.Core.LayerEffects; +using Artemis.Core.Tests.Infrastructure.Plugins.LayerEffects; +using Artemis.UI.Shared.Services.ProfileEditor.Commands; +using Artemis.UI.Shared.Tests.Infrastructure; +using RGB.NET.Core; + +namespace Artemis.UI.Shared.Tests.ProfileEditor.Commands; + +/// +/// Adding, removing and renaming effects +/// +public class LayerEffectCommandTests : ProfileEditorTestBase +{ + private readonly Layer _layer; + + public LayerEffectCommandTests() + { + _layer = AddLayer("Layer", null, LedId.Keyboard_A); + _layer.Enable(); + } + + [Fact] + public void AddLayerEffect_AddsAndEnablesTheEffect() + { + OpacityTestEffect effect = NewEffect(_layer); + AddLayerEffect command = new(_layer, effect); + + command.Execute(); + + command.DisplayName.ShouldBe("Add layer effect"); + _layer.LayerEffects.ShouldBe([effect]); + effect.ProfileElement.ShouldBe(_layer); + effect.Enabled.ShouldBeTrue(); + } + + [Fact] + public void AddLayerEffect_NewEffectsGoOnTop() + { + TintTestEffect existing = Harness.AddEffect(_layer); + OpacityTestEffect effect = NewEffect(_layer); + + new AddLayerEffect(_layer, effect).Execute(); + + // A new effect has order 0, so it sorts before the effects already there + _layer.LayerEffects.ShouldBe([effect, existing]); + } + + [Fact] + public void AddLayerEffect_Undo_RestoresTheProfile() + { + Harness.AddEffect(_layer); + string before = Snapshot(); + OpacityTestEffect effect = NewEffect(_layer); + AddLayerEffect command = new(_layer, effect); + + command.Execute(); + command.Undo(); + + _layer.LayerEffects.ShouldNotContain(effect); + effect.Enabled.ShouldBeFalse(); + Snapshot().ShouldBe(before); + } + + [Fact] + public void AddLayerEffect_Dispose_DisposesTheEffectOnlyWhenNotAdded() + { + OpacityTestEffect added = NewEffect(_layer); + AddLayerEffect addedCommand = new(_layer, added); + addedCommand.Execute(); + TintTestEffect undone = NewEffect(_layer); + AddLayerEffect undoneCommand = new(_layer, undone); + undoneCommand.Execute(); + undoneCommand.Undo(); + addedCommand.Dispose(); + undoneCommand.Dispose(); + + IsDisposed(added.Properties).ShouldBeFalse(); + IsDisposed(undone.Properties).ShouldBeTrue(); + } + + [Fact] + public void RemoveLayerEffect_RemovesAndDisablesTheEffect() + { + OpacityTestEffect effect = Harness.AddEffect(_layer); + RemoveLayerEffect command = new(effect); + + command.Execute(); + + command.DisplayName.ShouldBe("Remove layer effect"); + _layer.LayerEffects.ShouldBeEmpty(); + effect.Enabled.ShouldBeFalse(); + } + + [Fact] + public void RemoveLayerEffect_Undo_OfTheBottomEffect_RestoresTheProfile() + { + Harness.AddEffect(_layer); + Harness.AddEffect(_layer); + BaseLayerEffect[] effects = _layer.LayerEffects.ToArray(); + string before = Snapshot(); + RemoveLayerEffect command = new(effects[1]); + + command.Execute(); + command.Undo(); + + _layer.LayerEffects.ShouldBe(effects); + effects[1].Enabled.ShouldBeTrue(); + Snapshot().ShouldBe(before); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void RemoveLayerEffect_Undo_OfAnEffectAboveAnother_MovesItDown(int index) + { + Harness.AddEffect(_layer); + Harness.AddEffect(_layer); + Harness.AddEffect(_layer); + List effects = _layer.LayerEffects.ToList(); + BaseLayerEffect removed = effects[index]; + string before = Snapshot(); + RemoveLayerEffect command = new(removed); + + command.Execute(); + command.Undo(); + + // Pinned as-is: removing renumbers the remaining effects, so the effect below takes over the removed effect's + // order. Adding it back sorts it after the effect that now has the same order, swapping the two. Since effects + // apply in order, undo can change what the layer looks like + effects.RemoveAt(index); + effects.Insert(index + 1, removed); + _layer.LayerEffects.ShouldBe(effects); + Snapshot().ShouldNotBe(before); + } + + [Fact] + public void RemoveLayerEffect_Dispose_DisposesTheEffectOnlyWhenRemoved() + { + OpacityTestEffect removed = Harness.AddEffect(_layer); + RemoveLayerEffect removedCommand = new(removed); + removedCommand.Execute(); + TintTestEffect undone = Harness.AddEffect(_layer); + RemoveLayerEffect undoneCommand = new(undone); + undoneCommand.Execute(); + undoneCommand.Undo(); + removedCommand.Dispose(); + undoneCommand.Dispose(); + + IsDisposed(removed.Properties).ShouldBeTrue(); + IsDisposed(undone.Properties).ShouldBeFalse(); + } + + [Fact] + public void RemoveLayerEffect_FromAFolder_CanBeUndone() + { + Folder folder = Harness.AddFolder(Root, "Folder"); + folder.Enable(); + TintTestEffect effect = Harness.AddEffect(folder); + string before = Snapshot(); + RemoveLayerEffect command = new(effect); + + command.Execute(); + folder.LayerEffects.ShouldBeEmpty(); + + command.Undo(); + folder.LayerEffects.ShouldBe([effect]); + Snapshot().ShouldBe(before); + } + + [Fact] + public void RenameLayerEffect_RenamesAndMarksTheEffectAsRenamed() + { + BaseLayerEffect effect = Harness.AddEffect(_layer); + string originalName = effect.Name; + string before = Snapshot(); + RenameLayerEffect command = new(effect, "Renamed"); + + command.Execute(); + command.DisplayName.ShouldBe("Rename layer effect"); + effect.Name.ShouldBe("Renamed"); + effect.HasBeenRenamed.ShouldBeTrue(); + + command.Undo(); + effect.Name.ShouldBe(originalName); + effect.HasBeenRenamed.ShouldBeFalse(); + Snapshot().ShouldBe(before); + } + + [Fact] + public void RenameLayerEffect_Undo_KeepsAnEarlierRename() + { + BaseLayerEffect effect = Harness.AddEffect(_layer); + new RenameLayerEffect(effect, "First").Execute(); + RenameLayerEffect command = new(effect, "Second"); + + command.Execute(); + command.Undo(); + + effect.Name.ShouldBe("First"); + effect.HasBeenRenamed.ShouldBeTrue(); + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/LayerPropertyCommandTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/LayerPropertyCommandTests.cs new file mode 100644 index 000000000..ee6e3ee4d --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/LayerPropertyCommandTests.cs @@ -0,0 +1,297 @@ +using Artemis.UI.Shared.Services.ProfileEditor.Commands; +using Artemis.UI.Shared.Tests.Infrastructure; +using RGB.NET.Core; +using SkiaSharp; + +namespace Artemis.UI.Shared.Tests.ProfileEditor.Commands; + +/// +/// Changing and resetting property values, data bindings, color gradients and timeline segments +/// +public class LayerPropertyCommandTests : ProfileEditorTestBase +{ + private readonly Layer _layer; + private readonly FloatLayerProperty _opacity; + + public LayerPropertyCommandTests() + { + _layer = AddLayer("Layer", null, LedId.Keyboard_A); + _opacity = _layer.Transform.Opacity; + } + + private LayerPropertyKeyframe AddKeyframe(float value, double seconds) + { + LayerPropertyKeyframe keyframe = new(value, TimeSpan.FromSeconds(seconds), Easings.Functions.Linear, _opacity); + _opacity.AddKeyframe(keyframe); + return keyframe; + } + + [Fact] + public void UpdateLayerProperty_WithoutKeyframes_ChangesTheValue() + { + string before = Snapshot(); + UpdateLayerProperty command = new(_opacity, 40, TimeSpan.FromSeconds(1)); + + command.Execute(); + command.DisplayName.ShouldBe("Update Opacity"); + _opacity.BaseValue.ShouldBe(40); + _opacity.CurrentValue.ShouldBe(40); + + command.Undo(); + _opacity.BaseValue.ShouldBe(100); + Snapshot().ShouldBe(before); + } + + [Fact] + public void UpdateLayerProperty_WithKeyframes_AddsAKeyframeAtTheTime() + { + AddKeyframe(0, 0); + string before = Snapshot(); + UpdateLayerProperty command = new(_opacity, 40, TimeSpan.FromSeconds(1)); + + command.Execute(); + command.DisplayName.ShouldBe("Add Opacity keyframe"); + LayerPropertyKeyframe added = _opacity.Keyframes.Last(); + added.Position.ShouldBe(TimeSpan.FromSeconds(1)); + added.Value.ShouldBe(40); + + command.Undo(); + _opacity.Keyframes.Count.ShouldBe(1); + Snapshot().ShouldBe(before); + + command.Execute(); + _opacity.Keyframes.Last().ShouldBe(added); + } + + [Fact] + public void UpdateLayerProperty_OnAKeyframe_ChangesItsValue() + { + AddKeyframe(0, 0); + LayerPropertyKeyframe keyframe = AddKeyframe(60, 1); + _layer.OverrideTimelineAndApply(TimeSpan.FromSeconds(1)); + string before = Snapshot(); + UpdateLayerProperty command = new(_opacity, 40, TimeSpan.FromSeconds(1)); + + command.Execute(); + command.DisplayName.ShouldBe("Update Opacity"); + _opacity.Keyframes.Count.ShouldBe(2); + keyframe.Value.ShouldBe(40); + + command.Undo(); + keyframe.Value.ShouldBe(60); + Snapshot().ShouldBe(before); + } + + [Fact] + public void UpdateLayerProperty_OnAKeyframe_UndoesToTheValueAtTheCurrentTime() + { + AddKeyframe(0, 0); + LayerPropertyKeyframe keyframe = AddKeyframe(60, 2); + // The property shows the value of the timeline's position, which is between the two keyframes + _layer.OverrideTimelineAndApply(TimeSpan.FromSeconds(1)); + UpdateLayerProperty command = new(_opacity, 40, TimeSpan.FromSeconds(2)); + + command.Execute(); + command.Undo(); + + // The original value is the value the property had at the time the command was created, not the keyframe's own + // value. The editor always edits keyframes at the current time, where the two are the same + keyframe.Value.ShouldBe(30, 0.01f); + } + + [Fact] + public void UpdateLayerProperty_WithOriginalValue_ChangesTheValue() + { + // The property inputs show a preview while dragging and pass the value from before the preview + _opacity.SetCurrentValue(55); + UpdateLayerProperty command = new(_opacity, 40, 100, TimeSpan.FromSeconds(1)); + + command.Execute(); + _opacity.BaseValue.ShouldBe(40); + + command.Undo(); + _opacity.BaseValue.ShouldBe(100); + } + + [Fact] + public void UpdateLayerProperty_WithOriginalValue_OnAKeyframe_RemovesTheKeyframeOnUndo() + { + AddKeyframe(0, 0); + LayerPropertyKeyframe keyframe = AddKeyframe(60, 1); + UpdateLayerProperty command = new(_opacity, 40, 60, TimeSpan.FromSeconds(1)); + + command.Execute(); + keyframe.Value.ShouldBe(40); + command.Undo(); + + // Pinned as-is: this constructor never checks whether there already is a keyframe at the time. When keyframes are + // enabled, Execute treats the existing keyframe that SetCurrentValue updated as a new one, and Undo removes it + // instead of restoring its value. Changing a keyframe through a property input and undoing deletes the keyframe + _opacity.Keyframes.ShouldNotContain(keyframe); + _opacity.Keyframes.Count.ShouldBe(1); + command.DisplayName.ShouldBe("Add Opacity keyframe"); + } + + [Fact] + public void UpdateLayerProperty_WithoutTime_ChangesTheBaseValueEvenWithKeyframes() + { + AddKeyframe(0, 0); + UpdateLayerProperty command = new(_opacity, 40, null); + + command.Execute(); + + _opacity.BaseValue.ShouldBe(40); + _opacity.Keyframes.Count.ShouldBe(1); + // Pinned as-is: with keyframes enabled the command expects to add a keyframe and renames itself, even though + // without a time no keyframe is added + command.DisplayName.ShouldBe("Add Opacity keyframe"); + + command.Undo(); + _opacity.BaseValue.ShouldBe(0, 0.01f); + } + + [Fact] + public void ResetLayerProperty_RestoresTheDefaultAndTurnsOffKeyframes() + { + _opacity.SetCurrentValue(40); + AddKeyframe(10, 0); + string before = Snapshot(); + ResetLayerProperty command = new(_opacity); + + command.Execute(); + command.DisplayName.ShouldBe("Reset layer property"); + _opacity.BaseValue.ShouldBe(100); + _opacity.KeyframesEnabled.ShouldBeFalse(); + _opacity.Keyframes.Count.ShouldBe(1); + + command.Undo(); + _opacity.BaseValue.ShouldBe(40); + _opacity.KeyframesEnabled.ShouldBeTrue(); + Snapshot().ShouldBe(before); + } + + [Fact] + public void ResetLayerProperty_WithoutDefault_Throws() + { + _layer.General.BrushReference.DefaultValue.ShouldBeNull(); + + Should.Throw(() => new ResetLayerProperty(_layer.General.BrushReference)); + } + + [Fact] + public void ChangeDataBindingEnabled_TogglesAndUndoRestores() + { + string before = Snapshot(); + ChangeDataBindingEnabled enable = new(_opacity, true); + + enable.Execute(); + enable.DisplayName.ShouldBe("Enable data binding"); + _opacity.BaseDataBinding.IsEnabled.ShouldBeTrue(); + + enable.Undo(); + _opacity.BaseDataBinding.IsEnabled.ShouldBeFalse(); + Snapshot().ShouldBe(before); + + new ChangeDataBindingEnabled(_opacity, false).DisplayName.ShouldBe("Disable data binding"); + } + + [Fact] + public void UpdateColorGradient_ReplacesTheStopsAndUndoRestoresThem() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + UpdateColorGradient command = new(gradient, [new ColorGradientStop(SKColors.Lime, 0.2f), new ColorGradientStop(SKColors.White, 0.5f), new ColorGradientStop(SKColors.Black, 0.9f)], null); + + command.Execute(); + command.DisplayName.ShouldBe("Update color gradient"); + gradient.Select(s => (s.Color, s.Position)).ShouldBe([(SKColors.Lime, 0.2f), (SKColors.White, 0.5f), (SKColors.Black, 0.9f)]); + + command.Undo(); + gradient.Select(s => (s.Color, s.Position)).ShouldBe([(SKColors.Red, 0f), (SKColors.Blue, 1f)]); + } + + [Fact] + public void UpdateColorGradient_UpdatesExistingStopsInPlace() + { + ColorGradientStop first = new(SKColors.Red, 0); + ColorGradientStop second = new(SKColors.Blue, 1); + ColorGradient gradient = [first, second]; + UpdateColorGradient command = new(gradient, [new ColorGradientStop(SKColors.Lime, 0.3f)], null); + + command.Execute(); + gradient.ShouldBe([first]); + first.Color.ShouldBe(SKColors.Lime); + + command.Undo(); + gradient[0].ShouldBe(first); + gradient.Count.ShouldBe(2); + gradient[1].ShouldNotBeSameAs(second); + } + + [Fact] + public void UpdateColorGradient_KeepsItsOwnCopyOfTheStops() + { + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0)]; + ColorGradientStop newStop = new(SKColors.Lime, 0.5f); + UpdateColorGradient command = new(gradient, [newStop], null); + + newStop.Color = SKColors.Black; + command.Execute(); + + gradient[0].Color.ShouldBe(SKColors.Lime); + } + + [Fact] + public void UpdateColorGradient_WithOriginalStops_UndoesToThoseStops() + { + // The gradient editor changes the gradient while editing and passes the stops from before + ColorGradient gradient = [new ColorGradientStop(SKColors.Lime, 0.5f)]; + UpdateColorGradient command = new(gradient, [new ColorGradientStop(SKColors.Lime, 0.5f)], [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]); + + command.Execute(); + command.Undo(); + + gradient.Select(s => (s.Color, s.Position)).ShouldBe([(SKColors.Red, 0f), (SKColors.Blue, 1f)]); + } + + [Theory] + [InlineData(ResizeTimelineSegment.SegmentType.Start, "Resize start segment")] + [InlineData(ResizeTimelineSegment.SegmentType.Main, "Resize main segment")] + [InlineData(ResizeTimelineSegment.SegmentType.End, "Resize end segment")] + public void ResizeTimelineSegment_ResizesAndUndoRestoresTheLength(ResizeTimelineSegment.SegmentType segmentType, string displayName) + { + _layer.Timeline.StartSegmentLength = TimeSpan.FromSeconds(1); + _layer.Timeline.EndSegmentLength = TimeSpan.FromSeconds(2); + string before = Snapshot(); + ResizeTimelineSegment command = new(segmentType, _layer, TimeSpan.FromSeconds(7)); + + command.Execute(); + command.DisplayName.ShouldBe(displayName); + SegmentLength(segmentType).ShouldBe(TimeSpan.FromSeconds(7)); + + command.Undo(); + Snapshot().ShouldBe(before); + } + + [Fact] + public void ResizeTimelineSegment_WithOriginalLength_UndoesToThatLength() + { + // The timeline resizes segments while dragging and passes the length from before the drag + _layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(6); + ResizeTimelineSegment command = new(ResizeTimelineSegment.SegmentType.Main, _layer, TimeSpan.FromSeconds(7), TimeSpan.FromSeconds(5)); + + command.Execute(); + command.Undo(); + + _layer.Timeline.MainSegmentLength.ShouldBe(TimeSpan.FromSeconds(5)); + } + + private TimeSpan SegmentLength(ResizeTimelineSegment.SegmentType segmentType) + { + return segmentType switch + { + ResizeTimelineSegment.SegmentType.Start => _layer.Timeline.StartSegmentLength, + ResizeTimelineSegment.SegmentType.Main => _layer.Timeline.MainSegmentLength, + _ => _layer.Timeline.EndSegmentLength + }; + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/ProfileElementCommandTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/ProfileElementCommandTests.cs new file mode 100644 index 000000000..7c76517b2 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/Commands/ProfileElementCommandTests.cs @@ -0,0 +1,248 @@ +using Artemis.UI.Shared.Services.ProfileEditor.Commands; +using Artemis.UI.Shared.Tests.Infrastructure; +using RGB.NET.Core; + +namespace Artemis.UI.Shared.Tests.ProfileEditor.Commands; + +/// +/// Adding, removing, moving and renaming folders and layers +/// +public class ProfileElementCommandTests : ProfileEditorTestBase +{ + private readonly Folder _folder; + private readonly Layer _first; + private readonly Layer _second; + private readonly Layer _third; + + public ProfileElementCommandTests() + { + _folder = Harness.AddFolder(Root, "Folder"); + _first = AddLayer("First", _folder, LedId.Keyboard_Q); + _second = AddLayer("Second", _folder, LedId.Keyboard_W); + _third = AddLayer("Third", Root, LedId.Keyboard_E); + } + + [Fact] + public void AddProfileElement_AddsAndEnablesTheElementAtTheIndex() + { + Layer layer = new(_folder, "New layer"); + AddProfileElement command = new(layer, _folder, 1); + + command.Execute(); + + command.DisplayName.ShouldBe("Add layer"); + _folder.Children.ShouldBe([_first, layer, _second]); + layer.Parent.ShouldBe(_folder); + layer.Enabled.ShouldBeTrue(); + } + + [Fact] + public void AddProfileElement_Undo_RestoresTheProfile() + { + string before = Snapshot(); + Folder folder = new(Root, "New folder"); + AddProfileElement command = new(folder, Root, 0); + + command.Execute(); + command.Undo(); + + command.DisplayName.ShouldBe("Add folder"); + Root.Children.ShouldBe([_folder, _third]); + folder.Parent.ShouldBeNull(); + Snapshot().ShouldBe(before); + } + + [Fact] + public void AddProfileElement_Undo_LeavesTheElementEnabled() + { + Layer layer = new(_folder, "New layer"); + AddProfileElement command = new(layer, _folder, 0); + + command.Execute(); + command.Undo(); + + // Pinned as-is: Execute enables the element but Undo does not disable it again. It is no longer in the profile + // so it doesn't render, but its brush and effects stay enabled (unlike RemoveProfileElement, which disables) + layer.Enabled.ShouldBeTrue(); + } + + [Fact] + public void AddProfileElement_Dispose_DisposesTheElementOnlyWhenNotAdded() + { + Layer added = new(_folder, "Added"); + AddProfileElement addedCommand = new(added, _folder, 0); + addedCommand.Execute(); + Layer undone = new(_folder, "Undone"); + AddProfileElement undoneCommand = new(undone, _folder, 0); + undoneCommand.Execute(); + undoneCommand.Undo(); + + addedCommand.Dispose(); + undoneCommand.Dispose(); + + added.Disposed.ShouldBeFalse(); + undone.Disposed.ShouldBeTrue(); + } + + [Fact] + public void RemoveProfileElement_RemovesAndDisablesTheElement() + { + _first.Enable(); + RemoveProfileElement command = new(_first); + + command.Execute(); + + command.DisplayName.ShouldBe("Remove layer"); + _folder.Children.ShouldBe([_second]); + _first.Parent.ShouldBeNull(); + _first.Enabled.ShouldBeFalse(); + } + + [Fact] + public void RemoveProfileElement_Undo_PutsTheElementBackInPlace() + { + _first.Enable(); + string before = Snapshot(); + RemoveProfileElement command = new(_first); + + command.Execute(); + command.Undo(); + + _folder.Children.ShouldBe([_first, _second]); + _first.Parent.ShouldBe(_folder); + _first.Enabled.ShouldBeTrue(); + Snapshot().ShouldBe(before); + } + + [Fact] + public void RemoveProfileElement_Undo_LeavesADisabledElementDisabled() + { + _first.Disable(); + RemoveProfileElement command = new(_first); + + command.Execute(); + command.Undo(); + + _first.Enabled.ShouldBeFalse(); + } + + [Fact] + public void RemoveProfileElement_OfAFolder_TakesItsChildrenAlong() + { + string before = Snapshot(); + RemoveProfileElement command = new(_folder); + + command.Execute(); + Root.Children.ShouldBe([_third]); + _folder.Children.ShouldBe([_first, _second]); + + command.Undo(); + command.DisplayName.ShouldBe("Remove folder"); + Snapshot().ShouldBe(before); + } + + [Fact] + public void RemoveProfileElement_WithoutParent_Throws() + { + _folder.RemoveChild(_first); + + Should.Throw(() => new RemoveProfileElement(_first)); + } + + [Fact] + public void RemoveProfileElement_Dispose_DisposesTheElementOnlyWhenRemoved() + { + RemoveProfileElement removed = new(_first); + removed.Execute(); + RemoveProfileElement undone = new(_second); + undone.Execute(); + undone.Undo(); + + removed.Dispose(); + undone.Dispose(); + + _first.Disposed.ShouldBeTrue(); + _second.Disposed.ShouldBeFalse(); + } + + [Fact] + public void MoveProfileElement_ToAnotherFolder_AndBack() + { + string before = Snapshot(); + MoveProfileElement command = new(Root, _first, 1); + + command.Execute(); + Root.Children.ShouldBe([_folder, _first, _third]); + _folder.Children.ShouldBe([_second]); + _first.Parent.ShouldBe(Root); + + command.Undo(); + _folder.Children.ShouldBe([_first, _second]); + _first.Parent.ShouldBe(_folder); + Snapshot().ShouldBe(before); + } + + [Fact] + public void MoveProfileElement_WithinTheSameFolder_AndBack() + { + Layer fourth = AddLayer("Fourth", _folder, LedId.Keyboard_R); + string before = Snapshot(); + MoveProfileElement command = new(_folder, _first, 2); + + command.Execute(); + // The element is taken out first, so the index is counted without it + _folder.Children.ShouldBe([_second, fourth, _first]); + _folder.Children.Select(c => c.Order).ShouldBe([1, 2, 3]); + + command.Undo(); + _folder.Children.ShouldBe([_first, _second, fourth]); + Snapshot().ShouldBe(before); + } + + [Fact] + public void MoveProfileElement_IntoAnotherFolder_CanBeRedone() + { + MoveProfileElement command = new(Root, _first, 0); + + command.Execute(); + command.Undo(); + command.Execute(); + + Root.Children.ShouldBe([_first, _folder, _third]); + } + + [Fact] + public void MoveProfileElement_DisplayName_AlwaysSaysLayer() + { + Folder other = Harness.AddFolder(Root, "Other folder"); + + // Pinned as-is: the constructor sets "Move folder" for folders and then always overwrites it with "Move layer" + new MoveProfileElement(_folder, other, 0).DisplayName.ShouldBe("Move layer"); + new MoveProfileElement(Root, _first, 0).DisplayName.ShouldBe("Move layer"); + } + + [Fact] + public void RenameProfileElement_RenamesAndUndoRestoresTheName() + { + string before = Snapshot(); + RenameProfileElement layerCommand = new(_first, "Renamed layer"); + RenameProfileElement folderCommand = new(_folder, "Renamed folder"); + + layerCommand.Execute(); + folderCommand.Execute(); + _first.Name.ShouldBe("Renamed layer"); + _folder.Name.ShouldBe("Renamed folder"); + + folderCommand.Undo(); + layerCommand.Undo(); + layerCommand.DisplayName.ShouldBe("Rename layer"); + folderCommand.DisplayName.ShouldBe("Rename folder"); + Snapshot().ShouldBe(before); + } + + [Fact] + public void RenameProfileElement_OfTheProfile_IsNotSupported() + { + Should.Throw(() => new RenameProfileElement(Profile, "Renamed")); + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorCreateElementTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorCreateElementTests.cs new file mode 100644 index 000000000..7c60ea5e0 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorCreateElementTests.cs @@ -0,0 +1,126 @@ +using Artemis.Core.Tests.Infrastructure.Plugins.LayerBrushes; +using Artemis.UI.Shared.Tests.Infrastructure; +using RGB.NET.Core; + +namespace Artemis.UI.Shared.Tests.ProfileEditor; + +/// +/// Adding new folders and layers through the editor +/// +public class ProfileEditorCreateElementTests : ProfileEditorTestBase +{ + [Fact] + public async Task CreateAndAddFolder_InAFolder_AddsItAtTheTop() + { + Layer existing = AddLayer("Existing", null, LedId.Keyboard_A); + await StartEditing(); + + Folder folder = Service.CreateAndAddFolder(Root); + + folder.Name.ShouldBe("New folder"); + Root.Children.ShouldBe([folder, existing]); + } + + [Fact] + public async Task CreateAndAddFolder_GivesEachFolderAUniqueName() + { + await StartEditing(); + + Service.CreateAndAddFolder(Root); + Folder second = Service.CreateAndAddFolder(Root); + + second.Name.ShouldBe("New folder (2)"); + } + + [Fact] + public async Task CreateAndAddFolder_OnALayer_AddsItBelowTheLayer() + { + Layer first = AddLayer("First", null, LedId.Keyboard_A); + Layer second = AddLayer("Second", null, LedId.Keyboard_B); + await StartEditing(); + + Folder folder = Service.CreateAndAddFolder(first); + + // The layer's order is 1-based while AddChild takes a 0-based index, so the folder lands right below the layer + Root.Children.ShouldBe([first, folder, second]); + } + + [Fact] + public async Task CreateAndAddFolder_CanBeUndone() + { + await StartEditing(); + string before = Snapshot(); + + Folder folder = Service.CreateAndAddFolder(Root); + Undo(); + + Root.Children.ShouldNotContain(folder); + Snapshot().ShouldBe(before); + } + + [Fact] + public async Task CreateAndAddLayer_InAFolder_CoversEveryEnabledLedAndGetsTheDefaultBrush() + { + LayerBrushService.DefaultBrush = BrushDescriptor(); + Folder folder = Harness.AddFolder(Root, "Folder"); + Layer existing = AddLayer("Existing", folder, LedId.Keyboard_A); + await StartEditing(); + + Layer layer = Service.CreateAndAddLayer(folder); + + layer.Name.ShouldBe("New layer"); + folder.Children.ShouldBe([layer, existing]); + layer.Leds.ShouldBe(Keyboard.Leds, ignoreOrder: true); + layer.LayerBrush.ShouldBeOfType(); + layer.Enabled.ShouldBeTrue(); + } + + [Fact] + public async Task CreateAndAddLayer_WithoutDefaultBrush_HasNoBrush() + { + await StartEditing(); + + Layer layer = Service.CreateAndAddLayer(Root); + + layer.LayerBrush.ShouldBeNull(); + } + + [Fact] + public async Task CreateAndAddLayer_OnALayer_AddsItBelowTheLayer() + { + Layer first = AddLayer("First", null, LedId.Keyboard_A); + Layer second = AddLayer("Second", null, LedId.Keyboard_B); + await StartEditing(); + + Layer layer = Service.CreateAndAddLayer(first); + + Root.Children.ShouldBe([first, layer, second]); + } + + [Fact] + public async Task CreateAndAddLayer_OnALayer_CanRepeatANameAlreadyInUse() + { + Layer existing = AddLayer("New layer", null, LedId.Keyboard_A); + await StartEditing(); + + Layer layer = Service.CreateAndAddLayer(existing); + + // Pinned as-is: the name is picked from the children of the target layer (which has none) rather than from its + // parent, so the new layer gets the same name as its sibling. CreateAndAddFolder uses the parent. + layer.Name.ShouldBe("New layer"); + Root.Children.Count(c => c.Name == "New layer").ShouldBe(2); + } + + [Fact] + public async Task CreateAndAddLayer_CanBeUndone() + { + await StartEditing(); + string before = Snapshot(); + + Layer layer = Service.CreateAndAddLayer(Root); + Undo(); + + Root.Children.ShouldNotContain(layer); + Snapshot().ShouldBe(before); + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorFocusModeTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorFocusModeTests.cs new file mode 100644 index 000000000..d396a3ef0 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorFocusModeTests.cs @@ -0,0 +1,107 @@ +using Artemis.UI.Shared.Services.ProfileEditor; +using Artemis.UI.Shared.Tests.Infrastructure; +using RGB.NET.Core; + +namespace Artemis.UI.Shared.Tests.ProfileEditor; + +/// +/// Focus modes decide which part of the profile the profile service keeps showing while editing +/// +public class ProfileEditorFocusModeTests : ProfileEditorTestBase +{ + private readonly Folder _folder; + private readonly Layer _layer; + + public ProfileEditorFocusModeTests() + { + _folder = Harness.AddFolder(Root, "Folder"); + _layer = AddLayer("Layer", _folder, LedId.Keyboard_A); + } + + [Fact] + public async Task NoFocus_FocusesNothing() + { + await StartEditing(); + Service.ChangeCurrentProfileElement(_layer); + + ProfileService.FocusProfileElement.ShouldBeNull(); + } + + [Fact] + public async Task FolderFocus_FocusesTheFolderOfTheSelectedElement() + { + await StartEditing(); + Service.ChangeCurrentProfileElement(_layer); + + Service.ChangeFocusMode(ProfileEditorFocusMode.Folder); + + Current(Service.FocusMode).ShouldBe(ProfileEditorFocusMode.Folder); + ProfileService.FocusProfileElement.ShouldBe(_folder); + } + + [Fact] + public async Task SelectionFocus_FocusesTheSelectedElement() + { + await StartEditing(); + Service.ChangeCurrentProfileElement(_layer); + + Service.ChangeFocusMode(ProfileEditorFocusMode.Selection); + + ProfileService.FocusProfileElement.ShouldBe(_layer); + } + + [Fact] + public async Task ChangingTheSelection_MovesTheFocus() + { + await StartEditing(); + Service.ChangeFocusMode(ProfileEditorFocusMode.Selection); + + Service.ChangeCurrentProfileElement(_folder); + ProfileService.FocusProfileElement.ShouldBe(_folder); + + Service.ChangeCurrentProfileElement(null); + ProfileService.FocusProfileElement.ShouldBeNull(); + } + + [Fact] + public async Task BackToNoFocus_ClearsTheFocus() + { + await StartEditing(); + Service.ChangeCurrentProfileElement(_layer); + Service.ChangeFocusMode(ProfileEditorFocusMode.Selection); + + Service.ChangeFocusMode(ProfileEditorFocusMode.None); + + ProfileService.FocusProfileElement.ShouldBeNull(); + } + + [Fact] + public async Task ChangeFocusMode_ToTheCurrentMode_DoesNothing() + { + await StartEditing(); + Service.ChangeCurrentProfileElement(_layer); + Service.ChangeFocusMode(ProfileEditorFocusMode.Selection); + int changes = ProfileService.FocusProfileElementChanges.Count; + + Service.ChangeFocusMode(ProfileEditorFocusMode.Selection); + + ProfileService.FocusProfileElementChanges.Count.ShouldBe(changes); + } + + [Fact] + public async Task SuspendedEditing_StillFocusesTheSelection() + { + await StartEditing(); + Service.ChangeCurrentProfileElement(_layer); + Service.ChangeFocusMode(ProfileEditorFocusMode.Selection); + ProfileService.FocusProfileElementChanges.Clear(); + + Service.ChangeSuspendedEditing(true); + + // Pinned as-is: while editing is suspended ApplyFocusMode clears the focus, but then immediately sets it again + // from the focus mode, so the clear has no effect. It looks like the intention was to not focus anything while + // the profile plays normally. + ProfileService.FocusProfileElementChanges.ShouldBe([null, _layer]); + ProfileService.FocusProfileElement.ShouldBe(_layer); + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorHistoryTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorHistoryTests.cs new file mode 100644 index 000000000..baf17ec2c --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorHistoryTests.cs @@ -0,0 +1,262 @@ +using Artemis.UI.Shared.Services.ProfileEditor; +using Artemis.UI.Shared.Services.ProfileEditor.Commands; +using System.Reactive; +using Artemis.UI.Shared.Tests.Infrastructure; +using ReactiveUI; + +namespace Artemis.UI.Shared.Tests.ProfileEditor; + +/// +/// Executing commands through the editor, undo and redo, and command scopes +/// +public class ProfileEditorHistoryTests : ProfileEditorTestBase +{ + private readonly List _log = []; + + [Fact] + public void ExecuteCommand_WithoutProfile_ShowsAnErrorAndThrows() + { + RecordingCommand command = new("A", _log); + + Should.Throw(() => Service.ExecuteCommand(command)); + + _log.ShouldBeEmpty(); + WindowService.ExceptionDialogs.ShouldHaveSingleItem().Title.ShouldBe("Editor command failed"); + } + + [Fact] + public async Task ExecuteCommand_ExecutesTheCommandAndAllowsUndo() + { + await StartEditing(); + List canUndo = []; + using IDisposable _ = CurrentHistory.CanUndo.Subscribe(canUndo.Add); + + Service.ExecuteCommand(new RecordingCommand("A", _log)); + + _log.ShouldBe(["execute A"]); + canUndo.ShouldBe([true]); + } + + [Fact] + public async Task CanUndoAndCanRedo_SayNothingUntilTheHistoryChanges() + { + await StartEditing(); + List canUndo = []; + List canRedo = []; + + using IDisposable undoSubscription = CurrentHistory.CanUndo.Subscribe(canUndo.Add); + using IDisposable redoSubscription = CurrentHistory.CanRedo.Subscribe(canRedo.Add); + + // Pinned as-is: the subjects behind CanUndo and CanRedo have no initial value, a new subscriber only hears about + // the next change. Bindings made after a change show the wrong state until the next one + canUndo.ShouldBeEmpty(); + canRedo.ShouldBeEmpty(); + } + + [Fact] + public async Task UndoAndRedo_WalkThroughTheHistoryInOrder() + { + await StartEditing(); + RecordingCommand a = new("A", _log); + RecordingCommand b = new("B", _log); + Service.ExecuteCommand(a); + Service.ExecuteCommand(b); + + Undo().ShouldBe(b); + Undo().ShouldBe(a); + Redo().ShouldBe(a); + Redo().ShouldBe(b); + + _log.ShouldBe(["execute A", "execute B", "undo B", "undo A", "execute A", "execute B"]); + } + + [Fact] + public async Task UndoAndRedo_WithNothingToUndoOrRedo_DoNothing() + { + await StartEditing(); + + Undo().ShouldBeNull(); + Redo().ShouldBeNull(); + } + + [Fact] + public async Task UndoAndRedo_ReportWhetherMoreCanBeUndoneOrRedone() + { + await StartEditing(); + List canUndo = []; + List canRedo = []; + using IDisposable undoSubscription = CurrentHistory.CanUndo.Subscribe(canUndo.Add); + using IDisposable redoSubscription = CurrentHistory.CanRedo.Subscribe(canRedo.Add); + + Service.ExecuteCommand(new RecordingCommand("A", _log)); + Undo(); + Redo(); + + canUndo.ShouldBe([true, false, true]); + canRedo.ShouldBe([false, true, false]); + } + + [Fact] + public async Task ExecuteCommand_AfterUndo_ThrowsAwayAndDisposesWhatCouldBeRedone() + { + await StartEditing(); + RecordingCommand a = new("A", _log); + RecordingCommand b = new("B", _log); + Service.ExecuteCommand(a); + Undo(); + + Service.ExecuteCommand(b); + + a.Disposed.ShouldBeTrue(); + b.Disposed.ShouldBeFalse(); + Redo().ShouldBeNull(); + } + + [Fact] + public async Task Clear_DisposesEverythingInTheHistory() + { + await StartEditing(); + RecordingCommand a = new("A", _log); + RecordingCommand b = new("B", _log); + Service.ExecuteCommand(a); + Service.ExecuteCommand(b); + Undo(); + + CurrentHistory.Clear(); + + a.Disposed.ShouldBeTrue(); + b.Disposed.ShouldBeTrue(); + Undo().ShouldBeNull(); + Redo().ShouldBeNull(); + } + + [Fact] + public async Task ExecuteCommand_ThatFails_DoesNotEndUpInTheHistory() + { + RecordingCommand failing = new("A", _log) {ExecuteException = new InvalidOperationException("Oops")}; + + // ReactiveCommand also reports the exception to ReactiveUI's default exception handler, which throws it again on + // the main thread and would end the test run (and the app). Catch it here instead, the history picks up the + // handler when it is created, which happens when editing starts + List unhandled = []; + IObserver defaultHandler = RxApp.DefaultExceptionHandler; + RxApp.DefaultExceptionHandler = Observer.Create(unhandled.Add); + try + { + await StartEditing(); + Should.Throw(() => Service.ExecuteCommand(failing)).Message.ShouldBe("Oops"); + } + finally + { + RxApp.DefaultExceptionHandler = defaultHandler; + } + + WindowService.ExceptionDialogs.ShouldHaveSingleItem().Title.ShouldBe("Editor command failed"); + // Pinned as-is: besides the error dialog, ReactiveUI treats the failure as unhandled because nothing subscribes to + // the ThrownExceptions of the history's Execute command. In the app that means a failing command is reported as + // a crash on top of the dialog. + unhandled.ShouldHaveSingleItem().ShouldBeOfType(); + Undo().ShouldBeNull(); + } + + [Fact] + public async Task CommandScope_ExecutesCommandsRightAwayAndAddsThemToTheHistoryAsOne() + { + await StartEditing(); + RecordingCommand a = new("A", _log); + RecordingCommand b = new("B", _log); + + using (ProfileEditorCommandScope scope = Service.CreateCommandScope("Scope")) + { + Service.ExecuteCommand(a); + Service.ExecuteCommand(b); + + _log.ShouldBe(["execute A", "execute B"]); + scope.ProfileEditorCommands.ShouldBe([a, b]); + Undo().ShouldBeNull(); + } + + // Adding the scope to the history does not execute its commands again + _log.ShouldBe(["execute A", "execute B"]); + + IProfileEditorCommand? undone = Undo(); + undone.ShouldBeOfType().DisplayName.ShouldBe("Scope"); + Redo(); + _log.ShouldBe(["execute A", "execute B", "undo B", "undo A", "execute A", "execute B"]); + } + + [Fact] + public async Task CommandScope_CantBeNested() + { + await StartEditing(); + using ProfileEditorCommandScope _ = Service.CreateCommandScope("Outer"); + + Should.Throw(() => Service.CreateCommandScope("Inner")).Message.ShouldContain("Outer"); + } + + [Fact] + public async Task CommandScope_NeedsAName() + { + await StartEditing(); + + Should.Throw(() => Service.CreateCommandScope(null!)); + } + + [Fact] + public async Task CommandScope_DisposedTwice_AddsItselfToTheHistoryOnce() + { + await StartEditing(); + ProfileEditorCommandScope scope = Service.CreateCommandScope("Scope"); + Service.ExecuteCommand(new RecordingCommand("A", _log)); + + scope.Dispose(); + scope.Dispose(); + + Undo().ShouldBeOfType(); + Undo().ShouldBeNull(); + } + + [Fact] + public async Task CommandScope_WithoutCommands_StillAddsAnEntryToTheHistory() + { + await StartEditing(); + + Service.CreateCommandScope("Empty").Dispose(); + + // Pinned as-is: an empty scope leaves an undo step that does nothing + Undo().ShouldBeOfType().DisplayName.ShouldBe("Empty"); + } + + [Fact] + public async Task CommandScope_LeftOpenWhenSwitchingProfiles_EndsUpInTheHistoryOfThePreviousProfile() + { + Profile other = Harness.CreateProfile("Other profile"); + await StartEditing(); + ProfileEditorHistory history = CurrentHistory; + Service.CreateCommandScope("Scope"); + Service.ExecuteCommand(new RecordingCommand("A", _log)); + + await Service.ChangeCurrentProfileConfiguration(other.Configuration); + + Undo().ShouldBeNull(); + await StartEditing(); + CurrentHistory.ShouldBe(history); + Undo().ShouldBeOfType().DisplayName.ShouldBe("Scope"); + // A new scope can be created again + Service.CreateCommandScope("Next").Dispose(); + } + + [Fact] + public void ProfileEditorHistory_CanBeUsedOnItsOwn() + { + ProfileEditorHistory history = new(Configuration); + RecordingCommand a = new("A", _log); + + history.ExecuteEditorCommand(a); + history.Undo.Execute().Subscribe(); + history.Redo.Execute().Subscribe(); + + history.ProfileConfiguration.ShouldBe(Configuration); + _log.ShouldBe(["execute A", "undo A", "execute A"]); + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorServiceTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorServiceTests.cs new file mode 100644 index 000000000..a6aa24504 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorServiceTests.cs @@ -0,0 +1,292 @@ +using Artemis.UI.Shared.Services.ProfileEditor; +using Artemis.UI.Shared.Services.ProfileEditor.Commands; +using Artemis.UI.Shared.Tests.Infrastructure; +using RGB.NET.Core; + +namespace Artemis.UI.Shared.Tests.ProfileEditor; + +/// +/// Opening and switching profiles, selection, playback and saving +/// +public class ProfileEditorServiceTests : ProfileEditorTestBase +{ + [Fact] + public void NewService_HasNothingSelected() + { + Current(Service.ProfileConfiguration).ShouldBeNull(); + Current(Service.ProfileElement).ShouldBeNull(); + Current(Service.LayerProperty).ShouldBeNull(); + Current(Service.History).ShouldBeNull(); + Current(Service.Time).ShouldBe(TimeSpan.Zero); + Current(Service.Playing).ShouldBeFalse(); + Current(Service.SuspendedEditing).ShouldBeFalse(); + Current(Service.PixelsPerSecond).ShouldBe(120); + Current(Service.FocusMode).ShouldBe(ProfileEditorFocusMode.None); + Service.SelectedKeyframes.ShouldBeEmpty(); + } + + [Fact] + public async Task ChangeCurrentProfileConfiguration_ActivatesAndFocusesTheProfile() + { + await Service.ChangeCurrentProfileConfiguration(Configuration); + + Current(Service.ProfileConfiguration).ShouldBe(Configuration); + ProfileService.FocusProfile.ShouldBe(Configuration); + ProfileService.ActivatedProfiles.ShouldBe([Configuration]); + // The configuration has no module, so the override is deliberately cleared + ModuleService.ActivationOverrides.ShouldBe([null]); + Current(Service.History)!.ProfileConfiguration.ShouldBe(Configuration); + } + + [Fact] + public async Task ChangeCurrentProfileConfiguration_OverridesTheActivationOfTheProfilesModule() + { + using Core.Tests.Infrastructure.Plugins.TestPlugin plugin = new(); + Core.Tests.Infrastructure.Plugins.Modules.TestModule module = plugin.AddFeature(new Core.Tests.Infrastructure.Plugins.Modules.TestModule()); + Configuration.Module = module; + + await Service.ChangeCurrentProfileConfiguration(Configuration); + + ModuleService.ActivationOverrides.ShouldBe([module]); + } + + [Fact] + public async Task ChangeCurrentProfileConfiguration_ToTheSameConfiguration_DoesNothing() + { + await Service.ChangeCurrentProfileConfiguration(Configuration); + await Service.ChangeCurrentProfileConfiguration(Configuration); + + ProfileService.ActivatedProfiles.Count.ShouldBe(1); + ProfileService.SavedProfiles.ShouldBeEmpty(); + } + + [Fact] + public async Task ChangeCurrentProfileConfiguration_SavesThePreviousProfileAndStopsPlaying() + { + Profile other = Harness.CreateProfile("Other profile"); + await StartEditing(); + Service.Play(); + + await Service.ChangeCurrentProfileConfiguration(other.Configuration); + + ProfileService.SavedProfiles.ShouldBe([(Profile, true)]); + Current(Service.Playing).ShouldBeFalse(); + Current(Service.ProfileConfiguration).ShouldBe(other.Configuration); + } + + [Fact] + public async Task ChangeCurrentProfileConfiguration_ClearsTheSelectionAndResetsTime() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + Profile other = Harness.CreateProfile("Other profile"); + await StartEditing(); + Service.ChangeCurrentProfileElement(layer); + Service.ChangeCurrentLayerProperty(layer.Transform.Opacity); + Service.ChangeTime(TimeSpan.FromSeconds(2)); + + await Service.ChangeCurrentProfileConfiguration(other.Configuration); + + Current(Service.ProfileElement).ShouldBeNull(); + Current(Service.LayerProperty).ShouldBeNull(); + Current(Service.Time).ShouldBe(TimeSpan.Zero); + } + + [Fact] + public async Task ChangeCurrentProfileConfiguration_ResetsThePreviousProfile() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + Profile other = Harness.CreateProfile("Other profile"); + await StartEditing(); + Service.ChangeTime(TimeSpan.FromSeconds(2)); + layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(2)); + + await Service.ChangeCurrentProfileConfiguration(other.Configuration); + + // Reset puts the timeline back at the start, so the profile plays from the beginning outside the editor + layer.Timeline.Position.ShouldBe(TimeSpan.Zero); + } + + [Fact] + public async Task ChangeCurrentProfileConfiguration_EndsSuspendedEditing() + { + Profile other = Harness.CreateProfile("Other profile"); + await StartEditing(); + Service.ChangeSuspendedEditing(true); + + await Service.ChangeCurrentProfileConfiguration(other.Configuration); + + Current(Service.SuspendedEditing).ShouldBeFalse(); + } + + [Fact] + public async Task ChangeCurrentProfileConfiguration_ToNothing_StopsEditing() + { + await StartEditing(); + + await Service.ChangeCurrentProfileConfiguration(null); + + Current(Service.ProfileConfiguration).ShouldBeNull(); + Current(Service.History).ShouldBeNull(); + ProfileService.FocusProfile.ShouldBeNull(); + ProfileService.ActivatedProfiles.ShouldBe([Configuration]); + ModuleService.ActivationOverrides.ShouldBe([null, null]); + ProfileService.SavedProfiles.ShouldBe([(Profile, true)]); + } + + [Fact] + public async Task EachProfileConfiguration_KeepsItsOwnHistory() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + Profile other = Harness.CreateProfile("Other profile"); + await StartEditing(); + ProfileEditorHistory firstHistory = CurrentHistory; + Service.ExecuteCommand(new RenameProfileElement(layer, "Renamed")); + + await Service.ChangeCurrentProfileConfiguration(other.Configuration); + ProfileEditorHistory otherHistory = CurrentHistory; + otherHistory.ShouldNotBe(firstHistory); + Undo().ShouldBeNull(); + layer.Name.ShouldBe("Renamed"); + + await StartEditing(); + CurrentHistory.ShouldBe(firstHistory); + Undo().ShouldBeOfType(); + layer.Name.ShouldBe("Layer"); + } + + [Fact] + public async Task History_GivesNewSubscribersTheCurrentHistoryTwice() + { + await StartEditing(); + List received = []; + + using IDisposable _ = Service.History.Subscribe(received.Add); + + // Pinned as-is: History first returns the current history and then subscribes to ProfileConfiguration, which + // repeats the current configuration. Harmless for the UI, but every subscriber gets the same history twice + received.Count.ShouldBe(2); + received.ShouldAllBe(h => h == received[0]); + received[0]!.ProfileConfiguration.ShouldBe(Configuration); + } + + [Fact] + public async Task ChangeCurrentProfileElement_SelectsTheElementAndClearsTheRestOfTheSelection() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + Layer otherLayer = AddLayer("Other layer", null, LedId.Keyboard_B); + await StartEditing(); + layer.Transform.Opacity.KeyframesEnabled = true; + LayerPropertyKeyframe keyframe = new(50, TimeSpan.Zero, Easings.Functions.Linear, layer.Transform.Opacity); + layer.Transform.Opacity.AddKeyframe(keyframe); + Service.ChangeCurrentProfileElement(layer); + Service.ChangeCurrentLayerProperty(layer.Transform.Opacity); + Service.SelectKeyframe(keyframe, false, false); + + Service.ChangeCurrentProfileElement(otherLayer); + + Current(Service.ProfileElement).ShouldBe(otherLayer); + Current(Service.LayerProperty).ShouldBeNull(); + Service.SelectedKeyframes.ShouldBeEmpty(); + } + + [Fact] + public void ChangeCurrentLayerProperty_SelectsTheProperty() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + + Service.ChangeCurrentLayerProperty(layer.Transform.Opacity); + + Current(Service.LayerProperty).ShouldBe(layer.Transform.Opacity); + } + + [Fact] + public void ChangePixelsPerSecond_ChangesTheZoomLevel() + { + Service.ChangePixelsPerSecond(300); + + Current(Service.PixelsPerSecond).ShouldBe(300); + } + + [Fact] + public void PlayAndPause_OnlyReportChanges() + { + List received = []; + using IDisposable _ = Service.Playing.Subscribe(received.Add); + + Service.Pause(); + Service.Play(); + Service.Play(); + Service.Pause(); + Service.Pause(); + + received.ShouldBe([false, true, false]); + } + + [Fact] + public async Task SaveProfileAsync_SavesTheProfileIncludingItsChildren() + { + await StartEditing(); + + await Service.SaveProfileAsync(); + + ProfileService.SavedProfiles.ShouldBe([(Profile, true)]); + } + + [Fact] + public async Task SaveProfileAsync_WithoutProfile_SavesNothing() + { + await Service.SaveProfileAsync(); + + ProfileService.SavedProfiles.ShouldBeEmpty(); + } + + [Fact] + public async Task Commands_SaveTheProfileOnceASecondAfterTheLastChange() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + await StartEditing(); + + Service.ExecuteCommand(new RenameProfileElement(layer, "First")); + Service.ExecuteCommand(new RenameProfileElement(layer, "Second")); + Undo(); + ProfileService.SavedProfiles.ShouldBeEmpty(); + + await WaitUntil(() => ProfileService.SavedProfiles.Count > 0); + await Task.Delay(1200, TestContext.Current.CancellationToken); + + lock (ProfileService.SavedProfiles) + ProfileService.SavedProfiles.ShouldBe([(Profile, true)]); + } + + [Fact] + public async Task AutoSave_AfterAFailedSave_StopsSaving() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + await StartEditing(); + ProfileService.SaveException = new InvalidOperationException("Disk full"); + + Service.ExecuteCommand(new RenameProfileElement(layer, "First")); + await WaitUntil(() => WindowService.ExceptionDialogs.Count > 0); + WindowService.ExceptionDialogs.ShouldHaveSingleItem().Title.ShouldBe("Failed to auto-save profile"); + + ProfileService.SaveException = null; + Service.ExecuteCommand(new RenameProfileElement(layer, "Second")); + await Task.Delay(2000, TestContext.Current.CancellationToken); + + // Pinned as-is: the auto-save rethrows the exception after showing the dialog, which ends the observable that + // drives auto-saving. Nothing is auto-saved anymore for as long as the app runs, without telling the user + lock (ProfileService.SavedProfiles) + ProfileService.SavedProfiles.Count.ShouldBe(1); + } + + [Fact] + public async Task ClosingTheMainWindow_StopsEditing() + { + await StartEditing(); + + MainWindowService.RaiseMainWindowClosed(); + + await WaitUntil(() => Current(Service.ProfileConfiguration) == null); + ProfileService.FocusProfile.ShouldBeNull(); + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorTimeTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorTimeTests.cs new file mode 100644 index 000000000..36370f54b --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorTimeTests.cs @@ -0,0 +1,178 @@ +using Artemis.UI.Shared.Services.ProfileEditor.Commands; +using Artemis.UI.Shared.Tests.Infrastructure; +using RGB.NET.Core; + +namespace Artemis.UI.Shared.Tests.ProfileEditor; + +/// +/// Moving through time in the editor, which overrides the timelines of the profile being edited, and suspending editing +/// +public class ProfileEditorTimeTests : ProfileEditorTestBase +{ + [Fact] + public async Task ChangeTime_MovesTheTimelineOfEveryElement() + { + Folder folder = Harness.AddFolder(Root, "Folder"); + Layer layer = AddLayer("Layer", folder, LedId.Keyboard_A); + await StartEditing(); + + Service.ChangeTime(TimeSpan.FromSeconds(1.5)); + + Current(Service.Time).ShouldBe(TimeSpan.FromSeconds(1.5)); + foreach (RenderProfileElement element in new RenderProfileElement[] {Root, folder, layer}) + { + element.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(1.5)); + element.Timeline.IsOverridden.ShouldBeTrue(); + } + } + + [Fact] + public async Task ChangeTime_EnablesElements() + { + Folder folder = Harness.AddFolder(Root, "Folder"); + Layer layer = AddLayer("Layer", folder, LedId.Keyboard_A); + folder.Disable(); + layer.Disable(); + await StartEditing(); + + Service.ChangeTime(TimeSpan.FromSeconds(1)); + + folder.Enabled.ShouldBeTrue(); + layer.Enabled.ShouldBeTrue(); + } + + [Fact] + public async Task ChangeTime_AppliesKeyframesAtTheNewTime() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + FloatLayerProperty opacity = layer.Transform.Opacity; + opacity.KeyframesEnabled = true; + opacity.AddKeyframe(new LayerPropertyKeyframe(0, TimeSpan.Zero, Easings.Functions.Linear, opacity)); + opacity.AddKeyframe(new LayerPropertyKeyframe(100, TimeSpan.FromSeconds(2), Easings.Functions.Linear, opacity)); + await StartEditing(); + + Service.ChangeTime(TimeSpan.FromSeconds(1)); + opacity.CurrentValue.ShouldBe(50, 0.01f); + + Service.ChangeTime(TimeSpan.FromSeconds(2)); + opacity.CurrentValue.ShouldBe(100, 0.01f); + } + + [Fact] + public async Task ChangeTime_DisablesSuspendedElementsAndSkipsTheirChildren() + { + Folder folder = Harness.AddFolder(Root, "Folder"); + Layer layer = AddLayer("Layer", folder, LedId.Keyboard_A); + await StartEditing(); + Service.ChangeTime(TimeSpan.FromSeconds(1)); + + folder.Suspended = true; + Service.ChangeTime(TimeSpan.FromSeconds(2)); + + // Disabling a folder disables its children, their timelines are left where they were + folder.Enabled.ShouldBeFalse(); + layer.Enabled.ShouldBeFalse(); + layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ChangeTime_WithoutProfile_OnlyChangesTheTime() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + + Service.ChangeTime(TimeSpan.FromSeconds(1)); + + Current(Service.Time).ShouldBe(TimeSpan.FromSeconds(1)); + layer.Timeline.IsOverridden.ShouldBeFalse(); + } + + [Fact] + public async Task ChangeTime_WhileEditingIsSuspended_LeavesTheProfileAlone() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + await StartEditing(); + Service.ChangeSuspendedEditing(true); + + Service.ChangeTime(TimeSpan.FromSeconds(1)); + + Current(Service.Time).ShouldBe(TimeSpan.FromSeconds(1)); + layer.Timeline.Position.ShouldNotBe(TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task ExecuteCommand_AppliesTheCurrentTimeToTheChangedProfile() + { + await StartEditing(); + Service.ChangeTime(TimeSpan.FromSeconds(1)); + Layer layer = new(Root, "New layer"); + + Service.ExecuteCommand(new AddProfileElement(layer, Root, 0)); + + layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task SuspendingEditing_StopsPlayingAndHandsTheProfileBackToNormalPlayback() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + await StartEditing(); + Service.ChangeTime(TimeSpan.FromSeconds(2)); + Service.Play(); + + Service.ChangeSuspendedEditing(true); + + Current(Service.SuspendedEditing).ShouldBeTrue(); + Current(Service.Playing).ShouldBeFalse(); + ProfileService.UpdateFocusProfile.ShouldBeTrue(); + // The profile is reset so it plays from the start + layer.Timeline.Position.ShouldBe(TimeSpan.Zero); + // The editor keeps its own time + Current(Service.Time).ShouldBe(TimeSpan.FromSeconds(2)); + } + + [Fact] + public async Task ResumingEditing_TakesBackControlButLeavesTheTimelinesAtTheStart() + { + Layer layer = AddLayer("Layer", null, LedId.Keyboard_A); + await StartEditing(); + Service.ChangeTime(TimeSpan.FromSeconds(2)); + Service.ChangeSuspendedEditing(true); + + Service.ChangeSuspendedEditing(false); + + Current(Service.SuspendedEditing).ShouldBeFalse(); + ProfileService.UpdateFocusProfile.ShouldBeFalse(); + // Pinned as-is: resuming calls Tick to apply the editor's time again, but does so before marking editing as + // resumed, and Tick skips suspended editing. So the profile stays where suspending left it (at the start) until + // the time changes, while the editor shows 2 seconds + layer.Timeline.Position.ShouldBe(TimeSpan.Zero); + Current(Service.Time).ShouldBe(TimeSpan.FromSeconds(2)); + + Service.ChangeTime(TimeSpan.FromSeconds(2)); + layer.Timeline.Position.ShouldBe(TimeSpan.FromSeconds(2)); + } + + [Fact] + public void ResumingEditing_WithoutProfile_LeavesTheProfileServiceAlone() + { + Service.ChangeSuspendedEditing(true); + ProfileService.UpdateFocusProfile.ShouldBeTrue(); + + Service.ChangeSuspendedEditing(false); + + ProfileService.UpdateFocusProfile.ShouldBeTrue(); + } + + [Fact] + public async Task ChangeSuspendedEditing_ToTheCurrentValue_DoesNothing() + { + await StartEditing(); + List received = []; + using IDisposable _ = Service.SuspendedEditing.Subscribe(received.Add); + + Service.ChangeSuspendedEditing(false); + + received.ShouldBe([false]); + ProfileService.UpdateFocusProfile.ShouldBeFalse(); + } +} diff --git a/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorTimelineHelperTests.cs b/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorTimelineHelperTests.cs new file mode 100644 index 000000000..f12fea478 --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/ProfileEditor/ProfileEditorTimelineHelperTests.cs @@ -0,0 +1,178 @@ +using Artemis.UI.Shared.Tests.Infrastructure; +using RGB.NET.Core; + +namespace Artemis.UI.Shared.Tests.ProfileEditor; + +/// +/// Keyframe selection, snapping and rounding used by the timeline +/// +public class ProfileEditorTimelineHelperTests : ProfileEditorTestBase +{ + private readonly Layer _layer; + private readonly LayerPropertyKeyframe _first; + private readonly LayerPropertyKeyframe _second; + private readonly LayerPropertyKeyframe _third; + + public ProfileEditorTimelineHelperTests() + { + _layer = AddLayer("Layer", null, LedId.Keyboard_A); + FloatLayerProperty opacity = _layer.Transform.Opacity; + opacity.KeyframesEnabled = true; + _first = new LayerPropertyKeyframe(0, TimeSpan.Zero, Easings.Functions.Linear, opacity); + _second = new LayerPropertyKeyframe(50, TimeSpan.FromSeconds(1), Easings.Functions.Linear, opacity); + _third = new LayerPropertyKeyframe(100, TimeSpan.FromSeconds(2), Easings.Functions.Linear, opacity); + opacity.AddKeyframe(_first); + opacity.AddKeyframe(_second); + opacity.AddKeyframe(_third); + } + + [Fact] + public void SelectKeyframe_ReplacesTheSelection() + { + Service.SelectKeyframe(_first, false, false); + Service.SelectKeyframe(_second, false, false); + + Service.SelectedKeyframes.ShouldBe([_second]); + } + + [Fact] + public void SelectKeyframe_Expanding_AddsToTheSelection() + { + Service.SelectKeyframe(_first, false, false); + Service.SelectKeyframe(_second, true, false); + + Service.SelectedKeyframes.ShouldBe([_first, _second]); + } + + [Fact] + public void SelectKeyframe_ExpandingWithAKeyframeAlreadySelected_SelectsItTwice() + { + Service.SelectKeyframe(_first, false, false); + Service.SelectKeyframe(_first, true, false); + + // Pinned as-is: unlike SelectKeyframes, expanding the selection does not check whether the keyframe is already + // selected, so it ends up in the selection twice + Service.SelectedKeyframes.ShouldBe([_first, _first]); + } + + [Fact] + public void SelectKeyframe_Toggling_FlipsOnlyThatKeyframe() + { + Service.SelectKeyframe(_first, false, false); + + Service.SelectKeyframe(_second, false, true); + Service.SelectedKeyframes.ShouldBe([_first, _second]); + + Service.SelectKeyframe(_first, false, true); + Service.SelectedKeyframes.ShouldBe([_second]); + } + + [Fact] + public void SelectKeyframe_Nothing_ClearsTheSelectionUnlessExpanding() + { + Service.SelectKeyframe(_first, false, false); + + Service.SelectKeyframe(null, true, false); + Service.SelectedKeyframes.ShouldBe([_first]); + + Service.SelectKeyframe(null, false, false); + Service.SelectedKeyframes.ShouldBeEmpty(); + } + + [Fact] + public void SelectKeyframes_ReplacesTheSelection() + { + Service.SelectKeyframe(_first, false, false); + + Service.SelectKeyframes([_second, _third], false); + + Service.SelectedKeyframes.ShouldBe([_second, _third]); + } + + [Fact] + public void SelectKeyframes_Expanding_AddsKeyframesNotYetSelected() + { + Service.SelectKeyframe(_first, false, false); + + Service.SelectKeyframes([_first, _second], true); + + Service.SelectedKeyframes.ShouldBe([_first, _second]); + } + + [Theory] + [InlineData(1050, 1000)] // close to the end of the start segment + [InlineData(5950, 6000)] // close to the end of the main segment + [InlineData(7090, 7000)] // close to the end of the end segment + [InlineData(1100, 1100)] // the tolerance is exclusive + [InlineData(3000, 3000)] // nowhere near a segment + public void SnapToTimeline_SnapsToTheEndsOfTheSelectedElementsSegments(int time, int expected) + { + _layer.Timeline.StartSegmentLength = TimeSpan.FromSeconds(1); + _layer.Timeline.MainSegmentLength = TimeSpan.FromSeconds(5); + _layer.Timeline.EndSegmentLength = TimeSpan.FromSeconds(1); + Service.ChangeCurrentProfileElement(_layer); + + TimeSpan result = Service.SnapToTimeline(TimeSpan.FromMilliseconds(time), TimeSpan.FromMilliseconds(100), true, false); + + result.ShouldBe(TimeSpan.FromMilliseconds(expected)); + } + + [Fact] + public void SnapToTimeline_WithoutSnappingToSegments_IgnoresSegments() + { + _layer.Timeline.StartSegmentLength = TimeSpan.FromSeconds(1); + Service.ChangeCurrentProfileElement(_layer); + + Service.SnapToTimeline(TimeSpan.FromMilliseconds(1050), TimeSpan.FromMilliseconds(100), false, false) + .ShouldBe(TimeSpan.FromMilliseconds(1050)); + } + + [Fact] + public void SnapToTimeline_WithoutSelection_IgnoresSegments() + { + Service.SnapToTimeline(TimeSpan.FromMilliseconds(4950), TimeSpan.FromMilliseconds(100), true, false) + .ShouldBe(TimeSpan.FromMilliseconds(4950)); + } + + [Fact] + public void SnapToTimeline_SnapsToTheCurrentTime() + { + Service.ChangeTime(TimeSpan.FromSeconds(3)); + + Service.SnapToTimeline(TimeSpan.FromMilliseconds(3040), TimeSpan.FromMilliseconds(100), false, true).ShouldBe(TimeSpan.FromSeconds(3)); + Service.SnapToTimeline(TimeSpan.FromMilliseconds(3040), TimeSpan.FromMilliseconds(100), false, false).ShouldBe(TimeSpan.FromMilliseconds(3040)); + } + + [Fact] + public void SnapToTimeline_SnapsToTheFirstCloseSnapTime() + { + List snapTimes = [TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(560), TimeSpan.FromMilliseconds(540)]; + + Service.SnapToTimeline(TimeSpan.FromMilliseconds(550), TimeSpan.FromMilliseconds(100), false, false, snapTimes) + .ShouldBe(TimeSpan.FromMilliseconds(500)); + } + + [Fact] + public void SnapToTimeline_NeverSnapsToASnapTimeOfZero() + { + List snapTimes = [TimeSpan.Zero]; + + // Pinned as-is: a snap time of zero can't be told apart from "no snap time found" (the default of TimeSpan), so + // a keyframe at the very start of the timeline is never snapped to + Service.SnapToTimeline(TimeSpan.FromMilliseconds(30), TimeSpan.FromMilliseconds(100), false, false, snapTimes) + .ShouldBe(TimeSpan.FromMilliseconds(30)); + } + + [Theory] + [InlineData(40, 1290, 1200)] // zoomed far out: 200 ms steps + [InlineData(80, 1260, 1300)] // 100 ms steps + [InlineData(120, 1260, 1250)] // the default zoom level: 50 ms steps + [InlineData(300, 1269, 1260)] // 20 ms steps + [InlineData(600, 1269.4, 1269)] // zoomed far in: whole milliseconds + public void RoundTime_RoundsToStepsThatFitTheZoomLevel(int pixelsPerSecond, double milliseconds, double expected) + { + Service.ChangePixelsPerSecond(pixelsPerSecond); + + Service.RoundTime(TimeSpan.FromMilliseconds(milliseconds)).ShouldBe(TimeSpan.FromMilliseconds(expected)); + } +} diff --git a/src/Artemis.UI.Shared.Tests/TestAssembly.cs b/src/Artemis.UI.Shared.Tests/TestAssembly.cs new file mode 100644 index 000000000..f57a8315c --- /dev/null +++ b/src/Artemis.UI.Shared.Tests/TestAssembly.cs @@ -0,0 +1,6 @@ +using Xunit.Sdk; +using Xunit.v3; + +// Same reason as Artemis.Core.Tests: Artemis.Core keeps brush and effect registrations in static stores that tests would +// otherwise share +[assembly: Parallelization(Mode = ParallelMode.None)] diff --git a/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj b/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj index e56e208fa..794f59a53 100644 --- a/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj +++ b/src/Artemis.UI.Shared/Artemis.UI.Shared.csproj @@ -26,4 +26,8 @@ + + + + diff --git a/src/Artemis.sln b/src/Artemis.sln index 64f0edfa7..a67da5e2b 100644 --- a/src/Artemis.sln +++ b/src/Artemis.sln @@ -32,6 +32,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Artemis.Storage.Legacy", "Artemis.Storage.Legacy\Artemis.Storage.Legacy.csproj", "{D7B0966D-774A-40E4-9455-00C1261ACB6A}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Artemis.Core.Tests", "Artemis.Core.Tests\Artemis.Core.Tests.csproj", "{294BEC18-ABE5-448A-89AE-F4AFD3A6793E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Artemis.UI.Shared.Tests", "Artemis.UI.Shared.Tests\Artemis.UI.Shared.Tests.csproj", "{6C1D8E4A-3F2B-4B7E-9A5D-2E8F1C7B4A90}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -78,6 +82,14 @@ Global {D7B0966D-774A-40E4-9455-00C1261ACB6A}.Debug|x64.Build.0 = Debug|Any CPU {D7B0966D-774A-40E4-9455-00C1261ACB6A}.Release|x64.ActiveCfg = Release|Any CPU {D7B0966D-774A-40E4-9455-00C1261ACB6A}.Release|x64.Build.0 = Release|Any CPU + {294BEC18-ABE5-448A-89AE-F4AFD3A6793E}.Debug|x64.ActiveCfg = Debug|x64 + {294BEC18-ABE5-448A-89AE-F4AFD3A6793E}.Debug|x64.Build.0 = Debug|x64 + {294BEC18-ABE5-448A-89AE-F4AFD3A6793E}.Release|x64.ActiveCfg = Release|x64 + {294BEC18-ABE5-448A-89AE-F4AFD3A6793E}.Release|x64.Build.0 = Release|x64 + {6C1D8E4A-3F2B-4B7E-9A5D-2E8F1C7B4A90}.Debug|x64.ActiveCfg = Debug|x64 + {6C1D8E4A-3F2B-4B7E-9A5D-2E8F1C7B4A90}.Debug|x64.Build.0 = Debug|x64 + {6C1D8E4A-3F2B-4B7E-9A5D-2E8F1C7B4A90}.Release|x64.ActiveCfg = Release|x64 + {6C1D8E4A-3F2B-4B7E-9A5D-2E8F1C7B4A90}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 36f7bc3fd..739630034 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 5365b6353..9f17cdaf2 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -61,6 +61,7 @@ + @@ -68,5 +69,10 @@ + + + + + \ No newline at end of file