Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 136 additions & 107 deletions SentryDeck.Tests/MainWindowViewModelTests.Clips.cs

Large diffs are not rendered by default.

30 changes: 15 additions & 15 deletions SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,12 @@ public void StopCommand_ClearsNowPlayingClip()
using var clipFiles = TestClipFiles.Create(chunkCount: 1);
var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip);
var closesAfterOpen = front.Count("close");
vm.SelectedClip = clipFiles.Clip; // sets NowPlayingClip too (see OnSelectedClipChanged)
vm.Library.SelectedClip = clipFiles.Clip; // sets NowPlayingClip too (see OnSelectedClipChanged)

RunPinnedToTestThread(() => vm.StopCommand.ExecuteAsync(null));
RunPinnedToTestThread(() => vm.Playback.StopCommand.ExecuteAsync(null));

// Stop is the only thing that takes the now-playing badge off the clip list; leaving it set would mark a clip as playing with nothing loaded.
vm.NowPlayingClip.ShouldBeNull();
vm.Playback.NowPlayingClip.ShouldBeNull();
front.Count("close").ShouldBeGreaterThan(closesAfterOpen);
}

Expand All @@ -127,12 +127,12 @@ public async Task DeselectingWhileSelectionLoadIsYielding_ClearsTheLoadingOverla
backgroundYield: () => yieldGate.Task);
vm.InitializePlayer();

vm.SelectedClip = ClipWithChunks(1);
vm.Library.SelectedClip = ClipWithChunks(1);
vm.IsLoading.ShouldBeTrue();

// Clear the selection before the yield resumes (Ctrl+click deselect, or a search filter dropping the clip).
// The superseded load must not leave IsLoading stuck true forever.
vm.SelectedClip = null;
vm.Library.SelectedClip = null;
yieldGate.SetResult();

await Wait.UntilAsync(() => !vm.IsLoading);
Expand All @@ -153,17 +153,17 @@ public async Task SupersededSelection_DoesNotClearTheNewerLoadsLoadingState()

var superseded = ClipWithCameras(SixCameras);
var winner = ClipWithCamerasAndEventCamera(eventCamera: 7, SixCameras);
vm.SelectedClip = superseded;
vm.SelectedClip = winner;
vm.Library.SelectedClip = superseded;
vm.Library.SelectedClip = winner;

yieldGate.SetResult();

// The winner's load resumes and auto-focuses the rear camera.
// The superseded load is dropped on its way out, and the loading state it finds is no longer its own to clear -- doing so would strand the newer clip's open with no progress indication at all.
await Wait.UntilAsync(() => vm.SelectedCameraView == CameraNames.Back);
await Wait.UntilAsync(() => vm.Cameras.SelectedCameraView == CameraNames.Back);
vm.IsLoading.ShouldBeTrue();
vm.NowPlayingClip.ShouldBe(winner);
vm.SelectedClip.ShouldBe(winner);
vm.Playback.NowPlayingClip.ShouldBe(winner);
vm.Library.SelectedClip.ShouldBe(winner);
}

[Fact]
Expand All @@ -172,12 +172,12 @@ public void OpenFolderAndRefresh_AreDisabledWhileClipsAreScanning()
// Both commands funnel into LoadClipsAsync, which has no re-entrancy protection: a second load started mid-scan would interleave with the first and merge both roots' clips.
var vm = CreateViewModel();

vm.OpenFolderCommand.CanExecute(null).ShouldBeTrue();
vm.RefreshClipsCommand.CanExecute(null).ShouldBeTrue();
vm.Library.OpenFolderCommand.CanExecute(null).ShouldBeTrue();
vm.Library.RefreshClipsCommand.CanExecute(null).ShouldBeTrue();

vm.IsLoadingClips = true;
vm.Library.IsLoadingClips = true;

vm.OpenFolderCommand.CanExecute(null).ShouldBeFalse();
vm.RefreshClipsCommand.CanExecute(null).ShouldBeFalse();
vm.Library.OpenFolderCommand.CanExecute(null).ShouldBeFalse();
vm.Library.RefreshClipsCommand.CanExecute(null).ShouldBeFalse();
}
}
104 changes: 52 additions & 52 deletions SentryDeck.Tests/MainWindowViewModelTests.Markers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,23 @@ public void EventMarker_NearClipEnd_MapsToHighFraction()
var vm = CreateViewModel();

