Profile editor - Fix auto-saving, failing commands, suspended editing and smaller editor issues

- A failed auto-save no longer stops auto-saving for the rest of the session
- A failing command is no longer also reported to ReactiveUI as unhandled, on top of the error dialog
- Resuming editing applies the editor's time again right away
- Nothing is focused while editing is suspended
- A layer created on another layer gets a name that isn't in use next to it
- CanUndo and CanRedo start out false, so new subscribers know the state right away
- An empty command scope doesn't add an undo step
- New subscribers to History get the current history once
- Expanding the keyframe selection with an already selected keyframe doesn't add it twice
- Snapping can snap to a snap time of zero
This commit is contained in:
RobertBeekman committed 2026-10-20 09:47:43 +02:00
1 parent 0920cf8917
commit 55dc04163c
8 files changed
+60 -62

No files matched your search

@@ -98,17 +98,16 @@ public class ProfileEditorCreateElementTests : ProfileEditorTestBase
}
[Fact]
public async Task CreateAndAddLayer_OnALayer_CanRepeatANameAlreadyInUse()
public async Task CreateAndAddLayer_OnALayer_PicksANameNotInUseNextToIt()
{
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);
layer.Parent.ShouldBe(Root);
layer.Name.ShouldNotBe("New layer");
Root.Children.Count(c => c.Name == "New layer").ShouldBe(1);
}
[Fact]
@@ -89,7 +89,7 @@ public class ProfileEditorFocusModeTests : ProfileEditorTestBase
}
[Fact]
public async Task SuspendedEditing_StillFocusesTheSelection()
public async Task SuspendedEditing_DoesNotFocusAnything()
{
await StartEditing();
Service.ChangeCurrentProfileElement(_layer);
@@ -98,10 +98,8 @@ public class ProfileEditorFocusModeTests : ProfileEditorTestBase
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);
// The profile plays normally while editing is suspended
ProfileService.FocusProfileElementChanges.ShouldBe([null]);
ProfileService.FocusProfileElement.ShouldBeNull();
}
}
@@ -34,11 +34,11 @@ public class ProfileEditorHistoryTests : ProfileEditorTestBase
Service.ExecuteCommand(new RecordingCommand("A", _log));
_log.ShouldBe(["execute A"]);
canUndo.ShouldBe([true]);
canUndo.ShouldBe([false, true]);
}
[Fact]
public async Task CanUndoAndCanRedo_SayNothingUntilTheHistoryChanges()
public async Task CanUndoAndCanRedo_StartOutFalse()
{
await StartEditing();
List<bool> canUndo = [];
@@ -47,10 +47,8 @@ public class ProfileEditorHistoryTests : ProfileEditorTestBase
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();
canUndo.ShouldBe([false]);
canRedo.ShouldBe([false]);
}
[Fact]
@@ -92,7 +90,7 @@ public class ProfileEditorHistoryTests : ProfileEditorTestBase
Undo();
Redo();
canUndo.ShouldBe([true, false, true]);
canUndo.ShouldBe([false, true, false, true]);
canRedo.ShouldBe([false, true, false]);
}
@@ -152,10 +150,8 @@ public class ProfileEditorHistoryTests : ProfileEditorTestBase
}
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>();
// The dialog is enough, ReactiveUI doesn't also treat the failure as unhandled (a crash in the app)
unhandled.ShouldBeEmpty();
Undo().ShouldBeNull();
}
@@ -217,14 +213,13 @@ public class ProfileEditorHistoryTests : ProfileEditorTestBase
}
[Fact]
public async Task CommandScope_WithoutCommands_StillAddsAnEntryToTheHistory()
public async Task CommandScope_WithoutCommands_DoesNotAddAnEntryToTheHistory()
{
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");
Undo().ShouldBeNull();
}
[Fact]
@@ -155,18 +155,14 @@ public class ProfileEditorServiceTests : ProfileEditorTestBase
}
[Fact]
public async Task History_GivesNewSubscribersTheCurrentHistoryTwice()
public async Task History_GivesNewSubscribersTheCurrentHistoryOnce()
{
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);
received.ShouldHaveSingleItem()!.ProfileConfiguration.ShouldBe(Configuration);
}
[Fact]
@@ -259,7 +255,7 @@ public class ProfileEditorServiceTests : ProfileEditorTestBase
}
[Fact]
public async Task AutoSave_AfterAFailedSave_StopsSaving()
public async Task AutoSave_AfterAFailedSave_KeepsSaving()
{
Layer layer = AddLayer("Layer", null, LedId.Keyboard_A);
await StartEditing();
@@ -271,12 +267,13 @@ public class ProfileEditorServiceTests : ProfileEditorTestBase
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);
await WaitUntil(() =>
{
lock (ProfileService.SavedProfiles)
return ProfileService.SavedProfiles.Count > 1;
});
layer.Name.ShouldBe("Second");
}
[Fact]
@@ -131,7 +131,7 @@ public class ProfileEditorTimeTests : ProfileEditorTestBase
}
[Fact]
public async Task ResumingEditing_TakesBackControlButLeavesTheTimelinesAtTheStart()
public async Task ResumingEditing_TakesBackControlAndAppliesTheTime()
{
Layer layer = AddLayer("Layer", null, LedId.Keyboard_A);
await StartEditing();
@@ -142,13 +142,7 @@ public class ProfileEditorTimeTests : ProfileEditorTestBase
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));
}
@@ -45,14 +45,12 @@ public class ProfileEditorTimelineHelperTests : ProfileEditorTestBase
}
[Fact]
public void SelectKeyframe_ExpandingWithAKeyframeAlreadySelected_SelectsItTwice()
public void SelectKeyframe_ExpandingWithAKeyframeAlreadySelected_SelectsItOnce()
{
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]);
Service.SelectedKeyframes.ShouldBe([_first]);
}
[Fact]
@@ -153,14 +151,13 @@ public class ProfileEditorTimelineHelperTests : ProfileEditorTestBase
}
[Fact]
public void SnapToTimeline_NeverSnapsToASnapTimeOfZero()
public void SnapToTimeline_SnapsToASnapTimeOfZero()
{
// A keyframe at the very start of the timeline
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));
.ShouldBe(TimeSpan.Zero);
}
[Theory]