From d962744b676d4ee60eeefc347afa1435ca7caf10 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 13:10:35 -0500 Subject: [PATCH 1/3] Add test seams to MainWindowViewModel --- SentryReplay/MainWindowViewModel.cs | 24 +++++++++++++++++------- SentryReplay/SentryReplay.csproj | 4 ++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/SentryReplay/MainWindowViewModel.cs b/SentryReplay/MainWindowViewModel.cs index 2c22502..f03c03b 100644 --- a/SentryReplay/MainWindowViewModel.cs +++ b/SentryReplay/MainWindowViewModel.cs @@ -31,14 +31,24 @@ public partial class MainWindowViewModel : ObservableObject private readonly FlyleafRuntime _flyleafRuntime = new(); private readonly UpdateService _updateService = new(); private readonly Func _playerControllerFactory; + private readonly Func> _clipLoader; + private readonly Func _backgroundYield; private readonly Dispatcher _dispatcher; private VideoPlayerController _playerController; private bool _isSeeking; private bool _isInitialized; - public MainWindowViewModel(Func playerControllerFactory) + /// Creates the playback controller (the view supplies one bound to its Flyleaf hosts). + /// Maps a dashcam root to its clips. Defaults to scanning the filesystem; overridable for tests. + /// Yields to the UI before a clip loads so the window stays responsive. Overridable for tests. + public MainWindowViewModel( + Func playerControllerFactory, + Func> clipLoader = null, + Func backgroundYield = null) { _playerControllerFactory = playerControllerFactory; + _clipLoader = clipLoader ?? (root => CamStorage.Map(root).Clips); + _backgroundYield = backgroundYield ?? (async () => await Dispatcher.Yield(DispatcherPriority.Background)); _dispatcher = Dispatcher.CurrentDispatcher; } @@ -284,7 +294,7 @@ public void Shutdown() controller.Dispose(); } - private void InitializePlayer() + internal void InitializePlayer() { if (_playerController is not null) return; @@ -294,7 +304,7 @@ private void InitializePlayer() _playerController.PlaybackSpeed = SelectedPlaybackSpeed; } - private void LoadClips(IEnumerable roots) + internal void LoadClips(IEnumerable roots) { ClearError(); _allClips.Clear(); @@ -323,12 +333,12 @@ private void LoadClips(IEnumerable roots) try { - var storage = CamStorage.Map(root); - _allClips.AddRange(storage.Clips); + var clips = _clipLoader(root); + _allClips.AddRange(clips); Log.Information( "Scanned dashcam root. Root={Root}; ClipCount={ClipCount}; ElapsedMs={ElapsedMs}", root, - storage.Clips.Count, + clips.Count, rootStopwatch.ElapsedMilliseconds); } catch (UnauthorizedAccessException ex) @@ -475,7 +485,7 @@ private async Task PlaySelectedClipAsync() ClearError(); IsLoading = true; - await Dispatcher.Yield(DispatcherPriority.Background); + await _backgroundYield(); try { diff --git a/SentryReplay/SentryReplay.csproj b/SentryReplay/SentryReplay.csproj index 2bf425b..95c45ce 100644 --- a/SentryReplay/SentryReplay.csproj +++ b/SentryReplay/SentryReplay.csproj @@ -20,4 +20,8 @@ + + + + From da0faf04cea1b36c37e1b52649df21f102507c27 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 13:10:35 -0500 Subject: [PATCH 2/3] Add MainWindowViewModel clip-browsing and playback tests --- .../Fixtures/FakeCameraPlayer.cs | 98 ++++++++++ .../MainWindowViewModelTests.cs | 179 ++++++++++++++++++ .../VideoPlayerControllerTests.cs | 93 --------- 3 files changed, 277 insertions(+), 93 deletions(-) create mode 100644 SentryReplay.Tests/Fixtures/FakeCameraPlayer.cs diff --git a/SentryReplay.Tests/Fixtures/FakeCameraPlayer.cs b/SentryReplay.Tests/Fixtures/FakeCameraPlayer.cs new file mode 100644 index 0000000..aa073b2 --- /dev/null +++ b/SentryReplay.Tests/Fixtures/FakeCameraPlayer.cs @@ -0,0 +1,98 @@ +namespace SentryReplay.Tests; + +/// +/// In-memory used to drive a real +/// in tests without Flyleaf/FFmpeg. +/// +internal sealed class FakeCameraPlayer : ICameraPlayer +{ + public event EventHandler Opened; + public event EventHandler Ended; + public event EventHandler Failed; + public event EventHandler PositionChanged; + + public List OpenedPaths { get; } = []; + public List SeekPositions { get; } = []; + public bool OpenResult { get; init; } = true; + public bool ThrowOnStop { get; init; } + public TaskCompletionSource StopGate { get; set; } + public bool IsOpen { get; private set; } + public double Speed { get; set; } = 1.0; + public int PlayCount { get; private set; } + public int PauseCount { get; private set; } + public int StopCount { get; private set; } + public int CloseCount { get; private set; } + public int DisposeCount { get; private set; } + + public Task OpenAsync(string path) + { + OpenedPaths.Add(path); + IsOpen = OpenResult; + + if (OpenResult) + { + Opened?.Invoke(this, EventArgs.Empty); + } + + return Task.FromResult(OpenResult); + } + + public Task PlayAsync() + { + PlayCount++; + return Task.CompletedTask; + } + + public Task PauseAsync() + { + PauseCount++; + return Task.CompletedTask; + } + + public Task StopAsync() + { + StopCount++; + if (StopGate is not null) + { + return StopGate.Task; + } + + return ThrowOnStop + ? Task.FromException(new InvalidOperationException("stop failed")) + : Task.CompletedTask; + } + + public Task CloseAsync() + { + CloseCount++; + IsOpen = false; + return Task.CompletedTask; + } + + public Task SeekAsync(TimeSpan position) + { + SeekPositions.Add(position); + PositionChanged?.Invoke(this, new CameraPositionChangedEventArgs(position)); + return Task.CompletedTask; + } + + public void RaiseEnded() + { + Ended?.Invoke(this, EventArgs.Empty); + } + + public void RaiseFailed(Exception exception) + { + Failed?.Invoke(this, new CameraPlaybackFailedEventArgs(exception)); + } + + public void RaisePositionChanged(TimeSpan position) + { + PositionChanged?.Invoke(this, new CameraPositionChangedEventArgs(position)); + } + + public void Dispose() + { + DisposeCount++; + } +} diff --git a/SentryReplay.Tests/MainWindowViewModelTests.cs b/SentryReplay.Tests/MainWindowViewModelTests.cs index 7e4e8d3..c6ae510 100644 --- a/SentryReplay.Tests/MainWindowViewModelTests.cs +++ b/SentryReplay.Tests/MainWindowViewModelTests.cs @@ -275,4 +275,183 @@ public void DismissError_ClearsErrorState() vm.ErrorTitle.ShouldBeNull(); vm.ErrorDetails.ShouldBeNull(); } + + // --- Clip browsing: the injectable clip loader lets us populate clips without disk I/O --- + + [Fact] + public void FilteredClips_OrderNewestFirst() + { + var clips = TestClips.Create(3); // timestamps increase with index + var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); + + vm.LoadClips(new[] { "root" }); + + vm.FilteredClips.Select(c => c.Name).ShouldBe(new[] { "Clip 2", "Clip 1", "Clip 0" }); + } + + [Fact] + public void FilteredClips_FiltersByNameCaseInsensitively() + { + var clips = TestClips.Create(3); + var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); + vm.LoadClips(new[] { "root" }); + + vm.FilterText = "clip 1"; + + vm.FilteredClips.Single().Name.ShouldBe("Clip 1"); + } + + [Fact] + public void FilteredClips_FiltersByPath() + { + // TestClips share a folder path but have distinct names, so a path-only match keeps them all. + var clips = TestClips.Create(2); + var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); + vm.LoadClips(new[] { "root" }); + + vm.FilterText = clips[0].FullPath; + + vm.FilteredClips.Count.ShouldBe(2); + } + + [Fact] + public void FilteredClips_NoMatch_IsEmpty() + { + var clips = TestClips.Create(3); + var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); + vm.LoadClips(new[] { "root" }); + + vm.FilterText = "no-such-clip"; + + vm.FilteredClips.ShouldBeEmpty(); + } + + // --- Playback: drive a real VideoPlayerController through FakeCameraPlayer (no Flyleaf/FFmpeg) --- + + [Fact] + public void SeekMath_PositionTextScalesByDuration() + { + var vm = CreateViewModelWithController(out var controller, out _); + controller.Duration = TimeSpan.FromMinutes(2); + + vm.SeekPosition = 0.5; + + vm.PositionText.ShouldBe("1:00"); + vm.DurationText.ShouldBe("2:00"); + } + + [Fact] + public void CanSeek_RequiresOpenMediaDurationAndNotLoading() + { + var vm = CreateViewModelWithController(out var controller, out _); + vm.CanSeek.ShouldBeFalse(); // no media open yet + + controller.Duration = TimeSpan.FromMinutes(1); + controller.IsMediaOpen = true; + vm.CanSeek.ShouldBeTrue(); + + vm.IsLoading = true; + vm.CanSeek.ShouldBeFalse(); + } + + [Fact] + public void ControllerPositionChange_UpdatesSeekPosition() + { + var vm = CreateViewModelWithController(out var controller, out _); + controller.Duration = TimeSpan.FromMinutes(2); + + controller.Position = TimeSpan.FromSeconds(30); + + vm.SeekPosition.ShouldBe(0.25, 0.0001); + } + + [Fact] + public async Task WhileScrubbing_ControllerPositionDoesNotMoveTheSlider() + { + var vm = CreateViewModelWithController(out var controller, out _); + controller.Duration = TimeSpan.FromMinutes(2); + controller.IsMediaOpen = true; + + vm.BeginSeek(); + controller.Position = TimeSpan.FromSeconds(60); // user is dragging: ignore controller updates + vm.SeekPosition.ShouldBe(0.0); + + await vm.EndSeekAsync(); + controller.Position = TimeSpan.FromSeconds(30); // updates resume after the drag + vm.SeekPosition.ShouldBe(0.25, 0.0001); + } + + [Fact] + public void ControllerLoadingAndPlaying_MirrorToViewModel() + { + var vm = CreateViewModelWithController(out var controller, out _); + + controller.IsLoading = true; + vm.IsLoading.ShouldBeTrue(); + + controller.IsLoading = false; + controller.IsPlaying = true; + vm.IsPlaying.ShouldBeTrue(); + vm.PlayPauseIcon.ShouldBe("⏸"); + } + + [Fact] + public void ControllerError_ShowsErrorOverlay() + { + var vm = CreateViewModelWithController(out var controller, out _); + + controller.ErrorMessage = "decode failed"; + + vm.ShowErrorOverlay.ShouldBeTrue(); + vm.ErrorTitle.ShouldBe("Playback Error"); + vm.ErrorDetails.ShouldBe("decode failed"); + } + + [Fact] + public void CanGoNextPrevious_ReflectControllerPlaylist() + { + var vm = CreateViewModelWithController(out _, out _, clipLoader: _ => TestClips.Create(3)); + vm.LoadClips(new[] { "root" }); + + // Playlist loaded, nothing playing yet: can advance, can't go back. + vm.CanGoNext.ShouldBeTrue(); + vm.CanGoPrevious.ShouldBeFalse(); + } + + [Fact] + public void SelectingClip_TriggersPlaybackLoading() + { + var clip = TestClips.Create(1)[0]; + var vm = CreateViewModelWithController(out _, out _); + + vm.SelectedClip = clip; + + // Selecting a clip runs OnSelectedClipChanged -> PlaySelectedClipAsync, which sets IsLoading=true + // (synchronously, before the awaited yield) and calls the controller. The clip is intentionally NOT + // in the controller's playlist, so GoToClipAsync is a deterministic no-op; this verifies only that + // selection triggers the auto-play loading state. Opening media is VideoPlayerController's own job. + vm.IsLoading.ShouldBeTrue(); + vm.ShowErrorOverlay.ShouldBeFalse(); + } + + // Controller-backed tests deliberately keep every controller property change on the test thread. + // The VM captures Dispatcher.CurrentDispatcher in its constructor and there is no pumped dispatcher + // here, so RunOnUiThread stays deadlock-free only while CheckAccess() is true (same thread). Don't add + // awaits that suspend onto the thread pool (e.g. driving GoToClipAsync to completion) — they'd hang. + private static MainWindowViewModel CreateViewModelWithController( + out VideoPlayerController controller, + out FakeCameraPlayer front, + Func> clipLoader = null) + { + front = new FakeCameraPlayer(); + var built = new VideoPlayerController(front, new FakeCameraPlayer(), new FakeCameraPlayer(), new FakeCameraPlayer()); + controller = built; + + var vm = new MainWindowViewModel( + () => built, + clipLoader: clipLoader, + backgroundYield: () => Task.CompletedTask); + vm.InitializePlayer(); + return vm; + } } diff --git a/SentryReplay.Tests/VideoPlayerControllerTests.cs b/SentryReplay.Tests/VideoPlayerControllerTests.cs index ecabc03..6fd4c63 100644 --- a/SentryReplay.Tests/VideoPlayerControllerTests.cs +++ b/SentryReplay.Tests/VideoPlayerControllerTests.cs @@ -305,97 +305,4 @@ private static async Task WaitUntilAsync(Func condition) await Task.Delay(10, cts.Token); } } - - private sealed class FakeCameraPlayer : ICameraPlayer - { - public event EventHandler Opened; - public event EventHandler Ended; - public event EventHandler Failed; - public event EventHandler PositionChanged; - - public List OpenedPaths { get; } = []; - public List SeekPositions { get; } = []; - public bool OpenResult { get; init; } = true; - public bool ThrowOnStop { get; init; } - public TaskCompletionSource StopGate { get; set; } - public bool IsOpen { get; private set; } - public double Speed { get; set; } = 1.0; - public int PlayCount { get; private set; } - public int PauseCount { get; private set; } - public int StopCount { get; private set; } - public int CloseCount { get; private set; } - public int DisposeCount { get; private set; } - - public Task OpenAsync(string path) - { - OpenedPaths.Add(path); - IsOpen = OpenResult; - - if (OpenResult) - { - Opened?.Invoke(this, EventArgs.Empty); - } - - return Task.FromResult(OpenResult); - } - - public Task PlayAsync() - { - PlayCount++; - return Task.CompletedTask; - } - - public Task PauseAsync() - { - PauseCount++; - return Task.CompletedTask; - } - - public Task StopAsync() - { - StopCount++; - if (StopGate is not null) - { - return StopGate.Task; - } - - return ThrowOnStop - ? Task.FromException(new InvalidOperationException("stop failed")) - : Task.CompletedTask; - } - - public Task CloseAsync() - { - CloseCount++; - IsOpen = false; - return Task.CompletedTask; - } - - public Task SeekAsync(TimeSpan position) - { - SeekPositions.Add(position); - PositionChanged?.Invoke(this, new CameraPositionChangedEventArgs(position)); - return Task.CompletedTask; - } - - public void RaiseEnded() - { - Ended?.Invoke(this, EventArgs.Empty); - } - - public void RaiseFailed(Exception exception) - { - Failed?.Invoke(this, new CameraPlaybackFailedEventArgs(exception)); - } - - public void RaisePositionChanged(TimeSpan position) - { - PositionChanged?.Invoke(this, new CameraPositionChangedEventArgs(position)); - } - - public void Dispose() - { - DisposeCount++; - } - } } From 83b1240de688cb50c84f45e43141e44d1c661405 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 13:13:33 -0500 Subject: [PATCH 3/3] Make InitializePlayer/LoadClips public instead of internal --- SentryReplay/MainWindowViewModel.cs | 4 ++-- SentryReplay/SentryReplay.csproj | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/SentryReplay/MainWindowViewModel.cs b/SentryReplay/MainWindowViewModel.cs index f03c03b..8315842 100644 --- a/SentryReplay/MainWindowViewModel.cs +++ b/SentryReplay/MainWindowViewModel.cs @@ -294,7 +294,7 @@ public void Shutdown() controller.Dispose(); } - internal void InitializePlayer() + public void InitializePlayer() { if (_playerController is not null) return; @@ -304,7 +304,7 @@ internal void InitializePlayer() _playerController.PlaybackSpeed = SelectedPlaybackSpeed; } - internal void LoadClips(IEnumerable roots) + public void LoadClips(IEnumerable roots) { ClearError(); _allClips.Clear(); diff --git a/SentryReplay/SentryReplay.csproj b/SentryReplay/SentryReplay.csproj index 95c45ce..2bf425b 100644 --- a/SentryReplay/SentryReplay.csproj +++ b/SentryReplay/SentryReplay.csproj @@ -20,8 +20,4 @@ - - - -