// 10 one-minute chunks (600s modeled); event at 9m30s in -> 0.95.
vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570));
vm.Library.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570));

vm.HasEventMarker.ShouldBeTrue();
vm.EventMarkerPosition.ShouldBe(0.95, 0.0001);
vm.EventMarkerTooltip.ShouldStartWith("Honk · ");
vm.Playback.HasEventMarker.ShouldBeTrue();
vm.Playback.EventMarkerPosition.ShouldBe(0.95, 0.0001);
vm.Playback.EventMarkerTooltip.ShouldStartWith("Honk · ");
}

[Fact]
public void EventMarker_AbsentWithoutEvent()
{
var vm = CreateViewModel();

vm.SelectedClip = ClipWithChunks(3);
vm.Library.SelectedClip = ClipWithChunks(3);

vm.HasEventMarker.ShouldBeFalse();
vm.EventMarkerPosition.ShouldBe(0);
vm.EventMarkerTooltip.ShouldBeEmpty();
vm.Playback.HasEventMarker.ShouldBeFalse();
vm.Playback.EventMarkerPosition.ShouldBe(0);
vm.Playback.EventMarkerTooltip.ShouldBeEmpty();
}

[Fact]
Expand All @@ -40,9 +40,9 @@ public void EventMarker_AbsentWhenEventTimestampIsDefault()
var chunks = ClipWithChunks(3).Chunks;
var camEvent = new CamEvent { Reason = "user_interaction_honk" }; // Timestamp == default

vm.SelectedClip = new CamClip(System.IO.Path.GetTempPath(), "Default TS", new DateTime(2025, 1, 1, 12, 0, 0), chunks, camEvent);
vm.Library.SelectedClip = new CamClip(System.IO.Path.GetTempPath(), "Default TS", new DateTime(2025, 1, 1, 12, 0, 0), chunks, camEvent);

vm.HasEventMarker.ShouldBeFalse();
vm.Playback.HasEventMarker.ShouldBeFalse();
}

[Fact]
Expand All @@ -51,10 +51,10 @@ public void EventMarker_AtTheVeryStart_ShowsAtFractionZero()
var vm = CreateViewModel();

// Event fired on the first recorded frame (timestamp == first chunk's timestamp): fraction is exactly 0, which is a real position, not clock skew.
vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.Zero);
vm.Library.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.Zero);

vm.HasEventMarker.ShouldBeTrue();
vm.EventMarkerPosition.ShouldBe(0);
vm.Playback.HasEventMarker.ShouldBeTrue();
vm.Playback.EventMarkerPosition.ShouldBe(0);
}

[Fact]
Expand All @@ -63,9 +63,9 @@ public void EventMarker_AbsentWhenEventBeforeClipStart()
var vm = CreateViewModel();

// Clock skew: event five minutes before the first chunk -> fraction <= 0, no marker.
vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromMinutes(-5));
vm.Library.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromMinutes(-5));

vm.HasEventMarker.ShouldBeFalse();
vm.Playback.HasEventMarker.ShouldBeFalse();
}

[Fact]
Expand All @@ -74,9 +74,9 @@ public void EventMarker_AbsentWhenEventBeyondModeledDuration()
var vm = CreateViewModel();

// 3 chunks = 180s modeled; an event at 200s is past the estimated end (fraction > 1).
vm.SelectedClip = ClipWithChunksAndEvent(3, TimeSpan.FromSeconds(200));
vm.Library.SelectedClip = ClipWithChunksAndEvent(3, TimeSpan.FromSeconds(200));

vm.HasEventMarker.ShouldBeFalse();
vm.Playback.HasEventMarker.ShouldBeFalse();
}

[Fact]
Expand All @@ -85,33 +85,33 @@ public void EventMarker_AbsentWhenNoChunks_NoDivideByZero()
var vm = CreateViewModel();
var camEvent = new CamEvent { Reason = "user_interaction_honk", Timestamp = new DateTime(2025, 1, 1, 12, 5, 0) };

