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

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

No files matched your search

@@ -0,0 +1,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;
/// <summary>
/// Running several commands as one
/// </summary>
public class CompositeCommandTests : ProfileEditorTestBase
{
private readonly List<string> _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<ArgumentNullException>(() => new CompositeCommand(null!, "Name"));
Should.Throw<ArgumentNullException>(() => new CompositeCommand([], null!));
Should.Throw<ArgumentNullException>(() => new CompositeCommand(null!, "Name", true));
Should.Throw<ArgumentNullException>(() => 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<float>(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);
}
}
@@ -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;
/// <summary>
/// Changing display conditions and their settings
/// </summary>
public class ConditionCommandTests : ProfileEditorTestBase
{
private readonly Layer _layer;
public ConditionCommandTests()
{
_layer = AddLayer("Layer", null, LedId.Keyboard_A);
}
public static TheoryData<string> 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;
}
}
}
@@ -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;
/// <summary>
/// Deleting, duplicating, moving and easing keyframes, and turning keyframes on and off
/// </summary>
public class KeyframeCommandTests : ProfileEditorTestBase
{
private readonly FloatLayerProperty _opacity;
private readonly LayerPropertyKeyframe<float> _first;
private readonly LayerPropertyKeyframe<float> _second;
public KeyframeCommandTests()
{
Layer layer = AddLayer("Layer", null, LedId.Keyboard_A);
_opacity = layer.Transform.Opacity;
_opacity.KeyframesEnabled = true;
_first = new LayerPropertyKeyframe<float>(20, TimeSpan.FromSeconds(1), Easings.Functions.Linear, _opacity);
_second = new LayerPropertyKeyframe<float>(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<float> copy = command.Duplication.ShouldBeOfType<LayerPropertyKeyframe<float>>();
_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<float>) 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<float> command = new(rotation, true, TimeSpan.FromSeconds(2));
command.Execute();
command.DisplayName.ShouldBe("Enable keyframes");
rotation.KeyframesEnabled.ShouldBeTrue();
LayerPropertyKeyframe<float> 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<float> command = new(rotation, true, TimeSpan.FromSeconds(2));
command.Execute();
LayerPropertyKeyframe<float> 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<float> 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<float> 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<float> 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();
}
}
@@ -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;
/// <summary>
/// Changing the brush of a layer and the LEDs it covers
/// </summary>
public class LayerBrushAndLedCommandTests : ProfileEditorTestBase
{
private readonly Layer _layer;
private readonly SolidColorTestBrush _brush;
public LayerBrushAndLedCommandTests()
{
(_layer, _brush) = Harness.AddLayer<SolidColorTestBrush>(Root, Leds(LedId.Keyboard_A, LedId.Keyboard_S), "Layer");
_layer.Enable();
}
[Fact]
public void ChangeLayerBrush_GivesTheLayerANewBrush()
{
ChangeLayerBrush command = new(_layer, BrushDescriptor<GradientTestBrush>());
command.Execute();
command.DisplayName.ShouldBe("Change layer brush");
GradientTestBrush newBrush = _layer.LayerBrush.ShouldBeOfType<GradientTestBrush>();
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<GradientTestBrush>());
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<GradientTestBrush>());
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<GradientTestBrush>());
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<GradientTestBrush>();
}
[Fact]
public void ChangeLayerBrush_Dispose_DisposesTheBrushNoLongerInUse()
{
ChangeLayerBrush executed = new(_layer, BrushDescriptor<GradientTestBrush>());
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<GradientTestBrush>());
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();
}
}
@@ -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;
/// <summary>
/// Adding, removing and renaming effects
/// </summary>
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<OpacityTestEffect>(_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<TintTestEffect>(_layer);
OpacityTestEffect effect = NewEffect<OpacityTestEffect>(_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<TintTestEffect>(_layer);
string before = Snapshot();
OpacityTestEffect effect = NewEffect<OpacityTestEffect>(_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<OpacityTestEffect>(_layer);
AddLayerEffect addedCommand = new(_layer, added);
addedCommand.Execute();
TintTestEffect undone = NewEffect<TintTestEffect>(_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<OpacityTestEffect>(_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<TintTestEffect>(_layer);
Harness.AddEffect<OpacityTestEffect>(_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<TintTestEffect>(_layer);
Harness.AddEffect<OpacityTestEffect>(_layer);
Harness.AddEffect<ChannelSwapTestEffect>(_layer);
List<BaseLayerEffect> 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<OpacityTestEffect>(_layer);
RemoveLayerEffect removedCommand = new(removed);
removedCommand.Execute();
TintTestEffect undone = Harness.AddEffect<TintTestEffect>(_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<TintTestEffect>(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<OpacityTestEffect>(_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<OpacityTestEffect>(_layer);
new RenameLayerEffect(effect, "First").Execute();
RenameLayerEffect command = new(effect, "Second");
command.Execute();
command.Undo();
effect.Name.ShouldBe("First");
effect.HasBeenRenamed.ShouldBeTrue();
}
}
@@ -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;
/// <summary>
/// Changing and resetting property values, data bindings, color gradients and timeline segments
/// </summary>
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<float> AddKeyframe(float value, double seconds)
{
LayerPropertyKeyframe<float> 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<float> 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<float> command = new(_opacity, 40, TimeSpan.FromSeconds(1));
command.Execute();
command.DisplayName.ShouldBe("Add Opacity keyframe");
LayerPropertyKeyframe<float> 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<float> keyframe = AddKeyframe(60, 1);
_layer.OverrideTimelineAndApply(TimeSpan.FromSeconds(1));
string before = Snapshot();
UpdateLayerProperty<float> 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<float> 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<float> 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<float> 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<float> keyframe = AddKeyframe(60, 1);
UpdateLayerProperty<float> 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<float> 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<float> 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<ArtemisSharedUIException>(() => new ResetLayerProperty<LayerBrushReference>(_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
};
}
}
@@ -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;
/// <summary>
/// Adding, removing, moving and renaming folders and layers
/// </summary>
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<ArtemisSharedUIException>(() => 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<ArgumentException>(() => new RenameProfileElement(Profile, "Renamed"));
}
}
@@ -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;
/// <summary>
/// Adding new folders and layers through the editor
/// </summary>
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<SolidColorTestBrush>();
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<SolidColorTestBrush>();
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);
}
}
@@ -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;
/// <summary>
/// Focus modes decide which part of the profile the profile service keeps showing while editing
/// </summary>
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);
}
}
@@ -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;
/// <summary>
/// Executing commands through the editor, undo and redo, and command scopes
/// </summary>
public class ProfileEditorHistoryTests : ProfileEditorTestBase
{
private readonly List<string> _log = [];
[Fact]
public void ExecuteCommand_WithoutProfile_ShowsAnErrorAndThrows()
{
RecordingCommand command = new("A", _log);
Should.Throw<ArtemisSharedUIException>(() => Service.ExecuteCommand(command));
_log.ShouldBeEmpty();
WindowService.ExceptionDialogs.ShouldHaveSingleItem().Title.ShouldBe("Editor command failed");
}
[Fact]
public async Task ExecuteCommand_ExecutesTheCommandAndAllowsUndo()
{
await StartEditing();
List<bool> 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<bool> canUndo = [];
List<bool> 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<bool> canUndo = [];
List<bool> 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<Exception> unhandled = [];
IObserver<Exception> defaultHandler = RxApp.DefaultExceptionHandler;
RxApp.DefaultExceptionHandler = Observer.Create<Exception>(unhandled.Add);
try
{
await StartEditing();
Should.Throw<InvalidOperationException>(() => 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<InvalidOperationException>();
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<CompositeCommand>().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<ArtemisSharedUIException>(() => Service.CreateCommandScope("Inner")).Message.ShouldContain("Outer");
}
[Fact]
public async Task CommandScope_NeedsAName()
{
await StartEditing();
Should.Throw<ArgumentNullException>(() => 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<CompositeCommand>();
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<CompositeCommand>().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<CompositeCommand>().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"]);
}
}
@@ -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;
/// <summary>
/// Opening and switching profiles, selection, playback and saving
/// </summary>
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<RenameProfileElement>();
layer.Name.ShouldBe("Layer");
}
[Fact]
public async Task History_GivesNewSubscribersTheCurrentHistoryTwice()
{
await StartEditing();
List<ProfileEditorHistory?> 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<float> 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<bool> 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();
}
}
@@ -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;
/// <summary>
/// Moving through time in the editor, which overrides the timelines of the profile being edited, and suspending editing
/// </summary>
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<float>(0, TimeSpan.Zero, Easings.Functions.Linear, opacity));
opacity.AddKeyframe(new LayerPropertyKeyframe<float>(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<bool> received = [];
using IDisposable _ = Service.SuspendedEditing.Subscribe(received.Add);
Service.ChangeSuspendedEditing(false);
received.ShouldBe([false]);
ProfileService.UpdateFocusProfile.ShouldBeFalse();
}
}
@@ -0,0 +1,178 @@
using Artemis.UI.Shared.Tests.Infrastructure;
using RGB.NET.Core;
namespace Artemis.UI.Shared.Tests.ProfileEditor;
/// <summary>
/// Keyframe selection, snapping and rounding used by the timeline
/// </summary>
public class ProfileEditorTimelineHelperTests : ProfileEditorTestBase
{
private readonly Layer _layer;
private readonly LayerPropertyKeyframe<float> _first;
private readonly LayerPropertyKeyframe<float> _second;
private readonly LayerPropertyKeyframe<float> _third;
public ProfileEditorTimelineHelperTests()
{
_layer = AddLayer("Layer", null, LedId.Keyboard_A);
FloatLayerProperty opacity = _layer.Transform.Opacity;
opacity.KeyframesEnabled = true;
_first = new LayerPropertyKeyframe<float>(0, TimeSpan.Zero, Easings.Functions.Linear, opacity);
_second = new LayerPropertyKeyframe<float>(50, TimeSpan.FromSeconds(1), Easings.Functions.Linear, opacity);
_third = new LayerPropertyKeyframe<float>(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<TimeSpan> 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<TimeSpan> 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));
}
}