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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions SentryDeck.Data/Playback/ClipExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public sealed class ClipExporter(Func<string> ffmpegDirectoryResolver) : IClipExporter
/// <param name="runFfmpeg">
/// Runs FFmpeg with an executable path and an argument string. Defaults to launching the real
/// process; overridable for tests, which must not spawn ffmpeg.
/// </param>
public sealed class ClipExporter(
Func<string> ffmpegDirectoryResolver,
Func<string, string, CancellationToken, Task> runFfmpeg = null) : IClipExporter
{
private static readonly string ExportScriptDirectory =
Path.Combine(Path.GetTempPath(), "SentryDeck", "exports");
Expand All @@ -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");
Expand All @@ -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,
Expand Down
39 changes: 33 additions & 6 deletions SentryDeck.Tests/CamDiscoveryResilienceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
/// </summary>
public static class CamDiscoveryResilienceTests
public sealed class CamDiscoveryResilienceTests
{
private static string CreateTempDir()
{
Expand All @@ -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
Expand All @@ -42,7 +42,7 @@ public static void FindFiles_SkipsCalendarInvalidFileName()
}

[Fact]
public static void FindFiles_CanonicalizesLegacyRearViewSuffixToBack()
public void FindFiles_CanonicalizesLegacyRearViewSuffixToBack()
{
var dir = CreateTempDir();
try
Expand All @@ -61,7 +61,7 @@ public static void FindFiles_CanonicalizesLegacyRearViewSuffixToBack()
}

[Fact]
public static void FindClips_OneCalendarInvalidFileDoesNotDiscardOtherClips()
public void FindClips_OneCalendarInvalidFileDoesNotDiscardOtherClips()
{
var root = CreateTempDir();
try
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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);
}
}
}
18 changes: 11 additions & 7 deletions SentryDeck.Tests/CamEventTests.cs
Original file line number Diff line number Diff line change
@@ -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 = """
Expand Down Expand Up @@ -31,7 +31,7 @@ public static void Deserializes_Correctly()
}

[Fact]
public static void Deserialization_OptionalProperties()
public void Deserialization_OptionalProperties()
{
// Arrange
var json = """
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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");
}
}
43 changes: 23 additions & 20 deletions SentryDeck.Tests/CamStorageTests.cs
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -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");

Expand All @@ -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");

Expand All @@ -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();

Expand Down
72 changes: 69 additions & 3 deletions SentryDeck.Tests/ClipExporterTests.cs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -121,7 +138,8 @@ public void ResolveEntries_EmptyRange_Throws()
var fixture = CreateClip();

Should.Throw<InvalidOperationException>(() => 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]
Expand Down Expand Up @@ -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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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);
}
}
}
Loading