diff --git a/SentryDeck.Data/Playback/ClipExporter.cs b/SentryDeck.Data/Playback/ClipExporter.cs index f0de906..e252b51 100644 --- a/SentryDeck.Data/Playback/ClipExporter.cs +++ b/SentryDeck.Data/Playback/ClipExporter.cs @@ -32,7 +32,13 @@ public interface IClipExporter /// are fast and lossless. Stream copy cuts at keyframes, so the actual bounds can land up to a /// GOP (~1s in Tesla footage) before the requested ones. /// -public sealed class ClipExporter(Func ffmpegDirectoryResolver) : IClipExporter +/// +/// Runs FFmpeg with an executable path and an argument string. Defaults to launching the real +/// process; overridable for tests, which must not spawn ffmpeg. +/// +public sealed class ClipExporter( + Func ffmpegDirectoryResolver, + Func runFfmpeg = null) : IClipExporter { private static readonly string ExportScriptDirectory = Path.Combine(Path.GetTempPath(), "SentryDeck", "exports"); @@ -41,6 +47,8 @@ public async Task ExportAsync(ClipExportRequest request, CancellationToken cance { ArgumentNullException.ThrowIfNull(request); + runFfmpeg ??= RunFfmpegAsync; + var ffmpegDirectory = ffmpegDirectoryResolver() ?? throw new InvalidOperationException("FFmpeg is not installed. Restart the app to download it."); var ffmpegPath = Path.Combine(ffmpegDirectory, "ffmpeg.exe"); @@ -58,7 +66,7 @@ public async Task ExportAsync(ClipExportRequest request, CancellationToken cance try { - await RunFfmpegAsync(ffmpegPath, BuildArguments(scriptPath, request.OutputPath), cancellationToken); + await runFfmpeg(ffmpegPath, BuildArguments(scriptPath, request.OutputPath), cancellationToken); Log.Information( "Exported clip range. Clip={ClipName}; Camera={Camera}; Start={Start}; End={End}; Output={Output}; ElapsedMs={ElapsedMs}", request.Clip.Name, diff --git a/SentryDeck.Tests/CamDiscoveryResilienceTests.cs b/SentryDeck.Tests/CamDiscoveryResilienceTests.cs index bfbe3ff..277bd0c 100644 --- a/SentryDeck.Tests/CamDiscoveryResilienceTests.cs +++ b/SentryDeck.Tests/CamDiscoveryResilienceTests.cs @@ -6,7 +6,7 @@ namespace SentryDeck.Tests; /// Discovery must tolerate a single malformed/unreadable entry without discarding the whole /// library (regression guard for the "one bad filename empties the timeline" bug). /// -public static class CamDiscoveryResilienceTests +public sealed class CamDiscoveryResilienceTests { private static string CreateTempDir() { @@ -22,7 +22,7 @@ private static void Touch(string dir, string name) => File.WriteAllBytes(Path.Combine(dir, name), []); [Fact] - public static void FindFiles_SkipsCalendarInvalidFileName() + public void FindFiles_SkipsCalendarInvalidFileName() { var dir = CreateTempDir(); try @@ -42,7 +42,7 @@ public static void FindFiles_SkipsCalendarInvalidFileName() } [Fact] - public static void FindFiles_CanonicalizesLegacyRearViewSuffixToBack() + public void FindFiles_CanonicalizesLegacyRearViewSuffixToBack() { var dir = CreateTempDir(); try @@ -61,7 +61,7 @@ public static void FindFiles_CanonicalizesLegacyRearViewSuffixToBack() } [Fact] - public static void FindClips_OneCalendarInvalidFileDoesNotDiscardOtherClips() + public void FindClips_OneCalendarInvalidFileDoesNotDiscardOtherClips() { var root = CreateTempDir(); try @@ -87,7 +87,7 @@ public static void FindClips_OneCalendarInvalidFileDoesNotDiscardOtherClips() } [Fact] - public static void Map_DateLessFolderWithoutEvent_FallsBackToFirstChunkTimestamp() + public void Map_DateLessFolderWithoutEvent_FallsBackToFirstChunkTimestamp() { // A folder like Tesla's RecentClips: loose files directly inside, no date-named subfolder and // no event.json. The clip timestamp must come from the file names, not DateTime.MinValue. @@ -110,7 +110,7 @@ public static void Map_DateLessFolderWithoutEvent_FallsBackToFirstChunkTimestamp } [Fact] - public static void Map_CalendarInvalidFolderName_DoesNotThrowAndKeepsChunks() + public void Map_CalendarInvalidFolderName_DoesNotThrowAndKeepsChunks() { var root = CreateTempDir(); try @@ -128,4 +128,31 @@ public static void Map_CalendarInvalidFolderName_DoesNotThrowAndKeepsChunks() Directory.Delete(root, true); } } + + [Fact] + public void Map_BackAndRearViewAtOneTimestamp_KeepsOneChunkAndDoesNotDropTheClip() + { + // What a drive spanning a firmware transition (or two drives merged by hand) actually holds: both rear-camera suffixes at the same timestamp. + // CamFile canonicalizes rear_view to back, so the two files collide on one camera key -- and an unguarded ToDictionary would throw there, with CamClip.TryMap swallowing it and the whole clip folder vanishing from the library. + var dir = CreateTempDir(); + try + { + Touch(dir, "2023-02-23_14-14-48-front.mp4"); + Touch(dir, "2023-02-23_14-14-48-back.mp4"); + Touch(dir, "2023-02-23_14-14-48-rear_view.mp4"); + + var chunks = CamChunk.Map(dir); + + chunks.Count.ShouldBe(1); + + // Exactly one of the two rear files survives; which one follows enumeration order, so the winner is deliberately not pinned here. + chunks[0].Files.Keys.ShouldBe([CameraNames.Front, CameraNames.Back], ignoreOrder: true); + + CamClip.Map(dir).ShouldNotBeNull(); + } + finally + { + Directory.Delete(dir, true); + } + } } diff --git a/SentryDeck.Tests/CamEventTests.cs b/SentryDeck.Tests/CamEventTests.cs index e11c597..6221017 100644 --- a/SentryDeck.Tests/CamEventTests.cs +++ b/SentryDeck.Tests/CamEventTests.cs @@ -1,9 +1,9 @@ namespace SentryDeck.Tests; -public static class CamEventTests +public sealed class CamEventTests { [Fact] - public static void Deserializes_Correctly() + public void Deserialize_FullEventJson_PopulatesEveryField() { // Arrange var json = """ @@ -31,7 +31,7 @@ public static void Deserializes_Correctly() } [Fact] - public static void Deserialization_OptionalProperties() + public void Deserialization_OptionalProperties() { // Arrange var json = """ @@ -53,7 +53,7 @@ public static void Deserialization_OptionalProperties() } [Fact] - public static void Deserialization_RecoversValidFieldsFromMalformedJson() + public void Deserialization_RecoversValidFieldsFromMalformedJson() { // Every field here is well-formed JSON but several are semantically bad (bad date, non-numeric // lat/lon, non-integer camera). Strict deserialization throws; the lenient fallback keeps the @@ -83,7 +83,7 @@ public static void Deserialization_RecoversValidFieldsFromMalformedJson() } [Fact] - public static void Deserialization_BlankCoordinateKeepsCityAndTimestamp() + public void Deserialization_BlankCoordinateKeepsCityAndTimestamp() { // Tesla occasionally writes an incomplete est_lat; a single blank field must not discard the // city and the event timestamp the clip name falls back to. @@ -110,17 +110,21 @@ public static void Deserialization_BlankCoordinateKeepsCityAndTimestamp() } [Fact] - public static void Deserialization_ReturnsNullForNonObjectJson() + public void Deserialization_ReturnsNullForNonObjectJson() { CamEvent.Deserialize("\"just a string\"").ShouldBeNull(); CamEvent.Deserialize("not json at all {").ShouldBeNull(); } [Fact] - public static void FromFile() + public void FromFile_ReadsEventJsonFromDisk() { var camEvent = CamEvent.FromFile("Mocks/2023-02-23_14-16-15/event.json"); + // Assert the parsed values, not just non-null: every field could silently fall back to its default and still leave a CamEvent behind -- exactly what the sibling Deserialize tests exist to catch, and this is the only one that goes through the file-reading path. camEvent.ShouldNotBeNull(); + camEvent.Timestamp.ShouldBe(new DateTime(2023, 2, 23, 14, 16, 7)); + camEvent.City.ShouldBe("Austin"); + camEvent.Reason.ShouldBe("user_interaction_honk"); } } diff --git a/SentryDeck.Tests/CamStorageTests.cs b/SentryDeck.Tests/CamStorageTests.cs index 7b42eee..f3d5ae2 100644 --- a/SentryDeck.Tests/CamStorageTests.cs +++ b/SentryDeck.Tests/CamStorageTests.cs @@ -1,19 +1,27 @@ namespace SentryDeck.Tests; -public static class CamStorageTests +public sealed class CamStorageTests { [Fact] - public static void TraverseFindsAllClips() + public void Map_RootWithMixedFolders_ReturnsOnlyPlayableClips() { - var storage = CamStorage.Map("."); + var storage = CamStorage.Map("Mocks"); - storage.Clips.Count.ShouldBe(3); // Ignores the "No Camera Files" folder. + // Two mock folders are deliberately unplayable and must not surface: "No Camera Files" holds only an event.json, and "No Front Angle" has every angle except the front one -- CamChunk.Map keeps only timestamp groups containing a front file, so it yields no chunks at all. + // The "Mocks" root is itself a clip candidate, but it holds no media either. + storage.Clips.Select(clip => clip.Name).ShouldBe( + [ + "02/23/2023 14:16:15", + "Custom Folder Name", + "Missing Left Camera Angle on Second Chunk", + ], + ignoreOrder: true); } [Theory] [InlineData("Mocks/2023-02-23_14-16-15", "02/23/2023 14:16:15")] [InlineData("Mocks/Custom Folder Name", "Custom Folder Name")] - public static void ClipName(string path, string expectedName) + public void Map_ClipName_ComesFromFolderNameOrTimestamp(string path, string expectedName) { var clip = CamClip.Map(path); @@ -22,7 +30,7 @@ public static void ClipName(string path, string expectedName) } [Fact] - public static void MapClipWithNonstandardNameFallsBackToEventDataForTimestamp() + public void MapClipWithNonstandardNameFallsBackToEventDataForTimestamp() { var clip = CamClip.Map("Mocks/Custom Folder Name"); @@ -34,30 +42,25 @@ public static void MapClipWithNonstandardNameFallsBackToEventDataForTimestamp() [InlineData("Mocks/2023-02-23_14-16-15", 2)] [InlineData("Mocks/Missing Left Camera Angle on Second Chunk", 2)] [InlineData("Mocks/No Front Angle", 0)] - public static void FindsAllChunks(string path, int expectedCount) + public void FindsAllChunks(string path, int expectedCount) { var chunks = CamChunk.Map(path); chunks.Count.ShouldBe(expectedCount); } - [Theory] - [InlineData("Mocks/2023-02-23_14-16-15")] - public static void ChunksAreInCorrectOrder(string path) + [Fact] + public void ChunksAreInCorrectOrder() { - var chunks = CamChunk.Map(path); - - for (var i = 1; i < chunks.Count; i++) - { - var currentTimestamp = chunks[i - 1].Timestamp; - var nextTimestamp = chunks[i].Timestamp; + var chunks = CamChunk.Map("Mocks/2023-02-23_14-16-15"); - nextTimestamp.ShouldBeGreaterThan(currentTimestamp, "each timestamp should be more recent than the previous one"); - } + // The count assertion is load-bearing: an ordering check alone passes vacuously on an empty or single-chunk result, so it would stay green if discovery stopped finding chunks at all. + chunks.Count.ShouldBe(2); + chunks.Select(chunk => chunk.Timestamp).ShouldBeInOrder(); } [Fact] - public static void MapRoot_WhenRootIsClipFolder_ReturnsThatClip() + public void MapRoot_WhenRootIsClipFolder_ReturnsThatClip() { var storage = CamStorage.Map("Mocks/2023-02-23_14-16-15"); @@ -68,7 +71,7 @@ public static void MapRoot_WhenRootIsClipFolder_ReturnsThatClip() [Theory] [InlineData("Mocks/2023-02-23_14-16-15", 8)] [InlineData("Mocks/Missing Left Camera Angle on Second Chunk", 7)] - public static void FindsAllFiles(string path, int expectedCount) + public void FindFiles_ReturnsEveryCameraFile(string path, int expectedCount) { var files = CamFile.FindFiles(path).ToList(); diff --git a/SentryDeck.Tests/ClipExporterTests.cs b/SentryDeck.Tests/ClipExporterTests.cs index 3409cff..ce14497 100644 --- a/SentryDeck.Tests/ClipExporterTests.cs +++ b/SentryDeck.Tests/ClipExporterTests.cs @@ -1,9 +1,15 @@ +using System.IO; + namespace SentryDeck.Tests; public sealed class ClipExporterTests { private static readonly DateTime FirstTimestamp = new(2025, 1, 1, 12, 0, 0); + // ExportAsync writes its scripts to a fixed folder shared with the real app, so the cleanup tests compare snapshots of it rather than asserting it is empty. + private static readonly string ExportScriptDirectory = + Path.Combine(Path.GetTempPath(), "SentryDeck", "exports"); + // A clip of three 60s chunks plus the matching opened media source. Files never touch disk: // ResolveEntries/BuildConcatScript work purely on the model. omitCameraFromChunk removes one // camera's file from one chunk to exercise the truncation rules. @@ -39,8 +45,19 @@ private static ClipExportRequest Request( (CamClip Clip, ClipMediaSource Source) fixture, string camera, TimeSpan start, - TimeSpan end) - => new(fixture.Clip, fixture.Source, camera, start, end, @"C:\out\export.mp4"); + TimeSpan end, + string outputPath = @"C:\out\export.mp4") + => new(fixture.Clip, fixture.Source, camera, start, end, outputPath); + + // A path under the temp folder that nothing has created yet, so a test can prove the exporter deleted a file it wrote there. + private static string TempOutputPath() + => Path.Combine(Path.GetTempPath(), $"SentryDeckTests-{Guid.NewGuid():N}.mp4"); + + // BuildArguments wraps the script path in the first pair of quotes it emits. + private static string ScriptPathFromArguments(string arguments) => arguments.Split('"')[1]; + + private static string[] ExportScriptFiles() + => Directory.Exists(ExportScriptDirectory) ? Directory.GetFiles(ExportScriptDirectory) : []; [Fact] public void ResolveEntries_MapsSegmentsToTheCameraFiles() @@ -121,7 +138,8 @@ public void ResolveEntries_EmptyRange_Throws() var fixture = CreateClip(); Should.Throw(() => ClipExporter.ResolveEntries( - Request(fixture, CameraNames.Front, TimeSpan.FromSeconds(200), TimeSpan.FromSeconds(300)))); + Request(fixture, CameraNames.Front, TimeSpan.FromSeconds(200), TimeSpan.FromSeconds(300)))) + .Message.ShouldContain("contains no footage"); } [Fact] @@ -156,4 +174,52 @@ public void BuildArguments_StreamCopiesTheConcatScript() arguments.ShouldContain("\"C:\\tmp\\job.ffconcat\""); arguments.ShouldContain("\"C:\\out\\clip.mp4\""); } + + [Fact] + public async Task ExportAsync_FfmpegNotInstalled_ThrowsWithInstallMessage() + { + // FFmpeg is downloaded in the background at startup, so an export attempted before it lands must tell the user to restart instead of surfacing a raw file-not-found. + // Nothing may be written to disk before that check either. + var fixture = CreateClip(); + var scriptsBefore = ExportScriptFiles(); + var exporter = new ClipExporter(() => null, (_, _, _) => Task.CompletedTask); + + var exception = await Should.ThrowAsync(() => exporter.ExportAsync( + Request(fixture, CameraNames.Front, TimeSpan.Zero, TimeSpan.FromSeconds(60)))); + + exception.Message.ShouldContain("FFmpeg"); + ExportScriptFiles().ShouldBe(scriptsBefore, ignoreOrder: true); + } + + [Fact] + public async Task ExportAsync_WhenFfmpegFails_DeletesPartialOutputAndScript() + { + // FFmpeg had already begun writing when it failed, so without cleanup the user is left with a truncated file at the path they chose that looks like a finished export. + var fixture = CreateClip(); + var outputPath = TempOutputPath(); + string scriptPath = null; + + var exporter = new ClipExporter( + () => @"C:\ffmpeg", + (_, arguments, _) => + { + scriptPath = ScriptPathFromArguments(arguments); + File.WriteAllText(outputPath, "half-written"); + throw new InvalidOperationException("FFmpeg exited with code 1."); + }); + + try + { + await Should.ThrowAsync(() => exporter.ExportAsync( + Request(fixture, CameraNames.Front, TimeSpan.Zero, TimeSpan.FromSeconds(60), outputPath))); + + scriptPath.ShouldNotBeNull(); + File.Exists(outputPath).ShouldBeFalse(); + File.Exists(scriptPath).ShouldBeFalse(); + } + finally + { + File.Delete(outputPath); + } + } } diff --git a/SentryDeck.Tests/ClipMediaSourceTrimTests.cs b/SentryDeck.Tests/ClipMediaSourceTrimTests.cs index b7b5323..f9efb5c 100644 --- a/SentryDeck.Tests/ClipMediaSourceTrimTests.cs +++ b/SentryDeck.Tests/ClipMediaSourceTrimTests.cs @@ -20,6 +20,38 @@ private static ClipMediaSource ThreeChunkSource() durations); } + // Three included chunks whose probed durations differ (60s/45s/30s) while their timestamps stay a nominal minute apart, so a chunk's real span can no longer be mistaken for its clock slot. + private static ClipMediaSource UnevenChunkSource() + { + var starts = new[] { TimeSpan.Zero, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(105) }; + var durations = new[] { TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(45), TimeSpan.FromSeconds(30) }; + var timestamps = Enumerable.Range(0, 3).Select(i => FirstTimestamp.AddMinutes(i)).ToList(); + + return new ClipMediaSource( + TimeSpan.FromSeconds(135), + starts, + new Dictionary(), + [], + timestamps, + durations); + } + + // Two chunks whose spacing each test picks, so the gap-threshold comparison is the only thing the assertion can turn on. + private static ClipMediaSource TwoChunkSource(TimeSpan firstDuration, TimeSpan secondChunkOffset) + { + var starts = new[] { TimeSpan.Zero, firstDuration }; + var durations = new[] { firstDuration, TimeSpan.FromSeconds(60) }; + var timestamps = new[] { FirstTimestamp, FirstTimestamp + secondChunkOffset }; + + return new ClipMediaSource( + firstDuration + TimeSpan.FromSeconds(60), + starts, + new Dictionary(), + [], + timestamps, + durations); + } + [Fact] public void RangeInsideOneChunk_YieldsSingleSegmentWithBothPoints() { @@ -91,4 +123,58 @@ public void MissingChunkData_YieldsNothing() source.GetTrimSegments(TimeSpan.Zero, TimeSpan.FromSeconds(30)).ShouldBeEmpty(); } + + [Fact] + public void RangeSpanningUnevenChunks_TrimsOnlyTheOuterEdges() + { + var segments = UnevenChunkSource().GetTrimSegments(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(120)); + + segments.Count.ShouldBe(3); + + segments[0].InPoint.ShouldBe(TimeSpan.FromSeconds(30)); + segments[0].OutPoint.ShouldBeNull(); + + // The middle chunk holds only 45s, not the 60s its wall-clock slot suggests, and the range covers all of it -- so both points stay null rather than trimming at a nominal boundary. + segments[1].ChunkTimestamp.ShouldBe(FirstTimestamp.AddMinutes(1)); + segments[1].InPoint.ShouldBeNull(); + segments[1].OutPoint.ShouldBeNull(); + + // The last chunk starts at media time 105s, so 120s is 15s into it -- not the 0s a nominal 60s-per-chunk timeline would compute. + segments[2].ChunkTimestamp.ShouldBe(FirstTimestamp.AddMinutes(2)); + segments[2].InPoint.ShouldBeNull(); + segments[2].OutPoint.ShouldBe(TimeSpan.FromSeconds(15)); + } + + [Fact] + public void RangeEndingInsideTheShortTailChunk_ClipsToThatChunk() + { + var segments = UnevenChunkSource().GetTrimSegments(TimeSpan.FromSeconds(110), TimeSpan.FromSeconds(125)); + + var segment = segments.ShouldHaveSingleItem(); + segment.ChunkTimestamp.ShouldBe(FirstTimestamp.AddMinutes(2)); + segment.InPoint.ShouldBe(TimeSpan.FromSeconds(5)); + segment.OutPoint.ShouldBe(TimeSpan.FromSeconds(20)); + } + + [Fact] + public void ToMediaTime_AtExactClipEnd_ReturnsDuration() + { + // The clip's final instant belongs to the clip: an event marker sitting exactly at the end must still map to a position, since callers accept a fraction of 1. + ThreeChunkSource().ToMediaTime(FirstTimestamp.AddMinutes(3)).ShouldBe(TimeSpan.FromSeconds(180)); + } + + [Fact] + public void GapPositions_GapExactlyAtThreshold_IsNotAGap() + { + // Exactly the threshold is the ordinary skew between a chunk's nominal timestamp and the previous chunk's probed end, so it must not litter the timeline with a marker. + TwoChunkSource(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(65)).GapPositions.ShouldBeEmpty(); + } + + [Fact] + public void GapPositions_GapJustOverThreshold_IsAGap() + { + var source = TwoChunkSource(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(65).Add(TimeSpan.FromTicks(1))); + + source.GapPositions.ShouldBe([TimeSpan.FromSeconds(60)]); + } } diff --git a/SentryDeck.Tests/ClipPlaylistTests.cs b/SentryDeck.Tests/ClipPlaylistTests.cs index f38aa3d..cf03763 100644 --- a/SentryDeck.Tests/ClipPlaylistTests.cs +++ b/SentryDeck.Tests/ClipPlaylistTests.cs @@ -7,7 +7,12 @@ public void SetClips_ReplacesListAndClearsSelection() { var playlist = new ClipPlaylist(); var clips = TestClips.Create(3); - playlist.MoveTo(0); + + // Seed a real selection first. + // On a fresh playlist the index is already -1, so without this the "clears selection" assertions below hold no matter what SetClips does. + playlist.SetClips(TestClips.Create(2)); + playlist.MoveTo(1); + playlist.CurrentIndex.ShouldBe(1); playlist.SetClips(clips); @@ -149,7 +154,7 @@ public void RemoveClip_NotInPlaylist_ReturnsFalse_AndChangesNothing() var clips = TestClips.Create(2); playlist.SetClips(clips); playlist.MoveTo(1); - var stranger = TestClips.Create(1)[0]; + var stranger = new CamClip(@"C:\somewhere-else", "Not In Playlist", new DateTime(2024, 1, 1), [], camEvent: null); var removed = playlist.RemoveClip(stranger); @@ -171,4 +176,50 @@ public void RemoveClip_RaisesPlaylistChanged() playlistChanged.ShouldBe(1); } + + [Fact] + public void MoveTo_SameIndex_ReturnsFalseAndRaisesNoEvent() + { + var playlist = new ClipPlaylist(); + playlist.SetClips(TestClips.Create(2)); + playlist.MoveTo(1); + var currentChanged = 0; + playlist.CurrentClipChanged += (_, _) => currentChanged++; + + // Re-selecting the clip already playing must be a no-op; a spurious event would restart it. + playlist.MoveTo(1).ShouldBeFalse(); + + currentChanged.ShouldBe(0); + playlist.CurrentIndex.ShouldBe(1); + } + + [Fact] + public void RemoveClip_WhenNothingIsSelected_LeavesIndexAtMinusOne() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(3); + playlist.SetClips(clips); + + playlist.RemoveClip(clips[0]).ShouldBeTrue(); + + playlist.CurrentIndex.ShouldBe(-1); + playlist.Clips.ShouldBe(new[] { clips[1], clips[2] }); + } + + [Fact] + public void MoveTo_FieldIdenticalButDistinctClip_DoesNotMatch() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(2); + playlist.SetClips(clips); + playlist.MoveTo(1); + var twin = new CamClip(clips[0].FullPath, clips[0].Name, clips[0].Timestamp, [], camEvent: null); + + // Pins CURRENT behavior, not a designed contract. + // CamClip is a record, but its Chunks list compares by reference, so two clips describing the same folder never match. + // A maintainer who wants structural matching on FullPath should change this test with the behavior. + playlist.MoveTo(twin).ShouldBeFalse(); + + playlist.CurrentIndex.ShouldBe(1); + } } diff --git a/SentryDeck.Tests/ConverterTests.cs b/SentryDeck.Tests/ConverterTests.cs index 4d1efa5..885bbe9 100644 --- a/SentryDeck.Tests/ConverterTests.cs +++ b/SentryDeck.Tests/ConverterTests.cs @@ -1,9 +1,13 @@ +using System.IO; using System.Windows; +using System.Windows.Media; namespace SentryDeck.Tests; public sealed class ConverterTests { + private static readonly DateTime Moment = new(2023, 12, 16, 15, 53, 0); + [Theory] [InlineData(true, Visibility.Visible)] [InlineData(false, Visibility.Collapsed)] @@ -87,4 +91,153 @@ public void SelectionWidth_SpansExactlyBetweenTheMarkOffsets() (start.Left + width).ShouldBe(end.Left); } + + [Fact] + public void NowPlayingConverter_MarksOnlyTheClipInstanceThatIsPlaying() + { + var converter = new NowPlayingConverter(); + var clips = TestClips.Create(2); + + converter.Convert([clips[0], clips[0]], typeof(Visibility), null, null).ShouldBe(Visibility.Visible); + converter.Convert([clips[0], clips[1]], typeof(Visibility), null, null).ShouldBe(Visibility.Collapsed); + } + + [Theory] + [InlineData("date")] + [InlineData("time")] + [InlineData("Date")] // the parameter is lower-cased, so XAML casing can't silently pick the default branch + public void FriendlyDateConverter_Parameter_SelectsOneHalfOfTheTimestamp(string parameter) + { + // Asserted against the unparameterized rendering instead of a literal so this holds under any current culture: the clip card lays the two halves on opposite ends of one row, and together they must be exactly what the default branch renders. + var converter = new FriendlyDateConverter(); + + var part = (string)converter.Convert(Moment, typeof(string), parameter, null); + var combined = (string)converter.Convert(Moment, typeof(string), null, null); + + part.ShouldNotBeNullOrEmpty(); + combined.ShouldContain(part); + combined.Length.ShouldBeGreaterThan(part.Length); + } + + [Fact] + public void FriendlyDateConverter_NonDateValues_RenderNothing() + { + var converter = new FriendlyDateConverter(); + + converter.Convert(null, typeof(string), "date", null).ShouldBe(string.Empty); + converter.Convert("2023-12-16", typeof(string), "date", null).ShouldBe(string.Empty); + } + + [Fact] + public void DayGroupHeaderConverter_TodayAndYesterday_GetRelativeHeaders() + { + // Driven off DateTime.Today rather than a fixed date so the expectation can't go stale, and with a time of day attached because the header groups by calendar day, not by instant. + var converter = new DayGroupHeaderConverter(); + + converter.Convert(DateTime.Today.AddHours(23), typeof(string), null, null).ShouldBe("Today"); + converter.Convert(DateTime.Today.AddDays(-1).AddHours(9), typeof(string), null, null).ShouldBe("Yesterday"); + } + + [Fact] + public void DayGroupHeaderConverter_OlderDays_GetDistinctAbsoluteHeaders() + { + var converter = new DayGroupHeaderConverter(); + + var twoDaysAgo = (string)converter.Convert(DateTime.Today.AddDays(-2), typeof(string), null, null); + var threeDaysAgo = (string)converter.Convert(DateTime.Today.AddDays(-3), typeof(string), null, null); + + // Past yesterday each day needs a header that identifies it on its own, or two days of clips would collapse under one sticky group. + twoDaysAgo.ShouldNotBe("Today"); + twoDaysAgo.ShouldNotBe("Yesterday"); + twoDaysAgo.ShouldNotBe(threeDaysAgo); + } + + [Theory] + [InlineData(0, "—")] + [InlineData(1, "~1 min")] + [InlineData(60, "~1h 0m")] + [InlineData(95, "~1h 35m")] + public void ClipDurationConverter_RendersTheModeledChunkDuration(int chunkCount, string expected) + { + // Every chunk models 60s (ClipTimeline.EstimatedChunkSeconds), so the chunk count is the estimate in minutes; a clip whose chunks were all filtered out has nothing to estimate. + var result = new ClipDurationConverter().Convert(ClipWithChunks(chunkCount), typeof(string), null, null); + + result.ShouldBe(expected); + } + + [Fact] + public void ClipDurationConverter_NonClipValues_RenderNothing() + { + var converter = new ClipDurationConverter(); + + converter.Convert(null, typeof(string), null, null).ShouldBe(string.Empty); + converter.Convert("5 min", typeof(string), null, null).ShouldBe(string.Empty); + } + + [Fact] + public void ThumbnailConverter_MissingThumbnail_YieldsNoImage() + { + var converter = new ThumbnailConverter(); + + converter.Convert(null, typeof(ImageSource), null, null).ShouldBeNull(); + converter.Convert(string.Empty, typeof(ImageSource), null, null).ShouldBeNull(); + converter.Convert(MissingThumbnailPath(), typeof(ImageSource), null, null).ShouldBeNull(); + } + + [Fact] + public void ThumbnailConverter_UndecodableThumbnail_YieldsNoImage() + { + // Tesla writes thumb.png as it records, so a half-written or truncated one is normal. + // It has to land on the same "no thumbnail" path as a missing file instead of throwing out of a binding while the list scrolls. + WithTempFile(path => + new ThumbnailConverter().Convert(path, typeof(ImageSource), null, null).ShouldBeNull()); + } + + [Fact] + public void ThumbnailConverter_FallbackParameter_IsVisibleOnlyWhenTheFileIsMissing() + { + // The placeholder behind the image is driven purely by the file's presence. + var converter = new ThumbnailConverter(); + + converter.Convert(MissingThumbnailPath(), typeof(Visibility), "fallback", null).ShouldBe(Visibility.Visible); + converter.Convert(null, typeof(Visibility), "fallback", null).ShouldBe(Visibility.Visible); + WithTempFile(path => + converter.Convert(path, typeof(Visibility), "fallback", null).ShouldBe(Visibility.Collapsed)); + } + + [Fact] + public void EventConverters_NonEventValues_FallBackToTheNoEventDefaults() + { + // These bind against clip rows that may carry no event.json at all. + new ReasonLabelConverter().Convert(null, typeof(string), null, null).ShouldBe("Recent"); + new ReasonKeyConverter().Convert(null, typeof(string), null, null).ShouldBe(ClipDisplay.ReasonRecent); + new MapAvailabilityConverter().Convert("not an event", typeof(Visibility), null, null).ShouldBe(Visibility.Collapsed); + } + + private static CamClip ClipWithChunks(int chunkCount) + { + var chunks = Enumerable.Range(0, chunkCount) + .Select(index => new CamChunk(Moment.AddMinutes(index), [])); + + return new CamClip(Path.GetTempPath(), "Test Clip", Moment, chunks, camEvent: null); + } + + private static string MissingThumbnailPath() => + Path.Combine(Path.GetTempPath(), $"SentryDeckTests-{Guid.NewGuid():N}.png"); + + // ThumbnailConverter is the only converter here that reads from disk; give it a throwaway file that is deleted even when the assertion fails. + private static void WithTempFile(Action assert) + { + var path = MissingThumbnailPath(); + File.WriteAllText(path, "not a png"); + + try + { + assert(path); + } + finally + { + File.Delete(path); + } + } } diff --git a/SentryDeck.Tests/CultureInvarianceTests.cs b/SentryDeck.Tests/CultureInvarianceTests.cs new file mode 100644 index 0000000..0b023ad --- /dev/null +++ b/SentryDeck.Tests/CultureInvarianceTests.cs @@ -0,0 +1,113 @@ +using System.Globalization; +using System.IO; + +namespace SentryDeck.Tests; + +/// +/// Pins the InvariantCulture arguments that the rest of the suite cannot see, because every other test runs under the dev/CI machine's own culture where the invariant and current formats happen to agree. +/// Each test here runs under a culture that formats or parses differently, so dropping one of those arguments in production turns from invisible into a failure. +/// +public sealed class CultureInvarianceTests : IDisposable +{ + // The playlist directory is shared with the running app, so only the files this class wrote get deleted. + // Without this every run leaves a permanent, never-reused playlist behind: the file name hashes the clip's root path, and every fixture clip lives under a fresh GUID folder. + private readonly List _writtenPlaylists = []; + + public void Dispose() + { + foreach (var path in _writtenPlaylists) + { + File.Delete(path); + } + } + + [Theory] + [InlineData("de-DE")] + [InlineData("ar-SA")] + public void BuildConcatScript_UsesInvariantDecimalSeparator(string culture) + { + // de-DE writes "12,500000" and ar-SA writes an Arabic decimal separator (U+066B). + // FFmpeg's concat demuxer only understands an ASCII period, so either one makes every trimmed export fail on a machine whose only fault is its regional settings. + using var cultureSwap = new CultureSwap(culture); + + var script = ClipExporter.BuildConcatScript([(@"C:\clips\a.mp4", TimeSpan.FromSeconds(12.5), null)]); + + script.ShouldContain("inpoint 12.500000"); + } + + [Fact] + public void Build_WritesPlaylistWithInvariantDurations() + { + // Same hazard on the playback side: a de-DE "duration 60,000000" is a malformed directive and the whole clip fails to open, not just the one chunk. + // The fixture is created before the swap so its file names stay Gregorian. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + using var cultureSwap = new CultureSwap("de-DE"); + + var mediaSource = new FfconcatMediaSourceBuilder().Build(clipFiles.Clip); + _writtenPlaylists.AddRange(mediaSource.CameraPlaylistPaths.Values); + + File.ReadAllText(mediaSource.CameraPlaylistPaths[CameraNames.Front]) + .ShouldContain("duration 60.000000"); + } + + [Theory] + [InlineData("ar-SA")] + [InlineData("th-TH")] + public void FindFiles_ParsesTeslaFileNames_UnderNonGregorianCulture(string culture) + { + // TeslaCam names files with a Gregorian date regardless of who owns the car. + // Parsed against the current culture instead, ar-SA (UmAlQura) rejects the name outright -- the scan finds zero clips -- and th-TH (Buddhist) dates every clip 543 years off. + var root = Path.Combine(Path.GetTempPath(), $"SentryDeckTests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + + try + { + File.WriteAllBytes( + Path.Combine(root, "2023-02-23_14-14-48-front.mp4"), + TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))); + + using var cultureSwap = new CultureSwap(culture); + + var file = CamFile.FindFiles(root).ShouldHaveSingleItem(); + + file.Timestamp.Year.ShouldBe(2023); + file.Camera.ShouldBe(CameraNames.Front); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void ClipName_IsCultureInvariant() + { + // The clip's display name is the folder timestamp round-tripped through a parse and a format. + // Under th-TH the current culture would corrupt both halves -- parsing 2023 as a Buddhist year and formatting it back in the Buddhist calendar. + using var cultureSwap = new CultureSwap("th-TH"); + + var clip = CamClip.Map("Mocks/2023-02-23_14-16-15"); + + clip.ShouldNotBeNull(); + clip.Name.ShouldBe("02/23/2023 14:16:15"); + } + + /// + /// Runs a test body under a chosen culture and puts the original back. + /// CurrentCulture is per-thread and flows with the execution context, so the swap cannot leak into tests running in parallel on other threads. + /// + private sealed class CultureSwap : IDisposable + { + private readonly CultureInfo _previous = CultureInfo.CurrentCulture; + + public CultureSwap(string name) + { + CultureInfo.CurrentCulture = new CultureInfo(name); + } + + public void Dispose() + { + CultureInfo.CurrentCulture = _previous; + } + } +} diff --git a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs index 2367bd2..c6223a3 100644 --- a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs +++ b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs @@ -3,16 +3,17 @@ namespace SentryDeck.Tests; -public sealed class FfconcatMediaSourceBuilderTests +public sealed class FfconcatMediaSourceBuilderTests : IDisposable { + private readonly List _writtenPlaylists = []; + [Fact] public void Build_WritesPlaylistWithProbedDurations() { // Test fixtures write minimal valid mp4 files whose moov encodes a 60s duration. using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip); + var mediaSource = Build(clipFiles.Clip); mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(120)); mediaSource.ChunkStarts.ShouldBe([TimeSpan.Zero, TimeSpan.FromSeconds(60)]); @@ -22,8 +23,8 @@ public void Build_WritesPlaylistWithProbedDurations() File.Exists(frontPlaylistPath).ShouldBeTrue(); Path.GetExtension(frontPlaylistPath).ShouldBe(".ffconcat"); - var frontFile0 = clipFiles.GetPath(0, CameraNames.Front).Replace('\\', '/'); - var frontFile1 = clipFiles.GetPath(1, CameraNames.Front).Replace('\\', '/'); + var frontFile0 = clipFiles.GetFfconcatPath(0, CameraNames.Front); + var frontFile1 = clipFiles.GetFfconcatPath(1, CameraNames.Front); var expected = "ffconcat version 1.0" + Environment.NewLine + $"file '{frontFile0}'" + Environment.NewLine + @@ -34,32 +35,14 @@ public void Build_WritesPlaylistWithProbedDurations() File.ReadAllText(frontPlaylistPath).ShouldBe(expected); } - [Fact] - public void Build_SixCameraClip_BuildsPillarPlaylists() - { - // An HW4/AI4 clip carries the classic four cameras plus the two B-pillar cameras. - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var builder = new FfconcatMediaSourceBuilder(); - - var mediaSource = builder.Build(clipFiles.Clip); - - mediaSource.CameraPlaylistPaths.Keys.ShouldContain(CameraNames.LeftPillar); - mediaSource.CameraPlaylistPaths.Keys.ShouldContain(CameraNames.RightPillar); - - var leftPillar = File.ReadAllText(mediaSource.CameraPlaylistPaths[CameraNames.LeftPillar]); - leftPillar.ShouldContain(clipFiles.GetPath(0, CameraNames.LeftPillar).Replace('\\', '/')); - leftPillar.ShouldContain(clipFiles.GetPath(1, CameraNames.LeftPillar).Replace('\\', '/')); - } - [Fact] public void Build_Hw3FourCameraClip_BuildsExactlyThoseFourAndNoPillars() { // An HW3 clip has no pillar files; their absence is normal, not corruption. string[] hw3 = [CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater]; using var clipFiles = TestClipFiles.Create(chunkCount: 2, cameras: hw3); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip); + var mediaSource = Build(clipFiles.Clip); mediaSource.CameraPlaylistPaths.Keys.ShouldBe(hw3, ignoreOrder: true); } @@ -70,13 +53,12 @@ public void Build_UnknownCameraSuffix_StillGetsAPlaylist() // A future/unrecognized camera must be surfaced, not silently dropped. string[] cameras = [CameraNames.Front, "front_bumper"]; using var clipFiles = TestClipFiles.Create(chunkCount: 1, cameras: cameras); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip); + var mediaSource = Build(clipFiles.Clip); mediaSource.CameraPlaylistPaths.Keys.ShouldContain("front_bumper"); File.ReadAllText(mediaSource.CameraPlaylistPaths["front_bumper"]) - .ShouldContain(clipFiles.GetPath(0, "front_bumper").Replace('\\', '/')); + .ShouldContain(clipFiles.GetFfconcatPath(0, "front_bumper")); } [Fact] @@ -91,8 +73,7 @@ public void Build_CameraMissingFromLaterChunk_TruncatesThatCamerasPlaylist() chunks[1] = chunkWithoutLeft; var clip = new CamClip(clipFiles.Clip.FullPath, clipFiles.Clip.Name, clipFiles.Clip.Timestamp, chunks, camEvent: null); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clip); + var mediaSource = Build(clip); mediaSource.CameraPlaylistPaths.ContainsKey(CameraNames.LeftRepeater).ShouldBeTrue(); @@ -101,14 +82,14 @@ public void Build_CameraMissingFromLaterChunk_TruncatesThatCamerasPlaylist() // Only chunk 0's file should appear; chunk 1 is missing so the camera's playlist stops there, // and chunk 2 (which does have the file) must not be included since it comes after the gap. - leftContent.ShouldContain(clipFiles.GetPath(0, CameraNames.LeftRepeater).Replace('\\', '/')); - leftContent.ShouldNotContain(clipFiles.GetPath(2, CameraNames.LeftRepeater).Replace('\\', '/')); + leftContent.ShouldContain(clipFiles.GetFfconcatPath(0, CameraNames.LeftRepeater)); + leftContent.ShouldNotContain(clipFiles.GetFfconcatPath(2, CameraNames.LeftRepeater)); // Front is present in every chunk, so it still covers the full clip. var frontContent = File.ReadAllText(mediaSource.CameraPlaylistPaths[CameraNames.Front]); - frontContent.ShouldContain(clipFiles.GetPath(0, CameraNames.Front).Replace('\\', '/')); - frontContent.ShouldContain(clipFiles.GetPath(1, CameraNames.Front).Replace('\\', '/')); - frontContent.ShouldContain(clipFiles.GetPath(2, CameraNames.Front).Replace('\\', '/')); + frontContent.ShouldContain(clipFiles.GetFfconcatPath(0, CameraNames.Front)); + frontContent.ShouldContain(clipFiles.GetFfconcatPath(1, CameraNames.Front)); + frontContent.ShouldContain(clipFiles.GetFfconcatPath(2, CameraNames.Front)); } [Fact] @@ -116,8 +97,7 @@ public void Build_CameraMissingFromChunkZero_OmitsCameraEntirely() { using var clipFiles = TestClipFiles.Create(chunkCount: 2, omitCamerasFromChunkZero: new HashSet { CameraNames.Back }); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip); + var mediaSource = Build(clipFiles.Clip); mediaSource.CameraPlaylistPaths.ContainsKey(CameraNames.Back).ShouldBeFalse(); } @@ -137,8 +117,7 @@ public void Build_EscapesSingleQuoteInPath() var chunk = new CamChunk(timestamp, [frontFile]); var clip = new CamClip(root, "Test Clip", timestamp, [chunk], camEvent: null); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clip); + var mediaSource = Build(clip); var content = File.ReadAllText(mediaSource.CameraPlaylistPaths[CameraNames.Front]); var expectedEscapedPath = frontFile.FullPath.Replace('\\', '/').Replace("'", "'\\''"); @@ -155,36 +134,65 @@ public void Build_EscapesSingleQuoteInPath() public void Build_OverwritesPlaylistOnRebuild() { using var clipFiles = TestClipFiles.Create(chunkCount: 1); - var builder = new FfconcatMediaSourceBuilder(); - var first = builder.Build(clipFiles.Clip); + var first = Build(clipFiles.Clip); var firstContent = File.ReadAllText(first.CameraPlaylistPaths[CameraNames.Front]); - var second = builder.Build(clipFiles.Clip); + var second = Build(clipFiles.Clip); var secondContent = File.ReadAllText(second.CameraPlaylistPaths[CameraNames.Front]); first.CameraPlaylistPaths[CameraNames.Front].ShouldBe(second.CameraPlaylistPaths[CameraNames.Front]); secondContent.ShouldBe(firstContent); } + [Fact] + public void Build_TwoDifferentClipsWithTheSameName_WritePlaylistsToDifferentPaths() + { + // Clips are named from their folder timestamp, so a RecentClips/SavedClips pair -- or the same footage on two drives -- genuinely share a name. + // If the name alone keyed the playlist file, one clip would silently play the other's footage: wrong video, no exception. + using var firstFiles = TestClipFiles.Create(chunkCount: 1); + using var secondFiles = TestClipFiles.Create(chunkCount: 1); + firstFiles.Clip.Name.ShouldBe(secondFiles.Clip.Name); + + var first = Build(firstFiles.Clip); + var second = Build(secondFiles.Clip); + + var firstPlaylist = first.CameraPlaylistPaths[CameraNames.Front]; + var secondPlaylist = second.CameraPlaylistPaths[CameraNames.Front]; + firstPlaylist.ShouldNotBe(secondPlaylist); + + var firstRoot = firstFiles.RootPath.Replace('\\', '/'); + var secondRoot = secondFiles.RootPath.Replace('\\', '/'); + + var firstContent = File.ReadAllText(firstPlaylist); + firstContent.ShouldContain(firstRoot); + firstContent.ShouldNotContain(secondRoot); + + var secondContent = File.ReadAllText(secondPlaylist); + secondContent.ShouldContain(secondRoot); + secondContent.ShouldNotContain(firstRoot); + } + [Fact] public void Build_ExcludingMiddleChunk_RemovesItFromEveryCamerasPlaylistAndShrinksTimeline() { using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip, new HashSet { 1 }); + var mediaSource = Build(clipFiles.Clip, new HashSet { 1 }); // Two remaining chunks (0 and 2), each probing as 60s. mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(120)); mediaSource.ChunkStarts.ShouldBe([TimeSpan.Zero, TimeSpan.FromSeconds(60)]); + // The dropped minute is still missing from the wall clock, so the timeline must mark it. + mediaSource.GapPositions.ShouldBe([TimeSpan.FromSeconds(60)]); + foreach (var camera in CameraNames.All) { var content = File.ReadAllText(mediaSource.CameraPlaylistPaths[camera]); - content.ShouldContain(clipFiles.GetPath(0, camera).Replace('\\', '/')); - content.ShouldNotContain(clipFiles.GetPath(1, camera).Replace('\\', '/')); - content.ShouldContain(clipFiles.GetPath(2, camera).Replace('\\', '/')); + content.ShouldContain(clipFiles.GetFfconcatPath(0, camera)); + content.ShouldNotContain(clipFiles.GetFfconcatPath(1, camera)); + content.ShouldContain(clipFiles.GetFfconcatPath(2, camera)); } } @@ -192,16 +200,31 @@ public void Build_ExcludingMiddleChunk_RemovesItFromEveryCamerasPlaylistAndShrin public void Build_ExcludingChunkZero_StartsTimelineAtNextChunk() { using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip, new HashSet { 0 }); + var mediaSource = Build(clipFiles.Clip, new HashSet { 0 }); mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(60)); mediaSource.ChunkStarts.ShouldBe([TimeSpan.Zero]); var frontContent = File.ReadAllText(mediaSource.CameraPlaylistPaths[CameraNames.Front]); - frontContent.ShouldNotContain(clipFiles.GetPath(0, CameraNames.Front).Replace('\\', '/')); - frontContent.ShouldContain(clipFiles.GetPath(1, CameraNames.Front).Replace('\\', '/')); + frontContent.ShouldNotContain(clipFiles.GetFfconcatPath(0, CameraNames.Front)); + frontContent.ShouldContain(clipFiles.GetFfconcatPath(1, CameraNames.Front)); + } + + [Fact] + public void Build_AllChunksExcluded_ReturnsEmptySourceWithNoPlaylists() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 3); + + var mediaSource = Build(clipFiles.Clip, new HashSet { 0, 1, 2 }); + + mediaSource.Duration.ShouldBe(TimeSpan.Zero); + mediaSource.ChunkStarts.ShouldBeEmpty(); + mediaSource.CameraPlaylistPaths.ShouldBeEmpty(); + + // Auto-exclusions report only what the builder dropped on its own; the caller already knows what it excluded, and folding the two together would double-count them downstream. + mediaSource.AutoExcludedChunkIndices.ShouldBeEmpty(); + mediaSource.ToMediaTime(clipFiles.Clip.Chunks[0].Timestamp).ShouldBeNull(); } [Fact] @@ -218,23 +241,21 @@ public void Build_ExclusionAndCameraGapTruncation_InteractOnRemainingSequence() chunks[2] = chunkWithoutLeft; var clip = new CamClip(clipFiles.Clip.FullPath, clipFiles.Clip.Name, clipFiles.Clip.Timestamp, chunks, camEvent: null); - var builder = new FfconcatMediaSourceBuilder(); - // Exclude chunk 1 (corrupt). Remaining sequence for left-repeater is [0, 2(gap), 3]; // the gap at chunk 2 must still truncate the left-repeater playlist after chunk 0. - var mediaSource = builder.Build(clip, new HashSet { 1 }); + var mediaSource = Build(clip, new HashSet { 1 }); mediaSource.ChunkStarts.Count.ShouldBe(3); // chunks 0, 2, 3 remain in the shared timeline. var leftContent = File.ReadAllText(mediaSource.CameraPlaylistPaths[CameraNames.LeftRepeater]); - leftContent.ShouldContain(clipFiles.GetPath(0, CameraNames.LeftRepeater).Replace('\\', '/')); - leftContent.ShouldNotContain(clipFiles.GetPath(3, CameraNames.LeftRepeater).Replace('\\', '/')); + leftContent.ShouldContain(clipFiles.GetFfconcatPath(0, CameraNames.LeftRepeater)); + leftContent.ShouldNotContain(clipFiles.GetFfconcatPath(3, CameraNames.LeftRepeater)); var frontContent = File.ReadAllText(mediaSource.CameraPlaylistPaths[CameraNames.Front]); - frontContent.ShouldNotContain(clipFiles.GetPath(1, CameraNames.Front).Replace('\\', '/')); - frontContent.ShouldContain(clipFiles.GetPath(0, CameraNames.Front).Replace('\\', '/')); - frontContent.ShouldContain(clipFiles.GetPath(2, CameraNames.Front).Replace('\\', '/')); - frontContent.ShouldContain(clipFiles.GetPath(3, CameraNames.Front).Replace('\\', '/')); + frontContent.ShouldNotContain(clipFiles.GetFfconcatPath(1, CameraNames.Front)); + frontContent.ShouldContain(clipFiles.GetFfconcatPath(0, CameraNames.Front)); + frontContent.ShouldContain(clipFiles.GetFfconcatPath(2, CameraNames.Front)); + frontContent.ShouldContain(clipFiles.GetFfconcatPath(3, CameraNames.Front)); } [Fact] @@ -246,22 +267,57 @@ public void Build_FrontFileUnprobeable_AutoExcludesChunkForAllCameras() // the duration probe fails and the whole chunk must be dropped up front. File.WriteAllBytes(clipFiles.GetPath(1, CameraNames.Front), TestMp4.GarbageBytes); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip); + var mediaSource = Build(clipFiles.Clip); mediaSource.AutoExcludedChunkIndices.ShouldBe([1]); mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(120)); mediaSource.ChunkStarts.ShouldBe([TimeSpan.Zero, TimeSpan.FromSeconds(60)]); + // The dropped minute leaves the same wall-clock hole as a chunk missing from disk. + mediaSource.GapPositions.ShouldBe([TimeSpan.FromSeconds(60)]); + foreach (var camera in CameraNames.All) { var content = File.ReadAllText(mediaSource.CameraPlaylistPaths[camera]); - content.ShouldContain(clipFiles.GetPath(0, camera).Replace('\\', '/')); - content.ShouldNotContain(clipFiles.GetPath(1, camera).Replace('\\', '/')); - content.ShouldContain(clipFiles.GetPath(2, camera).Replace('\\', '/')); + content.ShouldContain(clipFiles.GetFfconcatPath(0, camera)); + content.ShouldNotContain(clipFiles.GetFfconcatPath(1, camera)); + content.ShouldContain(clipFiles.GetFfconcatPath(2, camera)); } } + [Fact] + public void Build_FrontFileProbesToZeroDuration_AutoExcludesChunk() + { + // A chunk whose moov survived but reports zero length (an interrupted write) parses cleanly, so only the positive-duration check keeps it out. + // Left in, it would occupy no media time while still claiming a slot, putting two chunks at the same position. + using var clipFiles = TestClipFiles.Create(chunkCount: 3); + File.WriteAllBytes(clipFiles.GetPath(1, CameraNames.Front), TestMp4.BuildWithDuration(TimeSpan.Zero)); + + var mediaSource = Build(clipFiles.Clip); + + mediaSource.AutoExcludedChunkIndices.ShouldBe([1]); + mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(120)); + } + + [Fact] + public void Build_ChunkWithNoFrontFile_IsAutoExcluded() + { + // The front camera drives the shared timeline, so a chunk that never had a front file is dropped before any probe -- there is nothing to measure its length against. + using var clipFiles = TestClipFiles.Create(chunkCount: 3); + File.Delete(clipFiles.GetPath(1, CameraNames.Front)); + var chunkWithoutFront = new CamChunk( + clipFiles.Clip.Chunks[1].Timestamp, + clipFiles.Clip.Chunks[1].Files.Values.Where(f => f.Camera != CameraNames.Front)); + var chunks = clipFiles.Clip.Chunks.ToList(); + chunks[1] = chunkWithoutFront; + var clip = new CamClip(clipFiles.Clip.FullPath, clipFiles.Clip.Name, clipFiles.Clip.Timestamp, chunks, camEvent: null); + + var mediaSource = Build(clip); + + mediaSource.AutoExcludedChunkIndices.ShouldBe([1]); + mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(120)); + } + [Fact] public void Build_SideFileUnprobeable_TruncatesOnlyThatCamera() { @@ -271,22 +327,66 @@ public void Build_SideFileUnprobeable_TruncatesOnlyThatCamera() // must be unaffected, while the back playlist truncates at the unreadable file. File.WriteAllBytes(clipFiles.GetPath(1, CameraNames.Back), TestMp4.GarbageBytes); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip); + var mediaSource = Build(clipFiles.Clip); mediaSource.AutoExcludedChunkIndices.ShouldBeEmpty(); mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(180)); mediaSource.ChunkStarts.Count.ShouldBe(3); var backContent = File.ReadAllText(mediaSource.CameraPlaylistPaths[CameraNames.Back]); - backContent.ShouldContain(clipFiles.GetPath(0, CameraNames.Back).Replace('\\', '/')); - backContent.ShouldNotContain(clipFiles.GetPath(1, CameraNames.Back).Replace('\\', '/')); - backContent.ShouldNotContain(clipFiles.GetPath(2, CameraNames.Back).Replace('\\', '/')); + backContent.ShouldContain(clipFiles.GetFfconcatPath(0, CameraNames.Back)); + backContent.ShouldNotContain(clipFiles.GetFfconcatPath(1, CameraNames.Back)); + backContent.ShouldNotContain(clipFiles.GetFfconcatPath(2, CameraNames.Back)); var frontContent = File.ReadAllText(mediaSource.CameraPlaylistPaths[CameraNames.Front]); - frontContent.ShouldContain(clipFiles.GetPath(0, CameraNames.Front).Replace('\\', '/')); - frontContent.ShouldContain(clipFiles.GetPath(1, CameraNames.Front).Replace('\\', '/')); - frontContent.ShouldContain(clipFiles.GetPath(2, CameraNames.Front).Replace('\\', '/')); + frontContent.ShouldContain(clipFiles.GetFfconcatPath(0, CameraNames.Front)); + frontContent.ShouldContain(clipFiles.GetFfconcatPath(1, CameraNames.Front)); + frontContent.ShouldContain(clipFiles.GetFfconcatPath(2, CameraNames.Front)); + } + + // --- Chunks whose probed durations differ from their nominal one-minute spacing --- + + [Fact] + public void Build_HeterogeneousChunkDurations_AccumulatesChunkStarts() + { + // Real chunks are not all a tidy 60s -- the last one before an ignition-off is short -- so each chunk must start where the previous one actually ended, not at a nominal multiple. + using var clipFiles = TestClipFiles.Create( + chunkCount: 3, + chunkDurations: [TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(45), TimeSpan.FromSeconds(30)]); + + var mediaSource = Build(clipFiles.Clip); + + mediaSource.ChunkStarts.ShouldBe([TimeSpan.Zero, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(105)]); + mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(135)); + } + + [Fact] + public void Build_ShortChunkFollowedByNextNominalChunk_ProducesAGap() + { + // Chunk 0 stops recording 45s in, yet chunk 1 still begins on the minute: 15s of wall clock has no footage at all. + // That is a real discontinuity even though both chunks are present. + using var clipFiles = TestClipFiles.Create( + chunkCount: 2, + chunkDurations: [TimeSpan.FromSeconds(45), TimeSpan.FromSeconds(60)]); + + var mediaSource = Build(clipFiles.Clip); + + mediaSource.GapPositions.ShouldBe([TimeSpan.FromSeconds(45)]); + } + + [Fact] + public void ToMediaTime_AfterAShortChunk_UsesProbedNotNominalOffsets() + { + using var clipFiles = TestClipFiles.Create( + chunkCount: 2, + chunkDurations: [TimeSpan.FromSeconds(45), TimeSpan.FromSeconds(60)]); + + var mediaSource = Build(clipFiles.Clip); + + // Chunk 1 begins 60s after chunk 0 on the wall clock but only 45s into the media, so an event 10s into it sits at 55s; seeking to the nominal 70s would overshoot the moment. + var instant = clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(10); + + mediaSource.ToMediaTime(instant).ShouldBe(TimeSpan.FromSeconds(55)); } // --- Gap-aware timeline: GapPositions and ToMediaTime --- @@ -295,9 +395,8 @@ public void Build_SideFileUnprobeable_TruncatesOnlyThatCamera() public void Build_ContiguousClip_HasNoGapPositions() { using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip); + var mediaSource = Build(clipFiles.Clip); mediaSource.GapPositions.ShouldBeEmpty(); } @@ -328,8 +427,7 @@ public void Build_MissingMiddleChunk_ProducesOneGapAtTheRightMediaTime() [clipFiles.Clip.Chunks[0], laterChunk], camEvent: null); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clip); + var mediaSource = Build(clip); // Chunk 0 is 60s of media, starting at media time 0; the second included chunk starts // right after it at media time 60s, regardless of the 3-minute wall-clock jump. @@ -337,39 +435,11 @@ public void Build_MissingMiddleChunk_ProducesOneGapAtTheRightMediaTime() mediaSource.GapPositions.ShouldBe([TimeSpan.FromSeconds(60)]); } - [Fact] - public void Build_AutoExcludedChunk_ProducesAGap() - { - using var clipFiles = TestClipFiles.Create(chunkCount: 3); - - // Corrupting chunk 1's front file makes the builder auto-exclude it, which should leave - // the same kind of wall-clock gap as a chunk that was simply missing from disk. - File.WriteAllBytes(clipFiles.GetPath(1, CameraNames.Front), TestMp4.GarbageBytes); - - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip); - - mediaSource.AutoExcludedChunkIndices.ShouldBe([1]); - mediaSource.GapPositions.ShouldBe([TimeSpan.FromSeconds(60)]); - } - - [Fact] - public void Build_CallerExcludedMiddleChunk_ProducesAGap() - { - using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var builder = new FfconcatMediaSourceBuilder(); - - var mediaSource = builder.Build(clipFiles.Clip, new HashSet { 1 }); - - mediaSource.GapPositions.ShouldBe([TimeSpan.FromSeconds(60)]); - } - [Fact] public void ToMediaTime_InstantInsideFirstChunk_MapsToOffsetWithinIt() { using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip); + var mediaSource = Build(clipFiles.Clip); var instant = clipFiles.Clip.Chunks[0].Timestamp.AddSeconds(15); @@ -384,8 +454,7 @@ public void ToMediaTime_InstantInsideChunkAfterAGap_MapsToThatChunksMediaOffset( var chunks = new List { clipFiles.Clip.Chunks[0], clipFiles.Clip.Chunks[2] }; var clip = new CamClip(clipFiles.Clip.FullPath, clipFiles.Clip.Name, clipFiles.Clip.Timestamp, chunks, camEvent: null); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clip); + var mediaSource = Build(clip); // Chunk 2's wall-clock timestamp is +120s; it lands at media time 60s (right after chunk 0). var instant = clipFiles.Clip.Chunks[2].Timestamp.AddSeconds(10); @@ -401,8 +470,7 @@ public void ToMediaTime_InstantInsideTheGap_SnapsForwardToNextChunkStart() var chunks = new List { clipFiles.Clip.Chunks[0], clipFiles.Clip.Chunks[2] }; var clip = new CamClip(clipFiles.Clip.FullPath, clipFiles.Clip.Name, clipFiles.Clip.Timestamp, chunks, camEvent: null); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clip); + var mediaSource = Build(clip); // An instant that falls between chunk 0's probed end (+60s) and chunk 2's timestamp // (+120s) has no media time of its own; it snaps forward to where footage resumes. @@ -411,18 +479,6 @@ public void ToMediaTime_InstantInsideTheGap_SnapsForwardToNextChunkStart() mediaSource.ToMediaTime(instantInsideGap).ShouldBe(TimeSpan.FromSeconds(60)); } - [Fact] - public void ToMediaTime_InstantBeforeClipStart_ReturnsNull() - { - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip); - - var instant = clipFiles.Clip.Chunks[0].Timestamp.AddSeconds(-1); - - mediaSource.ToMediaTime(instant).ShouldBeNull(); - } - [Fact] public void ToMediaTime_InstantInsideExcludedLeadingChunk_SnapsForwardToMediaTimeZero() { @@ -430,8 +486,7 @@ public void ToMediaTime_InstantInsideExcludedLeadingChunk_SnapsForwardToMediaTim // that fired during chunk 0's window sits in a LEADING gap and must snap forward to where // footage resumes -- media time zero -- just like an instant inside a mid-clip gap. using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip, new HashSet { 0 }); + var mediaSource = Build(clipFiles.Clip, new HashSet { 0 }); var instantInsideExcludedChunk = clipFiles.Clip.Chunks[0].Timestamp.AddSeconds(30); @@ -444,8 +499,7 @@ public void ToMediaTime_InstantBeforeClipStart_StaysNullEvenWithExcludedLeadingC // Clock skew: earlier than the clip ever recorded stays unmapped -- the leading-gap snap // only covers instants at or after the clip's original start. using var clipFiles = TestClipFiles.Create(chunkCount: 3); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip, new HashSet { 0 }); + var mediaSource = Build(clipFiles.Clip, new HashSet { 0 }); var instant = clipFiles.Clip.Chunks[0].Timestamp.AddSeconds(-1); @@ -456,11 +510,30 @@ public void ToMediaTime_InstantBeforeClipStart_StaysNullEvenWithExcludedLeadingC public void ToMediaTime_InstantAfterClipEnd_ReturnsNull() { using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var builder = new FfconcatMediaSourceBuilder(); - var mediaSource = builder.Build(clipFiles.Clip); + var mediaSource = Build(clipFiles.Clip); var instant = clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(61); mediaSource.ToMediaTime(instant).ShouldBeNull(); } + + /// + /// Builds through the real builder while recording the playlists it wrote, so can remove them. + /// The builder keys playlists by a hash of the clip folder, and every fixture clip lives under a fresh GUID folder, so each test would otherwise leave a permanent, never-reused file in the shared %TEMP% playlist directory. + /// + private ClipMediaSource Build(CamClip clip, IReadOnlySet excluded = null) + { + var mediaSource = new FfconcatMediaSourceBuilder().Build(clip, excluded); + _writtenPlaylists.AddRange(mediaSource.CameraPlaylistPaths.Values); + return mediaSource; + } + + public void Dispose() + { + // Only the paths this fixture produced: the playlist directory is shared with the running app, so wiping it would delete playlists a live player is reading from. + foreach (var path in _writtenPlaylists) + { + File.Delete(path); + } + } } diff --git a/SentryDeck.Tests/Fixtures/FakeClipMediaSourceBuilder.cs b/SentryDeck.Tests/Fixtures/FakeClipMediaSourceBuilder.cs index 6003cc5..fceafb0 100644 --- a/SentryDeck.Tests/Fixtures/FakeClipMediaSourceBuilder.cs +++ b/SentryDeck.Tests/Fixtures/FakeClipMediaSourceBuilder.cs @@ -10,31 +10,51 @@ internal sealed class FakeClipMediaSourceBuilder : IClipMediaSourceBuilder { public static readonly TimeSpan ChunkDuration = TimeSpan.FromSeconds(60); - // Build() runs on Task.Run threads while tests poll the bookkeeping below from the test - // thread, so it needs its own lock rather than relying on single-threaded access. + // Build() runs on Task.Run threads while tests poll the bookkeeping below from the test thread, so every piece of it stays private behind this lock: a test indexing a live List while a background Build() appends to it is a race that only shows up as a rare CI failure. private readonly Lock _recordingLock = new(); + private readonly List> _exclusionsPerBuild = []; + private readonly List _clipsPerBuild = []; + private readonly HashSet _autoExcludeChunkIndices = []; - public int BuildCount { get; private set; } + public int BuildCount + { + get + { + lock (_recordingLock) + { + return _clipsPerBuild.Count; + } + } + } /// - /// The exclusion set passed to each call, in call order, so tests can - /// assert on which chunks were excluded and how that changed over successive rebuilds. + /// A snapshot of the exclusion set passed to each call, in call order, so tests can assert on which chunks were excluded and how that changed over successive rebuilds. + /// Snapshot once and assert against that copy rather than calling this per assertion, so a build that lands mid-assertion can't make the claims disagree. /// - public List> ExclusionsPerBuild { get; } = []; + public IReadOnlyList> Exclusions() + { + lock (_recordingLock) + { + return [.. _exclusionsPerBuild]; + } + } /// - /// Every clip passed to , in call order (parallel to - /// ), so tests can assert how many times a specific clip was - /// built without caring about the total build count across other clips, and can look up that - /// clip's most recent exclusion set. + /// A snapshot of every clip passed to , in call order and parallel to . /// - public List ClipsPerBuild { get; } = []; + public IReadOnlyList Clips() + { + lock (_recordingLock) + { + return [.. _clipsPerBuild]; + } + } public int BuildCountFor(CamClip clip) { lock (_recordingLock) { - return ClipsPerBuild.Count(builtClip => builtClip == clip); + return _clipsPerBuild.Count(builtClip => builtClip == clip); } } @@ -46,17 +66,23 @@ public IReadOnlySet LastExclusionsFor(CamClip clip) { lock (_recordingLock) { - var index = ClipsPerBuild.LastIndexOf(clip); - return index < 0 ? null : ExclusionsPerBuild[index]; + var index = _clipsPerBuild.LastIndexOf(clip); + return index < 0 ? null : _exclusionsPerBuild[index]; } } /// - /// Original chunk indices this fake drops on its own, mirroring the real builder's - /// auto-exclusion of chunks whose front file is unreadable. Reported via - /// unless already caller-excluded. + /// Marks an original chunk index this fake drops on its own, mirroring the real builder's auto-exclusion of chunks whose front file is unreadable. + /// Reported via unless already caller-excluded. + /// Tests call this from the test thread mid-clip (a file going bad during playback), so it goes through the same lock as the rest of the bookkeeping. /// - public HashSet AutoExcludeChunkIndices { get; } = []; + public void AutoExcludeChunk(int chunkIndex) + { + lock (_recordingLock) + { + _autoExcludeChunkIndices.Add(chunkIndex); + } + } public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndices = null) { @@ -64,21 +90,22 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice // exclusion set, so recording the reference would retroactively rewrite earlier entries. var exclusionsSnapshot = excludedChunkIndices is null ? new HashSet() : new HashSet(excludedChunkIndices); + HashSet autoExcluded; lock (_recordingLock) { - BuildCount++; - ClipsPerBuild.Add(clip); - ExclusionsPerBuild.Add(exclusionsSnapshot); + _clipsPerBuild.Add(clip); + _exclusionsPerBuild.Add(exclusionsSnapshot); + autoExcluded = [.. _autoExcludeChunkIndices]; } var autoExcludedIndices = Enumerable.Range(0, clip.Chunks.Count) - .Where(index => AutoExcludeChunkIndices.Contains(index) + .Where(index => autoExcluded.Contains(index) && (excludedChunkIndices is null || !excludedChunkIndices.Contains(index))) .ToList(); var includedIndices = Enumerable.Range(0, clip.Chunks.Count) .Where(index => (excludedChunkIndices is null || !excludedChunkIndices.Contains(index)) - && !AutoExcludeChunkIndices.Contains(index)) + && !autoExcluded.Contains(index)) .ToList(); var chunkStarts = Enumerable.Range(0, includedIndices.Count) diff --git a/SentryDeck.Tests/Fixtures/TestClipFactory.cs b/SentryDeck.Tests/Fixtures/TestClipFactory.cs index 9c19423..371e3df 100644 --- a/SentryDeck.Tests/Fixtures/TestClipFactory.cs +++ b/SentryDeck.Tests/Fixtures/TestClipFactory.cs @@ -22,11 +22,24 @@ public string GetPath(int chunkIndex, string camera) return Path.Combine(RootPath, $"{timestamp:yyyy-MM-dd_HH-mm-ss}-{camera}.mp4"); } + /// + /// The same path as it appears inside a written ffconcat playlist, which normalizes separators. + /// + public string GetFfconcatPath(int chunkIndex, string camera) + { + return GetPath(chunkIndex, camera).Replace('\\', '/'); + } + /// Which camera suffixes to write per chunk (defaults to all known cameras). + /// + /// Per-chunk probed duration (defaults to a uniform 60s). + /// Chunk timestamps stay one minute apart no matter what is passed here: that divergence is the point, since a uniform fixture makes probed duration and nominal spacing indistinguishable in every timeline calculation. + /// public static TestClipFiles Create( int chunkCount, IReadOnlySet omitCamerasFromChunkZero = null, - IReadOnlyList cameras = null) + IReadOnlyList cameras = null, + IReadOnlyList chunkDurations = null) { var allCameras = cameras ?? CameraNames.All; var root = Path.Combine(Path.GetTempPath(), $"SentryDeckTests-{Guid.NewGuid():N}"); @@ -37,6 +50,7 @@ public static TestClipFiles Create( for (var i = 0; i < chunkCount; i++) { var chunkTimestamp = FirstTimestamp.AddMinutes(i); + var chunkDuration = chunkDurations?[i] ?? TimeSpan.FromSeconds(60); var cameraSet = i == 0 && omitCamerasFromChunkZero is not null ? allCameras.Where(camera => !omitCamerasFromChunkZero.Contains(camera)) : allCameras; @@ -44,9 +58,8 @@ public static TestClipFiles Create( { var path = Path.Combine(root, $"{chunkTimestamp:yyyy-MM-dd_HH-mm-ss}-{camera}.mp4"); - // Minimal valid mp4 bytes (60s moov/mvhd) so the file probes as healthy; tests - // that need a corrupt file overwrite it with TestMp4.GarbageBytes. - File.WriteAllBytes(path, TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))); + // Minimal valid mp4 bytes (a moov/mvhd encoding this chunk's duration) so the file probes as healthy; tests that need a corrupt file overwrite it with TestMp4.GarbageBytes. + File.WriteAllBytes(path, TestMp4.BuildWithDuration(chunkDuration)); return new CamFile(path, chunkTimestamp, camera); }); diff --git a/SentryDeck.Tests/Fixtures/TestMp4.cs b/SentryDeck.Tests/Fixtures/TestMp4.cs index 0053f06..ea995f1 100644 --- a/SentryDeck.Tests/Fixtures/TestMp4.cs +++ b/SentryDeck.Tests/Fixtures/TestMp4.cs @@ -50,6 +50,45 @@ public static byte[] Build(int version, uint timescale, ulong duration) return [.. ftypBox, .. moovBox]; } + /// + /// The first bytes of an otherwise valid 60s mp4, so box headers promise more data than the file holds -- the shape a recording cut off mid-write leaves. + /// + public static byte[] BuildTruncated(int keepBytes) + { + return BuildWithDuration(TimeSpan.FromSeconds(60))[..keepBytes]; + } + + /// + /// One box of the given type wrapping a valid 60s mvhd, with a hand-picked size field that need not describe the body. + /// can only emit well-formed sizes, so this is the only way to reach the "size 0 means to end of file" and undersized-header branches. + /// + public static byte[] BuildWithBoxSize(string type, uint sizeField) + { + using var stream = new MemoryStream(); + WriteUInt32BigEndian(stream, sizeField); + stream.Write(System.Text.Encoding.ASCII.GetBytes(type)); + stream.Write(BuildMvhdBox()); + return stream.ToArray(); + } + + /// + /// One box of the given type wrapping a valid 60s mvhd, using the 64-bit "largesize" form (a size field of 1 followed by an 8-byte length covering the whole box, header included). + /// + public static byte[] BuildWithLargeSize(string type, ulong largeSize) + { + using var stream = new MemoryStream(); + WriteUInt32BigEndian(stream, 1); + stream.Write(System.Text.Encoding.ASCII.GetBytes(type)); + WriteUInt64BigEndian(stream, largeSize); + stream.Write(BuildMvhdBox()); + return stream.ToArray(); + } + + private static byte[] BuildMvhdBox() + { + return BuildBox("mvhd", BuildMvhdBody(version: 0, timescale: 1000, duration: 60_000)); + } + private static byte[] BuildMvhdBody(int version, uint timescale, ulong duration) { using var stream = new MemoryStream(); diff --git a/SentryDeck.Tests/MainWindowViewModelTests.cs b/SentryDeck.Tests/MainWindowViewModelTests.cs index 7e22236..dc452e4 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.cs @@ -46,6 +46,15 @@ private static CamClip ClipWithCameras(params string[] cameras) return new CamClip(@"C:\clips", "Camera Clip", start, [new CamChunk(start, files)], camEvent: null); } + // Like ClipWithCameras, plus event metadata naming the Tesla camera id that triggered the recording. + private static CamClip ClipWithCamerasAndEventCamera(int eventCamera, params string[] cameras) + { + var start = new DateTime(2025, 1, 1, 12, 0, 0); + var files = cameras.Select(camera => new CamFile($@"C:\clips\2025-01-01_12-00-00-{camera}.mp4", start, camera)); + var camEvent = new CamEvent { Reason = "user_interaction_honk", Timestamp = start, Camera = eventCamera }; + return new CamClip(@"C:\clips", "Event Camera Clip", start, [new CamChunk(start, files)], camEvent); + } + private static readonly string[] SixCameras = [ CameraNames.Front, @@ -572,6 +581,29 @@ public void ClearFilter_ResetsFilterTextAndFlag() vm.HasFilterText.ShouldBeFalse(); } + [Fact] + public async Task TypingInSearch_DoesNotRebindTheListPerKeystroke() + { + var clips = TestClips.Create(3); + var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); + await vm.LoadClipsAsync(["root"]); + + var changed = new List(); + vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + vm.FilterText = "C"; + vm.FilterText = "Cl"; + vm.FilterText = "Cli"; + + // The list rebind is deferred to a debounce timer (which never ticks in tests) so the ListBox doesn't rebuild and replay its fade on every keystroke. + // Wiring FilteredClips/ClipCount straight onto FilterText would look harmless and quietly undo that. + changed.ShouldNotContain(nameof(MainWindowViewModel.FilteredClips)); + changed.ShouldNotContain(nameof(MainWindowViewModel.ClipCount)); + + // The clear affordance is the one part that stays immediate. + changed.ShouldContain(nameof(MainWindowViewModel.HasFilterText)); + } + [Fact] public void ShowOnMap_DisabledWithoutCoordinates() { @@ -583,6 +615,76 @@ public void ShowOnMap_DisabledWithoutCoordinates() vm.ShowOnMapCommand.CanExecute(withLocation).ShouldBeTrue(); } + // --- Scanning: what the sidebar and the overlay show when there is nothing to scan, or a root can't be read. + // The overlay is the whole UI in these states, so its wording and its dismissibility are the behavior. --- + + [Fact] + public async Task LoadClips_WithNoRoots_ShowsDismissibleEmptyState() + { + var vm = CreateViewModel(); + + await vm.LoadClipsAsync([]); + + // First run with no USB drive attached: a friendly prompt the user can dismiss to reach the rest of the app, not a scary error they're stuck behind. + vm.ErrorTitle.ShouldBe("No dashcam footage yet"); + vm.IsEmptyState.ShouldBeTrue(); + vm.CanDismissError.ShouldBeTrue(); + vm.ShowErrorOverlay.ShouldBeTrue(); + vm.ShowStatusOverlay.ShouldBeTrue(); + vm.ClipCount.ShouldBe(0); + } + + [Fact] + public async Task LoadClips_AccessDenied_ShowsAccessDeniedError() + { + var vm = new MainWindowViewModel(() => null!, clipLoader: _ => throw new UnauthorizedAccessException("denied")); + + await vm.LoadClipsAsync([@"D:\TeslaCam"]); + + // A permissions problem gets its own title and remedy; it isn't the empty state. + vm.ErrorTitle.ShouldBe("Access Denied"); + vm.ErrorDetails.ShouldContain(@"D:\TeslaCam"); + vm.ShowErrorOverlay.ShouldBeTrue(); + vm.IsEmptyState.ShouldBeFalse(); + } + + [Fact] + public async Task LoadClips_LoaderThrows_ShowsGenericLoadError() + { + var vm = new MainWindowViewModel(() => null!, clipLoader: _ => throw new IOException("the drive was removed")); + + await vm.LoadClipsAsync([@"E:\TeslaCam"]); + + // Both halves matter for a bug report: which folder failed, and what the failure was. + vm.ErrorTitle.ShouldBe("Error Loading Clips"); + vm.ErrorDetails.ShouldContain(@"E:\TeslaCam"); + vm.ErrorDetails.ShouldContain("the drive was removed"); + } + + [Fact] + public async Task LoadClips_OneRootFails_KeepsClipsFromTheHealthyRoot() + { + var clips = TestClips.Create(2); + var vm = new MainWindowViewModel( + () => null!, + clipLoader: root => + { + if (root == "bad") + { + throw new IOException("the drive was removed"); + } + + return clips; + }); + + await vm.LoadClipsAsync(["bad", "good"]); + + // Scanning is per-root: one unreadable drive reports itself but must not cost the user the library on the drive that is still plugged in. + vm.ClipCount.ShouldBe(2); + vm.ShowErrorOverlay.ShouldBeTrue(); + vm.ErrorTitle.ShouldBe("Error Loading Clips"); + } + // --- Delete to Recycle Bin: the injectable confirm/recycle delegates keep this off the shell --- private static List ClipsWithDistinctPaths(int count) => @@ -700,6 +802,45 @@ public async Task DeleteClip_WhenRecycleFails_ShowsError_AndKeepsClip() vm.FilteredClips.ShouldContain(target); } + // --- Deleting the clip that is actually open: the point of the feature, and the only path that touches the player. + // These drive a real controller, and the recycle runs behind a Task.Run whose continuation lands off the test thread -- hence the uiInvoker seam instead of the dispatcher hop. --- + + [Fact] + public async Task DeleteClip_TheOpenClip_StopsPlaybackBeforeRecycling() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip, uiInvoker: action => action()); + var stopsBeforeDelete = front.StopCount; + var stopsWhenRecycled = -1; + vm.ConfirmDeleteClip = _ => true; + vm.RecycleClipFolder = _ => stopsWhenRecycled = front.StopCount; + vm.SeekPosition = 0.5; + + await vm.DeleteClipCommand.ExecuteAsync(clipFiles.Clip); + + // Windows can't recycle a folder whose files are still locked, so playback must already be stopped when the shell operation runs -- not merely by the time delete returns. + stopsWhenRecycled.ShouldBeGreaterThan(stopsBeforeDelete); + vm.SeekPosition.ShouldBe(0); + } + + [Fact] + public async Task DeleteClip_TheOpenClip_RemovesItFromThePlayerPlaylist() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var clip = clipFiles.Clip; + var (vm, controller, _) = CreateViewModelWithOpenedClip(clip, uiInvoker: action => action()); + vm.ConfirmDeleteClip = _ => true; + vm.RecycleClipFolder = _ => { }; + vm.SelectedClip = clip; // sets NowPlayingClip too (see OnSelectedClipChanged) + + await vm.DeleteClipCommand.ExecuteAsync(clip); + + // Next/Previous walk the controller's playlist, so a deleted clip left behind in it would navigate straight back to a folder that no longer exists. + controller.Playlist.Clips.ShouldNotContain(clip); + vm.NowPlayingClip.ShouldBeNull(); + vm.SelectedClip.ShouldBeNull(); + } + [Fact] public async Task FilteredClips_NoMatch_IsEmpty() { @@ -1007,10 +1148,7 @@ public async Task WhileScrubbing_ControllerPositionDoesNotMoveTheSlider() vm.SeekPosition.ShouldBe(0.25, 0.0001); } - // Synchronous (no async/await in the test body itself -- see CreateViewModelWithOpenedClip's - // comment on why thread affinity matters here). FakeCameraPlayer's SeekAsync/OpenAsync and an - // uncontended SemaphoreSlim all complete synchronously, so blocking on the resulting tasks - // never suspends the thread and keeps every controller property change on it. + // Synchronous (no async/await in the test body itself -- see RunPinnedToTestThread). [Fact] public void DragSequence_IssuesFastSeeks_ReleaseIssuesAccurateSeekAtReleasePosition() { @@ -1035,19 +1173,13 @@ public void DragSequence_IssuesFastSeeks_ReleaseIssuesAccurateSeekAtReleasePosit // Release at 0.75 (45s): EndSeekAsync must issue exactly one ACCURATE seek at the release position. vm.SeekPosition = 0.75; - // Blocking rather than awaiting is deliberate here, not an oversight: this whole test must stay - // pinned to one thread (see CreateViewModelWithOpenedClip's comment), and EndSeekAsync only ever - // awaits FakeCameraPlayer calls and an uncontended SemaphoreSlim, both of which complete - // synchronously -- so this never actually blocks. -#pragma warning disable xUnit1031 - vm.EndSeekAsync().GetAwaiter().GetResult(); -#pragma warning restore xUnit1031 + RunPinnedToTestThread(vm.EndSeekAsync); front.SeekPositions[^1].ShouldBe(TimeSpan.FromSeconds(45)); front.SeekAccurateFlags[^1].ShouldBeTrue(); } - // Synchronous for the same thread-affinity reason as DragSequence above. + // Synchronous for the same thread-affinity reason as DragSequence above (see RunPinnedToTestThread). [Fact] public void StaleEndSeek_AfterANewDragStarted_DoesNotUnlockPositionSync() { @@ -1067,18 +1199,14 @@ public void StaleEndSeek_AfterANewDragStarted_DoesNotUnlockPositionSync() vm.SeekPosition = 0.25; }; -#pragma warning disable xUnit1031 // completes synchronously; see DragSequence's comment - vm.EndSeekAsync().GetAwaiter().GetResult(); -#pragma warning restore xUnit1031 + RunPinnedToTestThread(vm.EndSeekAsync); // A controller position sync arriving during drag #2 must still be ignored. front.RaisePositionChanged(TimeSpan.FromSeconds(50)); vm.SeekPosition.ShouldBe(0.25); // The active gesture still ends normally and re-enables position sync. -#pragma warning disable xUnit1031 - vm.EndSeekAsync().GetAwaiter().GetResult(); -#pragma warning restore xUnit1031 + RunPinnedToTestThread(vm.EndSeekAsync); front.RaisePositionChanged(TimeSpan.FromSeconds(30)); vm.SeekPosition.ShouldBe(0.5, 0.0001); } @@ -1118,23 +1246,23 @@ private static async Task WaitUntilAsync(Func condition) } } - // Opens the clip on the controller to completion BEFORE the view-model subscribes to it, then - // attaches the view-model. Doing it in this order (rather than via CreateViewModelWithController, - // which subscribes up front) avoids the deadlock described on that helper: the real open flow's - // ObservableProperty writes happen on background-thread continuations, and if the view-model were - // already subscribed, its PropertyChanged handler would call Dispatcher.Invoke from that background - // thread with no pumped message loop to service it, hanging the open forever. Once the clip is - // fully open and idle, driving the view-model's own seek APIs from the test thread afterward is safe. - // Synchronous by design (no async/await): the view-model's constructor captures - // Dispatcher.CurrentDispatcher for whatever thread calls it, and RunOnUiThread only stays - // deadlock-free while every later controller property change happens on that exact same - // thread (see the comment on CreateViewModelWithController above). An async method resumes - // its continuation after an await on a thread-pool thread with no guarantee it matches the - // thread that ran the code before the await -- which silently breaks that invariant. Blocking - // on the wait here (instead of awaiting it) keeps everything, including the VM construction - // and every later test action, pinned to the single calling thread. - // A four-camera controller (front + three secondaries) built on the camera-keyed constructor, - // with front as the primary/clock anchor -- mirrors what the view wires up at runtime. + /// + /// Runs a view-model async API to completion without ever leaving the calling thread. + /// The view-model captures Dispatcher.CurrentDispatcher in its constructor, and these tests have no pumped message loop, so its dispatcher hop only stays deadlock-free while every later controller property change arrives on that exact same thread. + /// An await inside a test can resume its continuation on a thread-pool thread with no guarantee it matches the thread that ran the code before it -- silently breaking that invariant -- whereas blocking cannot. + /// And nothing here actually blocks: FakeCameraPlayer's calls and an uncontended SemaphoreSlim all complete synchronously. + /// Flows that genuinely go async (anything behind a Task.Run) must instead take the uiInvoker seam, as the delete-the-open-clip tests do. + /// + internal static void RunPinnedToTestThread(Func action) + { +#pragma warning disable xUnit1031 + action().GetAwaiter().GetResult(); +#pragma warning restore xUnit1031 + } + + /// + /// A four-camera controller (front + three secondaries) built on the camera-keyed constructor, with front as the primary/clock anchor -- mirrors what the view wires up at runtime. + /// private static VideoPlayerController BuildFourCameraController(FakeCameraPlayer front) => new( new Dictionary @@ -1146,10 +1274,19 @@ private static VideoPlayerController BuildFourCameraController(FakeCameraPlayer }, CameraNames.Front); + /// + /// Opens the clip on the controller to completion BEFORE the view-model subscribes to it, then attaches the view-model. + /// Doing it in this order (rather than via CreateViewModelWithController, which subscribes up front) avoids the deadlock described on that helper: the real open flow's ObservableProperty writes happen on background-thread continuations, and if the view-model were already subscribed, its PropertyChanged handler would call Dispatcher.Invoke from that background thread with no pumped message loop to service it, hanging the open forever. + /// Once the clip is fully open and idle, driving the view-model's own seek APIs from the test thread afterward is safe. + /// Synchronous by design (no async/await) for the reason spelled out on : blocking on the wait keeps everything, including the view-model construction and every later test action, pinned to the single calling thread. + /// + /// Replaces the view-model's dispatcher hop. + /// Pass action => action() for flows whose continuations genuinely land off the test thread (e.g. delete, which recycles behind a Task.Run). private static (MainWindowViewModel Vm, VideoPlayerController Controller, FakeCameraPlayer Front) CreateViewModelWithOpenedClip( CamClip clip, IClipExporter clipExporter = null, - Func savePathPicker = null) + Func savePathPicker = null, + Action uiInvoker = null) { var front = new FakeCameraPlayer(); var built = BuildFourCameraController(front); @@ -1162,7 +1299,8 @@ private static (MainWindowViewModel Vm, VideoPlayerController Controller, FakeCa () => built, backgroundYield: () => Task.CompletedTask, clipExporter: clipExporter, - savePathPicker: savePathPicker) + savePathPicker: savePathPicker, + uiInvoker: uiInvoker) { RevealInExplorer = _ => { }, }; @@ -1223,6 +1361,19 @@ public void SelectingClip_TriggersPlaybackLoading() vm.ShowErrorOverlay.ShouldBeFalse(); } + [Fact] + public void SelectingAnEventClip_AutoFocusesTheTriggeringCamera() + { + var vm = CreateViewModelWithController(out _, out _); + + // Camera id 7 is the rear camera. + // As in SelectingClip_TriggersPlaybackLoading, the clip is deliberately not in the controller's playlist, so GoToClipAsync early-returns and the rest of the selection load runs inline on this thread. + vm.SelectedClip = ClipWithCamerasAndEventCamera(eventCamera: 7, SixCameras); + + // Opening an incident on the angle that triggered it is the whole point of the metadata. + vm.SelectedCameraView.ShouldBe(CameraNames.Back); + } + // --- Export selection: in/out marks and the FFmpeg-free export path (FakeClipExporter) --- [Fact] @@ -1427,8 +1578,7 @@ public void MarkSelection_RequiresSeekableMedia() vm.ExportSelectionCommand.CanExecute(null).ShouldBeFalse(); } - // Synchronous/blocking for the same thread-affinity reasons as the drag-sequence test above: - // the fake exporter and save picker complete synchronously, so the export never suspends. + // Synchronous/blocking for the same thread-affinity reason as the drag-sequence test above (see RunPinnedToTestThread): the fake exporter and save picker complete synchronously. [Fact] public void ExportSelection_SendsMediaTimeRangeAndActiveCameraToTheExporter() { @@ -1442,9 +1592,7 @@ public void ExportSelection_SendsMediaTimeRangeAndActiveCameraToTheExporter() vm.SeekPosition = 0.75; vm.MarkSelectionEndCommand.Execute(null); -#pragma warning disable xUnit1031 - vm.ExportSelectionCommand.ExecuteAsync(null).GetAwaiter().GetResult(); -#pragma warning restore xUnit1031 + RunPinnedToTestThread(() => vm.ExportSelectionCommand.ExecuteAsync(null)); var request = exporter.Requests.ShouldHaveSingleItem(); request.Clip.ShouldBe(clipFiles.Clip); @@ -1467,9 +1615,7 @@ public void ExportSelection_SaveDialogCanceled_DoesNotExport() vm.SeekPosition = 0.75; vm.MarkSelectionEndCommand.Execute(null); -#pragma warning disable xUnit1031 - vm.ExportSelectionCommand.ExecuteAsync(null).GetAwaiter().GetResult(); -#pragma warning restore xUnit1031 + RunPinnedToTestThread(() => vm.ExportSelectionCommand.ExecuteAsync(null)); exporter.Requests.ShouldBeEmpty(); vm.ShowErrorOverlay.ShouldBeFalse(); @@ -1487,9 +1633,7 @@ public void ExportSelection_ExporterFailure_ShowsErrorAndResetsBusyState() vm.SeekPosition = 0.75; vm.MarkSelectionEndCommand.Execute(null); -#pragma warning disable xUnit1031 - vm.ExportSelectionCommand.ExecuteAsync(null).GetAwaiter().GetResult(); -#pragma warning restore xUnit1031 + RunPinnedToTestThread(() => vm.ExportSelectionCommand.ExecuteAsync(null)); vm.ShowErrorOverlay.ShouldBeTrue(); vm.ErrorTitle.ShouldBe("Export Failed"); @@ -1667,6 +1811,72 @@ public void ChangingSpeed_FlowsToTheController() controller.PlaybackSpeed.ShouldBe(4.0); } + // --- Keyboard transport: the shortcuts that drive the player itself. + // Each one reaches a real controller, so they run pinned to the test thread (see RunPinnedToTestThread). --- + + [Fact] + public void ArrowKeys_SeekFiveSecondsAndClampAtTheEnds() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); // one 60s chunk + var (vm, controller, front) = CreateViewModelWithOpenedClip(clipFiles.Clip); + + RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Right, ModifierKeys.None)); + front.SeekPositions[^1].ShouldBe(TimeSpan.FromSeconds(5)); + + RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Left, ModifierKeys.None)); + RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Left, ModifierKeys.None)); + + // Nudging back past the start parks on the first frame instead of seeking to a negative time. + front.SeekPositions[^1].ShouldBe(TimeSpan.Zero); + + controller.Position = TimeSpan.FromSeconds(60); // parked at the very end + RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Right, ModifierKeys.None)); + + front.SeekPositions[^1].ShouldBe(TimeSpan.FromSeconds(60)); + } + + [Fact] + public void Space_TogglesPlayPause() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip); + var playsAfterOpen = front.PlayCount; + var pausesAfterOpen = front.PauseCount; + + RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Space, ModifierKeys.None)); + front.PauseCount.ShouldBe(pausesAfterOpen + 1); // the clip was playing after the open + + RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Space, ModifierKeys.None)); + front.PlayCount.ShouldBe(playsAfterOpen + 1); + } + + [Fact] + public void CommaAndPeriod_StepFrames_OnlyWhenSeekable() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip); + + RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.OemPeriod, ModifierKeys.None)); + RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.OemComma, ModifierKeys.None)); + + front.StepLog.ShouldBe(["forward", "backward"]); + } + + [Fact] + public void StopCommand_ClearsNowPlayingClip() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip); + var stopsAfterOpen = front.StopCount; + vm.SelectedClip = clipFiles.Clip; // sets NowPlayingClip too (see OnSelectedClipChanged) + + RunPinnedToTestThread(() => vm.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(); + front.StopCount.ShouldBeGreaterThan(stopsAfterOpen); + } + [Fact] public async Task DeselectingWhileSelectionLoadIsYielding_ClearsTheLoadingOverlay() { @@ -1693,6 +1903,32 @@ public async Task DeselectingWhileSelectionLoadIsYielding_ClearsTheLoadingOverla vm.ShowStatusOverlay.ShouldBeTrue(); // the idle empty state, not a permanent spinner } + [Fact] + public async Task SupersededSelection_DoesNotClearTheNewerLoadsLoadingState() + { + var front = new FakeCameraPlayer(); + var built = BuildFourCameraController(front); + + // Hold both selection loads at their pre-open background yield, so the second one lands while the first is still suspended and supersedes it. + var yieldGate = new TaskCompletionSource(); + var vm = new MainWindowViewModel(() => built, backgroundYield: () => yieldGate.Task); + vm.InitializePlayer(); + + var superseded = ClipWithCameras(SixCameras); + var winner = ClipWithCamerasAndEventCamera(eventCamera: 7, SixCameras); + vm.SelectedClip = superseded; + vm.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 WaitUntilAsync(() => vm.SelectedCameraView == CameraNames.Back); + vm.IsLoading.ShouldBeTrue(); + vm.NowPlayingClip.ShouldBe(winner); + vm.SelectedClip.ShouldBe(winner); + } + [Fact] public void OpenFolderAndRefresh_AreDisabledWhileClipsAreScanning() { @@ -1709,10 +1945,9 @@ public void OpenFolderAndRefresh_AreDisabledWhileClipsAreScanning() vm.RefreshClipsCommand.CanExecute(null).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. + /// + /// A view-model wired to a real controller, subscribed from the calling thread: every controller property change must then arrive on that same thread (see ), so don't add awaits that suspend onto the thread pool (e.g. driving GoToClipAsync to completion). + /// private static MainWindowViewModel CreateViewModelWithController( out VideoPlayerController controller, out FakeCameraPlayer front, diff --git a/SentryDeck.Tests/Mp4DurationReaderTests.cs b/SentryDeck.Tests/Mp4DurationReaderTests.cs index faa0800..4d70dd0 100644 --- a/SentryDeck.Tests/Mp4DurationReaderTests.cs +++ b/SentryDeck.Tests/Mp4DurationReaderTests.cs @@ -81,6 +81,101 @@ public void TryReadDuration_ZeroTimescale_ReturnsNull() } } + [Fact] + public void TryReadDuration_TruncatedAfterMoovHeader_ReturnsNull() + { + // A recording cut off mid-write: the moov header survives and promises a box running to offset 52, but the file stops at 28, so the nested mvhd scan reads straight past the end. + // Half a header must read as "unknown", never as a duration built from whatever bytes remain. + var path = WriteTempFile(TestMp4.BuildTruncated(28)); + + try + { + Mp4DurationReader.TryReadDuration(path).ShouldBeNull(); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void TryReadDuration_MoovWithSizeZero_ReturnsDurationFromEndOfFile() + { + // A size field of 0 legally means "this box runs to the end of the file" -- the form a still-being-written recording carries. + // Treating it as a zero-length box would advance the scan nowhere and lose the duration of a chunk that is perfectly readable. + var path = WriteTempFile(TestMp4.BuildWithBoxSize("moov", sizeField: 0)); + + try + { + var duration = Mp4DurationReader.TryReadDuration(path); + + duration.ShouldNotBeNull(); + duration.Value.ShouldBe(TimeSpan.FromSeconds(60)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void TryReadDuration_LargeSizeBox_ReturnsDuration() + { + // The 64-bit largesize form puts the real length after the type, so the content starts eight bytes later than usual; miscounting that header would look for the mvhd in the wrong place. + var path = WriteTempFile(TestMp4.BuildWithLargeSize("moov", largeSize: 44)); + + try + { + var duration = Mp4DurationReader.TryReadDuration(path); + + duration.ShouldNotBeNull(); + duration.Value.ShouldBe(TimeSpan.FromSeconds(60)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void TryReadDuration_LargeSizeBoxWithAbsurdLength_TerminatesAndReturnsNull() + { + // A 64-bit length is the only size field that can exceed long.MaxValue and come back negative, which would walk the scan backwards forever. + // Corrupt bytes must stop the probe, not hang the clip scan that calls it once per file. + var path = WriteTempFile(TestMp4.BuildWithLargeSize("moov", largeSize: ulong.MaxValue)); + TimeSpan? duration = null; + + try + { + Should.CompleteIn(() => { duration = Mp4DurationReader.TryReadDuration(path); }, TimeSpan.FromSeconds(2)); + + duration.ShouldBeNull(); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void TryReadDuration_ZeroDuration_ReturnsZeroNotNull() + { + // A readable header that happens to say zero is a different fact from an unreadable header: callers decide what to do with an empty chunk, and null would hide that distinction. + var path = WriteTempFile(TestMp4.Build(version: 0, timescale: 1000, duration: 0)); + + try + { + var duration = Mp4DurationReader.TryReadDuration(path); + + duration.ShouldNotBeNull(); + duration.Value.ShouldBe(TimeSpan.Zero); + } + finally + { + File.Delete(path); + } + } + private static string WriteTempFile(byte[] bytes) { var path = Path.Combine(Path.GetTempPath(), $"Mp4DurationReaderTests-{Guid.NewGuid():N}.mp4"); diff --git a/SentryDeck.Tests/PackageManagerTests.cs b/SentryDeck.Tests/PackageManagerTests.cs new file mode 100644 index 0000000..4413166 --- /dev/null +++ b/SentryDeck.Tests/PackageManagerTests.cs @@ -0,0 +1,84 @@ +using System.IO; +using System.IO.Compression; + +namespace SentryDeck.Tests; + +public sealed class PackageManagerTests : IDisposable +{ + private const string ArchiveRoot = "ffmpeg-n8.1-latest-win64-gpl-shared-8.1"; + + private readonly string _root = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), $"SentryDeckTests-{Guid.NewGuid():N}")).FullName; + + public void Dispose() => Directory.Delete(_root, recursive: true); + + // Nested below the scratch root so everything a test writes lands inside the directory Dispose deletes. + private string DestinationBinPath => Path.Combine(_root, "install", "ffmpeg-bin"); + + private string WriteArchive(params string[] entryNames) + { + var zipPath = Path.Combine(_root, $"{Guid.NewGuid():N}.zip"); + using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); + + foreach (var entryName in entryNames) + { + var entry = archive.CreateEntry(entryName); + + // A name ending in "/" is a bare directory entry, which real FFmpeg archives carry. + if (entryName.EndsWith('/')) + { + continue; + } + + using var stream = entry.Open(); + stream.Write("payload"u8); + } + + return zipPath; + } + + [Fact] + public void ExtractFFmpegBin_SkipsEntriesOutsideTheBinPrefix() + { + var zipPath = WriteArchive( + $"{ArchiveRoot}/bin/", + $"{ArchiveRoot}/bin/ffmpeg.exe", + $"{ArchiveRoot}/bin/sub/avcodec.dll", + $"{ArchiveRoot}/doc/README"); + + var extracted = PackageManager.ExtractFFmpegBin(zipPath, DestinationBinPath, ArchiveRoot); + + // Only the two real binaries count. + // The returned count is the only signal the caller logs, so a bare directory entry inflating it would make an unusable install look successful. + extracted.ShouldBe(2); + File.Exists(Path.Combine(DestinationBinPath, "ffmpeg.exe")).ShouldBeTrue(); + Directory.GetFiles(DestinationBinPath, "README", SearchOption.AllDirectories).ShouldBeEmpty(); + } + + [Fact] + public void ExtractFFmpegBin_MatchesThePrefixCaseInsensitively() + { + // The prefix is derived from the download URL, not from the archive, so a casing mismatch between the two would otherwise leave an empty bin folder and no FFmpeg at all. + var zipPath = WriteArchive($"{ArchiveRoot.ToUpperInvariant()}/BIN/ffmpeg.exe"); + + var extracted = PackageManager.ExtractFFmpegBin(zipPath, DestinationBinPath, ArchiveRoot); + + extracted.ShouldBe(1); + File.Exists(Path.Combine(DestinationBinPath, "ffmpeg.exe")).ShouldBeTrue(); + } + + [Fact] + public void ExtractFFmpegBin_ClearsAnExistingDestination() + { + // A leftover DLL from an earlier FFmpeg release must not survive alongside the new ones; Flyleaf loads whatever is in this folder and a mixed set fails at load time. + Directory.CreateDirectory(DestinationBinPath); + var stalePath = Path.Combine(DestinationBinPath, "avcodec-60.dll"); + File.WriteAllText(stalePath, "stale"); + + var zipPath = WriteArchive($"{ArchiveRoot}/bin/ffmpeg.exe"); + PackageManager.ExtractFFmpegBin(zipPath, DestinationBinPath, ArchiveRoot); + + File.Exists(stalePath).ShouldBeFalse(); + File.Exists(Path.Combine(DestinationBinPath, "ffmpeg.exe")).ShouldBeTrue(); + } +} diff --git a/SentryDeck.Tests/SeekScrubCoalescerTests.cs b/SentryDeck.Tests/SeekScrubCoalescerTests.cs index dfbbb5c..406c4ad 100644 --- a/SentryDeck.Tests/SeekScrubCoalescerTests.cs +++ b/SentryDeck.Tests/SeekScrubCoalescerTests.cs @@ -154,6 +154,30 @@ public void Reset_ForgetsLastIssuedValue_SoNextValueAlwaysIssues() issued.ShouldBe([TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(10050)]); } + [Fact] + public async Task FaultingSeek_DoesNotWedgeTheCoalescer() + { + var issued = new List(); + var coalescer = new SeekScrubCoalescer(position => + { + issued.Add(position); + + // The seek this delegates to can genuinely fail -- it routes into the player controller, which throws ObjectDisposedException if the window closes mid-drag -- and nothing between here and the coalescer catches it. + return issued.Count == 1 + ? Task.FromException(new InvalidOperationException("seek failed")) + : Task.CompletedTask; + }); + + coalescer.OnDragValueChanged(TimeSpan.FromSeconds(1)); + + // A fault has to release the in-flight flag too, or every later value is queued forever behind a seek that already finished and scrubbing is dead for the rest of the session. + await WaitUntilAsync(() => !coalescer.IsSeekInFlight); + + coalescer.OnDragValueChanged(TimeSpan.FromSeconds(5)); + + issued.ShouldBe([TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5)]); + } + private static async Task WaitUntilAsync(Func condition) { var deadline = DateTime.UtcNow.AddSeconds(5); diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.cs b/SentryDeck.Tests/VideoPlayerControllerTests.cs index 11a897a..332303c 100644 --- a/SentryDeck.Tests/VideoPlayerControllerTests.cs +++ b/SentryDeck.Tests/VideoPlayerControllerTests.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Runtime.CompilerServices; namespace SentryDeck.Tests; @@ -145,6 +146,29 @@ public async Task SelectingClip_WhenAllFilesAreGarbage_ButNotEncrypted_KeepsTheC controller.IsPlaying.ShouldBeFalse(); } + [Fact] + public async Task PrimaryCameraFailsToOpen_ReportsFailureWithoutPlaying() + { + // The playlist exists and is handed to the player, but the player itself refuses it (a codec/handle failure inside Flyleaf). + // Unlike the missing-footage cases above, the open WAS attempted -- and nothing past it may happen: no play, and no secondary cameras. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var front = new FakeCameraPlayer { OpenResult = false }; + var back = new FakeCameraPlayer(); + using var controller = CreateController(front, back); + + controller.LoadClips([clipFiles.Clip]); + controller.Playlist.MoveTo(0); + + await WaitUntilAsync(() => controller.ErrorMessage is not null); + + controller.ErrorMessage.ShouldBe("Failed to open front camera video."); + front.OpenedPaths.Count.ShouldBe(1); + front.PlayCount.ShouldBe(0); + back.OpenedPaths.ShouldBeEmpty(); + controller.IsPlaying.ShouldBeFalse(); + controller.IsMediaOpen.ShouldBeFalse(); + } + [Fact] public async Task PauseSeekAndStop_ControlOpenPlayers() { @@ -171,6 +195,52 @@ public async Task PauseSeekAndStop_ControlOpenPlayers() controller.IsMediaOpen.ShouldBeFalse(); } + [Fact] + public async Task PlayAsync_OnTheAlreadyOpenClip_ResumesWithoutRebuilding() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 2); + var front = new FakeCameraPlayer(); + var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); + using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); + + controller.LoadClips([clipFiles.Clip]); + controller.Playlist.MoveTo(0); + await WaitUntilClipOpenedAsync(controller, front); + + var openCountBeforeResume = front.OpenedPaths.Count; + await controller.PauseAsync(); + + await controller.PlayAsync(); + + // Resuming the clip that's already open must take the resume fast path: no rebuild, no reopen, just play. + // Rebuilding here would restart the clip from scratch on every pause. + mediaSourceBuilder.BuildCount.ShouldBe(1); + front.OpenedPaths.Count.ShouldBe(openCountBeforeResume); + front.PlayCount.ShouldBe(2); + controller.IsPlaying.ShouldBeTrue(); + } + + [Fact] + public async Task PlayAsync_AtEndOfClip_RestartsFromZero() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var front = new FakeCameraPlayer(); + using var controller = CreateController(front); + + controller.LoadClips([clipFiles.Clip]); + controller.Playlist.MoveTo(0); + await WaitUntilClipOpenedAsync(controller, front); + + // Playback parks at the end of a finished clip rather than advancing, so pressing play there has to mean "replay" -- otherwise the button does nothing at all. + front.RaisePositionChanged(controller.Duration); + + await controller.PlayAsync(); + + front.SeekPositions.ShouldContain(TimeSpan.Zero); + controller.Position.ShouldBe(TimeSpan.Zero); + controller.IsPlaying.ShouldBeTrue(); + } + [Fact] public async Task ScrubSeekAsync_IssuesFastSeeksToOpenPlayers() { @@ -270,7 +340,34 @@ public async Task SecondaryCameraFailure_DoesNotStopPrimaryPlayback() } [Fact] - public async Task FrontMediaEnded_WithNoNextClip_FinishesWithoutReopening() + public async Task SecondaryCameraEnded_DoesNotStopOrRecover() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 3); + var front = new FakeCameraPlayer(); + var back = new FakeCameraPlayer(); + var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); + using var controller = CreateController(front, back, mediaSourceBuilder: mediaSourceBuilder); + + controller.LoadClips([clipFiles.Clip]); + controller.Playlist.MoveTo(0); + await WaitUntilClipOpenedAsync(controller, front); + + var buildCountBeforeEnded = mediaSourceBuilder.BuildCount; + var positionBeforeEnded = controller.Position; + + // A secondary camera with fewer usable chunks runs out of footage long before the front does. + // Only the primary drives the timeline, so this must neither park playback at the end nor start corrupt-chunk recovery -- the front is still mid-clip. + back.RaiseEnded(); + + controller.IsPlaying.ShouldBeTrue(); + controller.Position.ShouldBe(positionBeforeEnded); + mediaSourceBuilder.BuildCount.ShouldBe(buildCountBeforeEnded); + controller.ErrorMessage.ShouldBeNull(); + controller.IsMediaOpen.ShouldBeTrue(); + } + + [Fact] + public async Task FrontMediaEnded_WithinTolerance_CompletesNormallyWithoutRebuilding() { using var clipFiles = TestClipFiles.Create(chunkCount: 2); var front = new FakeCameraPlayer(); @@ -284,7 +381,7 @@ public async Task FrontMediaEnded_WithNoNextClip_FinishesWithoutReopening() var openCountBeforeEnded = front.OpenedPaths.Count; var duration = controller.Duration; - // A genuine end-of-clip: position reaches (within tolerance of) Duration before Ended fires. + // A genuine end-of-clip: position reaches Duration before Ended fires. front.RaisePositionChanged(duration); front.RaiseEnded(); @@ -436,7 +533,110 @@ public async Task GoToClipAsync_ShowsLoadingWhileCurrentClipStops() front.StopGate.SetResult(null); await changeClipTask; - await WaitUntilAsync(() => controller.CurrentClip == secondClipFiles.Clip); + await WaitUntilAsync(() => controller.CurrentClip == secondClipFiles.Clip && !controller.IsLoading); + + controller.CurrentClip.ShouldBe(secondClipFiles.Clip); + controller.IsLoading.ShouldBeFalse(); + } + + [Fact] + public async Task NextAsync_MovesToTheNextClipAndStopsTheCurrentOne() + { + using var firstClipFiles = TestClipFiles.Create(chunkCount: 1); + using var secondClipFiles = TestClipFiles.Create(chunkCount: 1); + var front = new FakeCameraPlayer(); + using var controller = CreateController(front); + + controller.LoadClips([firstClipFiles.Clip, secondClipFiles.Clip]); + controller.Playlist.MoveTo(0); + await WaitUntilClipOpenedAsync(controller, front); + + var stopCountBeforeNext = front.StopCount; + + await controller.NextAsync(); + await WaitUntilClipOpenedAsync(controller, front); + + // The outgoing clip is torn down before the playlist moves, so the new clip never opens on top of players still holding the old one's playlist. + controller.CurrentClip.ShouldBe(secondClipFiles.Clip); + front.StopCount.ShouldBeGreaterThan(stopCountBeforeNext); + } + + [Fact] + public async Task PreviousAsync_MovesToThePreviousClip() + { + using var firstClipFiles = TestClipFiles.Create(chunkCount: 1); + using var secondClipFiles = TestClipFiles.Create(chunkCount: 1); + var front = new FakeCameraPlayer(); + using var controller = CreateController(front); + + controller.LoadClips([firstClipFiles.Clip, secondClipFiles.Clip]); + controller.Playlist.MoveTo(1); + await WaitUntilClipOpenedAsync(controller, front); + + var stopCountBeforePrevious = front.StopCount; + + await controller.PreviousAsync(); + await WaitUntilClipOpenedAsync(controller, front); + + controller.CurrentClip.ShouldBe(firstClipFiles.Clip); + front.StopCount.ShouldBeGreaterThan(stopCountBeforePrevious); + } + + [Fact] + public async Task NextAsync_AtTheEndOfThePlaylist_IsANoOp() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var front = new FakeCameraPlayer(); + var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); + using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); + + controller.LoadClips([clipFiles.Clip]); + controller.Playlist.MoveTo(0); + await WaitUntilClipOpenedAsync(controller, front); + + // The only clip is also the last one. + // Next must bail out before the teardown, not stop what's playing to then go nowhere. + await controller.NextAsync(); + + controller.CanGoNext.ShouldBeFalse(); + controller.CurrentClip.ShouldBe(clipFiles.Clip); + mediaSourceBuilder.BuildCount.ShouldBe(1); + controller.IsMediaOpen.ShouldBeTrue(); + } + + [Fact] + public async Task GoToClipAsync_ByIndex_MovesAndIgnoresOutOfRangeIndices() + { + using var firstClipFiles = TestClipFiles.Create(chunkCount: 1); + using var secondClipFiles = TestClipFiles.Create(chunkCount: 1); + var front = new FakeCameraPlayer(); + using var controller = CreateController(front); + + controller.LoadClips([firstClipFiles.Clip, secondClipFiles.Clip]); + controller.Playlist.MoveTo(0); + await WaitUntilClipOpenedAsync(controller, front); + + await controller.GoToClipAsync(1); + await WaitUntilClipOpenedAsync(controller, front); + + controller.CurrentClip.ShouldBe(secondClipFiles.Clip); + + // An index that no longer addresses a clip (a stale selection from a list that has since shrunk) must leave playback exactly where it is. + // CurrentClip alone doesn't prove that: ClipPlaylist.MoveTo rejects the bad index on its own, so the controller could still have torn playback down on the way there. + // The stop count and the open/loading flags are what pin the controller's own guard. + var stopCountBeforeBadIndex = front.StopCount; + + await controller.GoToClipAsync(-1); + controller.CurrentClip.ShouldBe(secondClipFiles.Clip); + front.StopCount.ShouldBe(stopCountBeforeBadIndex); + controller.IsMediaOpen.ShouldBeTrue(); + controller.IsLoading.ShouldBeFalse(); + + await controller.GoToClipAsync(99); + controller.CurrentClip.ShouldBe(secondClipFiles.Clip); + front.StopCount.ShouldBe(stopCountBeforeBadIndex); + controller.IsMediaOpen.ShouldBeTrue(); + controller.IsLoading.ShouldBeFalse(); } [Fact] @@ -510,9 +710,10 @@ public async Task FrontMediaEnded_FarBeforeDuration_ExcludesBadChunkAndResumesPl await WaitUntilAsync(() => mediaSourceBuilder.BuildCount >= 3); - mediaSourceBuilder.ExclusionsPerBuild.Count.ShouldBe(3); - mediaSourceBuilder.ExclusionsPerBuild[1].ShouldBeEmpty(); - mediaSourceBuilder.ExclusionsPerBuild[2].ShouldBe(new HashSet { 1 }); + var builds = mediaSourceBuilder.Exclusions(); + builds.Count.ShouldBe(3); + builds[1].ShouldBeEmpty(); + builds[2].ShouldBe(new HashSet { 1 }); // Resume position is chunk 1's start in the OLD timeline (60s), since everything before // the bad chunk is unchanged. @@ -529,33 +730,6 @@ public async Task FrontMediaEnded_FarBeforeDuration_ExcludesBadChunkAndResumesPl controller.IsMediaOpen.ShouldBeTrue(); } - [Fact] - public async Task FrontMediaEnded_WithinTolerance_CompletesNormallyWithoutRebuilding() - { - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var front = new FakeCameraPlayer(); - var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - var duration = controller.Duration; - - // End just short of Duration (within the 3s tolerance) -- a normal completion. - front.RaisePositionChanged(duration - TimeSpan.FromMilliseconds(500)); - front.RaiseEnded(); - - await WaitUntilAsync(() => controller.Position == duration && !controller.IsPlaying); - - mediaSourceBuilder.BuildCount.ShouldBe(1); - controller.Position.ShouldBe(duration); - controller.IsPlaying.ShouldBeFalse(); - // The media stays open at the end so the scrubber and frame-step remain usable. - controller.IsMediaOpen.ShouldBeTrue(); - } - [Fact] public async Task FrontMediaEnded_FourthPrematureEndOnSameClip_GivesUpWithErrorMessage() { @@ -596,6 +770,57 @@ public async Task FrontMediaEnded_FourthPrematureEndOnSameClip_GivesUpWithErrorM controller.IsMediaOpen.ShouldBeFalse(); } + [Fact] + public async Task SingleChunkClip_PrematureEnd_GivesUpImmediately() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var front = new FakeCameraPlayer(); + var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); + using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); + + controller.LoadClips([clipFiles.Clip]); + controller.Playlist.MoveTo(0); + await WaitUntilClipOpenedAsync(controller, front); + + // The clip's only chunk is the bad one, so excluding it would leave nothing at all to play. + // Recovery has to give up on the very first probe-clean premature end rather than spend its budget rebuilding an empty timeline. + front.RaisePositionChanged(TimeSpan.Zero); + front.RaiseEnded(); + + await WaitUntilAsync(() => controller.ErrorMessage is not null); + + controller.ErrorMessage.ShouldContain("too many unreadable video files"); + controller.IsMediaOpen.ShouldBeFalse(); + } + + [Fact] + public async Task Recovery_WhenRebuildYieldsNoChunks_GivesUp() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 2); + var front = new FakeCameraPlayer(); + var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); + using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); + + controller.LoadClips([clipFiles.Clip]); + controller.Playlist.MoveTo(0); + await WaitUntilClipOpenedAsync(controller, front); + + // Every chunk becomes unreadable AFTER the clip opened (e.g. the drive was pulled mid-playback), so the recovery rebuild's probe drops all of them and hands back an empty timeline. + // Marking them before the open instead fails the initial open with "No front camera footage found." and never reaches recovery at all. + mediaSourceBuilder.AutoExcludeChunk(0); + mediaSourceBuilder.AutoExcludeChunk(1); + + front.RaisePositionChanged(TimeSpan.Zero); + front.RaiseEnded(); + + await WaitUntilAsync(() => controller.ErrorMessage is not null); + + // One rebuild, then give up: there is nothing left to reopen, so no second build and no reopen attempt on an empty playlist. + mediaSourceBuilder.BuildCount.ShouldBe(2); + controller.ErrorMessage.ShouldContain("too many unreadable video files"); + controller.IsMediaOpen.ShouldBeFalse(); + } + [Fact] public async Task SelectingNewClip_ResetsExclusionsFromPreviousClip() { @@ -685,7 +910,7 @@ public async Task FrontMediaFailed_MidClip_ProbeFindsRealBadChunk_KeepsHealthyCh // Chunk 2's file becomes unreadable AFTER the clip opened (e.g. removed/truncated // mid-playback); the fake's probe will auto-exclude it on the next rebuild. - mediaSourceBuilder.AutoExcludeChunkIndices.Add(2); + mediaSourceBuilder.AutoExcludeChunk(2); // The demuxer reads ahead of the presentation position, so Failed fires while playback // is still inside HEALTHY chunk 1 (90s). Probe-first recovery must find chunk 2 via the @@ -699,8 +924,9 @@ public async Task FrontMediaFailed_MidClip_ProbeFindsRealBadChunk_KeepsHealthyCh // The probe found the culprit, so exactly one rebuild happened and no Build call ever // received a position-derived (healthy-chunk) exclusion. - mediaSourceBuilder.BuildCount.ShouldBe(2); - mediaSourceBuilder.ExclusionsPerBuild[1].ShouldBeEmpty(); + var builds = mediaSourceBuilder.Exclusions(); + builds.Count.ShouldBe(2); + builds[1].ShouldBeEmpty(); // Chunks 0, 1, and 3 remain: healthy chunk 1 was NOT excluded. controller.Duration.ShouldBe(TimeSpan.FromSeconds(180)); @@ -718,7 +944,7 @@ public async Task Recovery_AccountsForBuilderAutoExcludedChunks() using var clipFiles = TestClipFiles.Create(chunkCount: 3); var front = new FakeCameraPlayer(); var mediaSourceBuilder = new FakeClipMediaSourceBuilder(); - mediaSourceBuilder.AutoExcludeChunkIndices.Add(1); + mediaSourceBuilder.AutoExcludeChunk(1); using var controller = CreateController(front, mediaSourceBuilder: mediaSourceBuilder); controller.LoadClips([clipFiles.Clip]); @@ -738,8 +964,9 @@ public async Task Recovery_AccountsForBuilderAutoExcludedChunks() await WaitUntilAsync(() => mediaSourceBuilder.BuildCount >= 3); - mediaSourceBuilder.ExclusionsPerBuild[1].ShouldBe(new HashSet { 1 }); - mediaSourceBuilder.ExclusionsPerBuild[2].ShouldBe(new HashSet { 1, 2 }); + var builds = mediaSourceBuilder.Exclusions(); + builds[1].ShouldBe(new HashSet { 1 }); + builds[2].ShouldBe(new HashSet { 1, 2 }); await WaitUntilAsync(() => front.SeekPositions.Contains(TimeSpan.FromSeconds(60))); @@ -828,33 +1055,19 @@ public async Task SelectingClip_WithEvent_AutoJumpsToShortlyBeforeTheEventMoment controller.IsMediaOpen.ShouldBeTrue(); } - [Fact] - public async Task SelectingClip_WithEventInsideTheLeadIn_OpensAtTopOfBuffer() + [Theory] + // No event metadata (e.g. a clip the car saved without a trigger): nothing to jump to, so the clip opens at 0:00. + [InlineData(null)] + // The event fired 5s into the clip, inside the 10s lead-in window, so there is nothing to jump back to: the clip opens at the start with no seek rather than clamping to a redundant 0. + [InlineData(5.0)] + // An event timestamped before the clip ever recorded (clock skew) has no media time, so the clip opens at the start rather than jumping to a bogus position. + [InlineData(-60.0)] + public async Task SelectingClip_WithNoJumpTarget_OpensAtTopOfBuffer(double? eventOffsetSeconds) { - // The event fired 5s into the clip, inside the 10s lead-in window, so there is nothing to - // jump back to: the clip opens at the start with no seek rather than clamping to a redundant 0. - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var clip = WithEvent(clipFiles.Clip, clipFiles.Clip.Chunks[0].Timestamp.AddSeconds(5)); - - var front = new FakeCameraPlayer(); - using var controller = CreateController(front, mediaSourceBuilder: new FakeClipMediaSourceBuilder()); - - controller.LoadClips([clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - front.SeekPositions.ShouldBeEmpty(); - controller.Position.ShouldBe(TimeSpan.Zero); - controller.IsPlaying.ShouldBeTrue(); - } - - [Fact] - public async Task SelectingClip_WithEventOutsideTheFootage_OpensAtTopOfBuffer() - { - // An event timestamped before the clip ever recorded (clock skew) has no media time, so the - // clip opens at the start rather than jumping to a bogus position. - using var clipFiles = TestClipFiles.Create(chunkCount: 2); - var clip = WithEvent(clipFiles.Clip, clipFiles.Clip.Chunks[0].Timestamp.AddMinutes(-1)); + using var clipFiles = TestClipFiles.Create(chunkCount: 2); // TestClipFiles builds clips with camEvent: null + var clip = eventOffsetSeconds is null + ? clipFiles.Clip + : WithEvent(clipFiles.Clip, clipFiles.Clip.Chunks[0].Timestamp.AddSeconds(eventOffsetSeconds.Value)); var front = new FakeCameraPlayer(); using var controller = CreateController(front, mediaSourceBuilder: new FakeClipMediaSourceBuilder()); @@ -868,24 +1081,6 @@ public async Task SelectingClip_WithEventOutsideTheFootage_OpensAtTopOfBuffer() controller.IsPlaying.ShouldBeTrue(); } - [Fact] - public async Task SelectingClip_WithoutEvent_OpensAtTopOfBuffer() - { - // No event metadata (e.g. a clip the car saved without a trigger): nothing to jump to, so - // the clip opens at 0:00. - using var clipFiles = TestClipFiles.Create(chunkCount: 2); // TestClipFiles builds clips with camEvent: null - var front = new FakeCameraPlayer(); - using var controller = CreateController(front, mediaSourceBuilder: new FakeClipMediaSourceBuilder()); - - controller.LoadClips([clipFiles.Clip]); - controller.Playlist.MoveTo(0); - await WaitUntilClipOpenedAsync(controller, front); - - front.SeekPositions.ShouldBeEmpty(); - controller.Position.ShouldBe(TimeSpan.Zero); - controller.IsPlaying.ShouldBeTrue(); - } - [Fact] public async Task SelectingClip_WithEvent_SecondaryCamerasJoinAtTheJumpedToPosition() { @@ -936,6 +1131,7 @@ public async Task RecoverFromPrematureEnd_OpenDoesNotPlayOrSeekBeforeCallerPosit back.CallLog.Count(call => call == "play").ShouldBe(1); back.CallLog.Count(call => call.StartsWith("seek:")).ShouldBe(1); back.CallLog.ShouldContain("seek:60"); + back.CallLog.ShouldContain("pause"); back.CallLog.IndexOf("pause").ShouldBeLessThan(back.CallLog.IndexOf("play")); controller.IsPlaying.ShouldBeTrue(); @@ -1072,14 +1268,24 @@ private static Task WaitUntilClipOpenedAsync(VideoPlayerController controller, F return WaitUntilAsync(() => front.PlayCount > 0 && controller.IsMediaOpen && !controller.IsLoading); } - private static async Task WaitUntilAsync(Func condition) + /// + /// Polls until the condition holds, then throws naming the predicate that never came true -- this file drives the most timing-sensitive code in the suite, so a hang here has to say what it was waiting for rather than surfacing as a bare cancellation. + /// + private static async Task WaitUntilAsync( + Func condition, + [CallerArgumentExpression(nameof(condition))] string description = null) { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var timeout = TimeSpan.FromSeconds(10); + var deadline = DateTime.UtcNow + timeout; while (!condition()) { - cts.Token.ThrowIfCancellationRequested(); - await Task.Delay(10, cts.Token); + if (DateTime.UtcNow > deadline) + { + throw new TimeoutException($"Condition was not met within {timeout}: {description}"); + } + + await Task.Delay(10); } } } diff --git a/SentryDeck/MainWindowViewModel.cs b/SentryDeck/MainWindowViewModel.cs index 835568c..4aa51f2 100644 --- a/SentryDeck/MainWindowViewModel.cs +++ b/SentryDeck/MainWindowViewModel.cs @@ -45,6 +45,7 @@ public partial class MainWindowViewModel : ObservableObject private readonly Func> _clipLoader; private readonly Func _backgroundYield; private readonly Dispatcher _dispatcher; + private readonly Action _uiInvoker; private readonly DispatcherTimer _filterDebounceTimer; private readonly IClipExporter _clipExporter; private readonly Func _savePathPicker; @@ -71,13 +72,15 @@ public partial class MainWindowViewModel : ObservableObject /// Exports trimmed clip ranges. Defaults to the FFmpeg-backed exporter; overridable for tests. /// Maps a suggested file name to the chosen save path (null = canceled). Defaults to a save dialog; overridable for tests. /// Builds a media source for exporting a clip that isn't currently open. Overridable for tests. + /// Runs an action on the UI thread. Defaults to the dispatcher hop; overridable for tests, which have no pumped message loop to service it. public MainWindowViewModel( Func playerControllerFactory, Func> clipLoader = null, Func backgroundYield = null, IClipExporter clipExporter = null, Func savePathPicker = null, - IClipMediaSourceBuilder exportMediaSourceBuilder = null) + IClipMediaSourceBuilder exportMediaSourceBuilder = null, + Action uiInvoker = null) { _playerControllerFactory = playerControllerFactory; _clipLoader = clipLoader ?? (root => CamStorage.Map(root).Clips); @@ -86,6 +89,7 @@ public MainWindowViewModel( _savePathPicker = savePathPicker ?? PickSavePathWithDialog; _exportMediaSourceBuilder = exportMediaSourceBuilder ?? new FfconcatMediaSourceBuilder(); _dispatcher = Dispatcher.CurrentDispatcher; + _uiInvoker = uiInvoker ?? InvokeOnDispatcher; _scrubCoalescer = new SeekScrubCoalescer(ScrubToAsync); // Coalesces the expensive clip-list regroup/rebind so fast typing in search stays smooth; @@ -1813,7 +1817,9 @@ private void ShowOnMap(CamClip clip) private static bool CanShowOnMap(CamClip clip) => clip?.Event is not null && ClipDisplay.HasLocation(clip.Event); - private void RunOnUiThread(Action action) + private void RunOnUiThread(Action action) => _uiInvoker(action); + + private void InvokeOnDispatcher(Action action) { if (_dispatcher.CheckAccess()) { diff --git a/SentryDeck/Services/PackageManager.cs b/SentryDeck/Services/PackageManager.cs index 610da77..ffa8b41 100644 --- a/SentryDeck/Services/PackageManager.cs +++ b/SentryDeck/Services/PackageManager.cs @@ -37,7 +37,7 @@ private static async Task DownloadFile(string url, string savePath) return fileStream.Length; } - private static int ExtractFFmpegBin(string zipFilePath, string destinationBinPath, string archiveRoot) + internal static int ExtractFFmpegBin(string zipFilePath, string destinationBinPath, string archiveRoot) { if (Directory.Exists(destinationBinPath)) {