vm.SelectedClip = new CamClip(System.IO.Path.GetTempPath(), "No Chunks", new DateTime(2025, 1, 1, 12, 0, 0), [], camEvent);
vm.Library.SelectedClip = new CamClip(System.IO.Path.GetTempPath(), "No Chunks", new DateTime(2025, 1, 1, 12, 0, 0), [], camEvent);

vm.HasEventMarker.ShouldBeFalse();
vm.EventMarkerPosition.ShouldBe(0);
vm.ChunkBoundaries.ShouldBeEmpty();
vm.Playback.HasEventMarker.ShouldBeFalse();
vm.Playback.EventMarkerPosition.ShouldBe(0);
vm.Playback.ChunkBoundaries.ShouldBeEmpty();
}

[Fact]
public void ChunkBoundaries_AreInteriorFractions()
{
var vm = CreateViewModel();

vm.SelectedClip = ClipWithChunks(3);
vm.Library.SelectedClip = ClipWithChunks(3);

vm.ChunkBoundaries.Count.ShouldBe(2);
vm.ChunkBoundaries[0].ShouldBe(1.0 / 3, 0.0001);
vm.ChunkBoundaries[1].ShouldBe(2.0 / 3, 0.0001);
vm.Playback.ChunkBoundaries.Count.ShouldBe(2);
vm.Playback.ChunkBoundaries[0].ShouldBe(1.0 / 3, 0.0001);
vm.Playback.ChunkBoundaries[1].ShouldBe(2.0 / 3, 0.0001);
}

[Fact]
public void ChunkBoundaries_EmptyForSingleChunk()
{
var vm = CreateViewModel();

vm.SelectedClip = ClipWithChunks(1);
vm.Library.SelectedClip = ClipWithChunks(1);

vm.ChunkBoundaries.ShouldBeEmpty();
vm.Playback.ChunkBoundaries.ShouldBeEmpty();
}

// --- Gap-aware markers: once the controller has actually opened the clip's media, event/gap positions come from the real ClipMediaSource (probed durations + wall-clock mapping) rather than the uniform-chunk-length estimate used before the media opens. ---
Expand All @@ -122,9 +122,9 @@ public void GapPositions_EmptyBeforeMediaOpens()
// No controller at all: RecomputeSelectedClipTimeline can only fall back to the estimate, which carries no gap information.
var vm = CreateViewModel();

vm.SelectedClip = ClipWithChunksAndEvent(3, TimeSpan.FromSeconds(90));
vm.Library.SelectedClip = ClipWithChunksAndEvent(3, TimeSpan.FromSeconds(90));

vm.GapPositions.ShouldBeEmpty();
vm.Playback.GapPositions.ShouldBeEmpty();
}

[Fact]
Expand All @@ -137,10 +137,10 @@ public void GapPositions_ReflectAGapOnceMediaSourceIsOpen()
var clip = new CamClip(clipFiles.Clip.FullPath, clipFiles.Clip.Name, clipFiles.Clip.Timestamp, chunks, camEvent: null);

var (vm, _, _) = CreateViewModelWithOpenedClip(clip);
vm.SelectedClip = clip;
vm.Library.SelectedClip = clip;

// Two included chunks of 60s each = 120s total; the single gap sits at media time 60s.
vm.GapPositions.ShouldBe([60.0 / 120], 0.0001);
vm.Playback.GapPositions.ShouldBe([60.0 / 120], 0.0001);
}

[Fact]
Expand All @@ -155,38 +155,38 @@ public void EventMarker_AfterAGap_UsesGapCorrectedFraction_NotLinearTime()
var clip = new CamClip(clipFiles.Clip.FullPath, clipFiles.Clip.Name, clipFiles.Clip.Timestamp, chunks, camEvent);

var (vm, _, _) = CreateViewModelWithOpenedClip(clip);
vm.SelectedClip = clip;
vm.Library.SelectedClip = clip;

