mirror of
https://github.com/Artemis-RGB/Artemis
synced 2026-11-11 11:26:27 +00:00
Tests - Add Artemis.Core and profile editor test suites (#907)
This commit is contained in:
1 parent
6e7fae16d2
commit
504845c7d2
168 files changed
+22914
-7
No files matched your search
@@ -0,0 +1,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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user