@@ -649,6 +649,123 @@ public async Task WhileScrubbing_ControllerPositionDoesNotMoveTheSlider()
649649 vm . SeekPosition . ShouldBe ( 0.25 , 0.0001 ) ;
650650 }
651651
652+ // Synchronous (no async/await in the test body itself -- see CreateViewModelWithOpenedClip's
653+ // comment on why thread affinity matters here). FakeCameraPlayer's SeekAsync/OpenAsync and an
654+ // uncontended SemaphoreSlim all complete synchronously, so blocking on the resulting tasks
655+ // never suspends the thread and keeps every controller property change on it.
656+ [ Fact ]
657+ public void DragSequence_IssuesFastSeeks_ReleaseIssuesAccurateSeekAtReleasePosition ( )
658+ {
659+ using var clipFiles = TestClipFiles . Create ( chunkCount : 1 ) ; // 60s clip (see TestClipFiles)
660+ var ( vm , _, front ) = CreateViewModelWithOpenedClip ( UniquelyNamed ( clipFiles ) ) ;
661+
662+ vm . BeginSeek ( ) ;
663+
664+ // Simulate a drag: each slider value change while dragging should scrub-seek (fast/keyframe).
665+ vm . SeekPosition = 0.2 ; // 12s of 60s
666+ vm . OnSeekSliderValueChanged ( ) ;
667+
668+ vm . SeekPosition = 0.5 ; // 30s
669+ vm . OnSeekSliderValueChanged ( ) ;
670+
671+ front . SeekPositions . ShouldContain ( TimeSpan . FromSeconds ( 12 ) ) ;
672+ front . SeekPositions . ShouldContain ( TimeSpan . FromSeconds ( 30 ) ) ;
673+
674+ // Every seek issued so far while dragging must have been fast (non-accurate).
675+ front . SeekAccurateFlags . ShouldAllBe ( accurate => accurate == false ) ;
676+
677+ // Release at 0.75 (45s): EndSeekAsync must issue exactly one ACCURATE seek at the release position.
678+ vm . SeekPosition = 0.75 ;
679+
680+ // Blocking rather than awaiting is deliberate here, not an oversight: this whole test must stay
681+ // pinned to one thread (see CreateViewModelWithOpenedClip's comment), and EndSeekAsync only ever
682+ // awaits FakeCameraPlayer calls and an uncontended SemaphoreSlim, both of which complete
683+ // synchronously -- so this never actually blocks.
684+ #pragma warning disable xUnit1031
685+ vm . EndSeekAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
686+ #pragma warning restore xUnit1031
687+
688+ front . SeekPositions [ ^ 1 ] . ShouldBe ( TimeSpan . FromSeconds ( 45 ) ) ;
689+ front . SeekAccurateFlags [ ^ 1 ] . ShouldBeTrue ( ) ;
690+ }
691+
692+ [ Fact ]
693+ public void PositionSync_WhenNotDragging_DoesNotTriggerScrubSeeks ( )
694+ {
695+ using var clipFiles = TestClipFiles . Create ( chunkCount : 1 ) ;
696+ var ( vm , controller , front ) = CreateViewModelWithOpenedClip ( UniquelyNamed ( clipFiles ) ) ;
697+
698+ front . SeekPositions . Clear ( ) ;
699+
700+ // Playback position advances on its own (not a drag): SeekPosition updates via the
701+ // controller -> UpdateSeekPositionFromController path, which does not go through
702+ // OnSeekSliderValueChanged, so no scrub seek should ever be issued.
703+ controller . Position = TimeSpan . FromSeconds ( 10 ) ;
704+ vm . OnSeekSliderValueChanged ( ) ; // the view raises ValueChanged for programmatic changes too
705+
706+ front . SeekPositions . ShouldBeEmpty ( ) ;
707+ }
708+
709+ // A generous 20s deadline (vs. the 5s used elsewhere in these test files): this helper waits on
710+ // a real clip-open flowing through Task.Run/the media source builder, which can slow down a lot
711+ // under the CPU/disk contention of the full suite's many parallel test classes; 20s comfortably
712+ // absorbs that while still catching a genuine hang.
713+ private static async Task WaitUntilAsync ( Func < bool > condition )
714+ {
715+ var deadline = DateTime . UtcNow . AddSeconds ( 20 ) ;
716+ while ( ! condition ( ) )
717+ {
718+ if ( DateTime . UtcNow > deadline )
719+ {
720+ throw new TimeoutException ( "Condition was not met within the timeout." ) ;
721+ }
722+
723+ await Task . Delay ( 10 ) ;
724+ }
725+ }
726+
727+ // FfconcatMediaSourceBuilder writes each camera's playlist to a shared temp path keyed only by
728+ // the clip's Name (not its folder), and every TestClipFiles.Create() call across the whole
729+ // suite uses the same literal name ("Test Clip") -- so under full-suite parallel execution,
730+ // concurrently-running tests can race on the very same playlist file. Giving this test's clip
731+ // its own unique name sidesteps that shared-fixture collision without touching the fixture (or
732+ // the production path-naming) itself, which is shared by dozens of other tests.
733+ private static CamClip UniquelyNamed ( TestClipFiles clipFiles )
734+ {
735+ var clip = clipFiles . Clip ;
736+ return new CamClip ( clip . FullPath , $ "{ clip . Name } { Guid . NewGuid ( ) : N} ", clip . Timestamp , clip . Chunks , clip . Event ) ;
737+ }
738+
739+ // Opens the clip on the controller to completion BEFORE the view-model subscribes to it, then
740+ // attaches the view-model. Doing it in this order (rather than via CreateViewModelWithController,
741+ // which subscribes up front) avoids the deadlock described on that helper: the real open flow's
742+ // ObservableProperty writes happen on background-thread continuations, and if the view-model were
743+ // already subscribed, its PropertyChanged handler would call Dispatcher.Invoke from that background
744+ // thread with no pumped message loop to service it, hanging the open forever. Once the clip is
745+ // fully open and idle, driving the view-model's own seek APIs from the test thread afterward is safe.
746+ // Synchronous by design (no async/await): the view-model's constructor captures
747+ // Dispatcher.CurrentDispatcher for whatever thread calls it, and RunOnUiThread only stays
748+ // deadlock-free while every later controller property change happens on that exact same
749+ // thread (see the comment on CreateViewModelWithController above). An async method resumes
750+ // its continuation after an await on a thread-pool thread with no guarantee it matches the
751+ // thread that ran the code before the await -- which silently breaks that invariant. Blocking
752+ // on the wait here (instead of awaiting it) keeps everything, including the VM construction
753+ // and every later test action, pinned to the single calling thread.
754+ private static ( MainWindowViewModel Vm , VideoPlayerController Controller , FakeCameraPlayer Front ) CreateViewModelWithOpenedClip (
755+ CamClip clip )
756+ {
757+ var front = new FakeCameraPlayer ( ) ;
758+ var built = new VideoPlayerController ( front , new FakeCameraPlayer ( ) , new FakeCameraPlayer ( ) , new FakeCameraPlayer ( ) ) ;
759+
760+ built . LoadClips ( [ clip ] ) ;
761+ built . Playlist . MoveTo ( 0 ) ;
762+ WaitUntilAsync ( ( ) => front . PlayCount > 0 && built . IsMediaOpen && ! built . IsLoading ) . GetAwaiter ( ) . GetResult ( ) ;
763+
764+ var vm = new MainWindowViewModel ( ( ) => built , backgroundYield : ( ) => Task . CompletedTask ) ;
765+ vm . InitializePlayer ( ) ;
766+ return ( vm , built , front ) ;
767+ }
768+
652769 [ Fact ]
653770 public void ControllerLoadingAndPlaying_MirrorToViewModel ( )
654771 {
0 commit comments