vm.HasEventMarker.ShouldBeTrue();
vm.EventMarkerPosition.ShouldBe(70.0 / 120, 0.0001);
vm.Playback.HasEventMarker.ShouldBeTrue();
vm.Playback.EventMarkerPosition.ShouldBe(70.0 / 120, 0.0001);

// Sanity check that this genuinely differs from what the naive linear/estimated model (ignoring the gap) would have produced, so the test would fail if gap-awareness regressed.
Math.Abs(vm.EventMarkerPosition - (130.0 / 180)).ShouldBeGreaterThan(0.01);
Math.Abs(vm.Playback.EventMarkerPosition - (130.0 / 180)).ShouldBeGreaterThan(0.01);
}

[Fact]
public void ClearingSelection_ResetsEventMarkerAndChunks()
{
var vm = CreateViewModel();
vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570));
vm.HasEventMarker.ShouldBeTrue();
vm.Library.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570));
vm.Playback.HasEventMarker.ShouldBeTrue();

vm.SelectedClip = null;
vm.Library.SelectedClip = null;

vm.HasEventMarker.ShouldBeFalse();
vm.EventMarkerPosition.ShouldBe(0);
vm.ChunkBoundaries.ShouldBeEmpty();
vm.Playback.HasEventMarker.ShouldBeFalse();
vm.Playback.EventMarkerPosition.ShouldBe(0);
vm.Playback.ChunkBoundaries.ShouldBeEmpty();
}

[Fact]
public void JumpToEvent_CanExecute_FollowsHasEventMarker()
{
var vm = CreateViewModel();
vm.JumpToEventCommand.CanExecute(null).ShouldBeFalse();
vm.Playback.JumpToEventCommand.CanExecute(null).ShouldBeFalse();

vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570));
vm.Library.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570));

vm.JumpToEventCommand.CanExecute(null).ShouldBeTrue();
vm.Playback.JumpToEventCommand.CanExecute(null).ShouldBeTrue();
}

[Fact]
Expand All @@ -201,22 +201,22 @@ public void EventShortcut_WithAnOpenedEventClip_SeeksThePlayersToTheEventMoment(
clipFiles.Clip.Chunks,
new CamEvent { Timestamp = clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(30) });
var (vm, controller, front) = CreateViewModelWithOpenedClip(clip);
vm.SelectedClip = clip;
vm.Library.SelectedClip = clip;

var handled = vm.HandleKeyDown(Key.E, ModifierKeys.None);
RunPinnedToTestThread(controller.WhenIdleAsync);

handled.ShouldBeTrue();
front.Seeks[^1].Position.ShouldBe(TimeSpan.FromSeconds(90));
controller.Position.ShouldBe(TimeSpan.FromSeconds(90));
vm.SeekPosition.ShouldBe(0.5, 0.0001);
vm.Playback.SeekPosition.ShouldBe(0.5, 0.0001);
}

[Fact]
public async Task EventShortcut_Ignored_WhenNoMarker()
{
var vm = CreateViewModel();
vm.SelectedClip = ClipWithChunks(3); // no event
vm.Library.SelectedClip = ClipWithChunks(3); // no event

var handled = await vm.HandleKeyDownAsync(Key.E, ModifierKeys.None);

Expand All @@ -228,12 +228,12 @@ public void SelectingClip_RaisesEventMarkerNotifications()
{
var vm = CreateViewModel();
var changed = new List<string>();
vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
vm.Playback.PropertyChanged += (_, e) => changed.Add(e.PropertyName);

vm.SelectedClip = ClipWithChunksAndEvent(5, TimeSpan.FromSeconds(250));
vm.Library.SelectedClip = ClipWithChunksAndEvent(5, TimeSpan.FromSeconds(250));

changed.ShouldContain(nameof(MainWindowViewModel.EventMarkerPosition));
changed.ShouldContain(nameof(MainWindowViewModel.HasEventMarker));
changed.ShouldContain(nameof(MainWindowViewModel.ChunkBoundaries));
changed.ShouldContain(nameof(PlaybackViewModel.EventMarkerPosition));
changed.ShouldContain(nameof(PlaybackViewModel.HasEventMarker));
changed.ShouldContain(nameof(PlaybackViewModel.ChunkBoundaries));
}
}
Loading