Skip to content

Commit aa3b15e

Browse files
Strengthen the test suite: 298 -> 395 tests, and fix three bugs it found
Reviewed the whole suite for coverage gaps, redundancy, brittleness, and maintainability. The suite was already in good shape -- fast, deterministic, no external dependencies -- so this keeps those properties (still ~3s) and targets the concentrated weaknesses. Coverage gaps closed: - ClipExporter.ExportAsync had zero tests. Added an optional `runFfmpeg` delegate so its failure, cancellation, and script-cleanup paths run without spawning ffmpeg. - PackageManager had no test file at all; ExtractFFmpegBin now has one. - Nine of thirteen converters were untested, including every null and wrong-type branch. - VideoPlayerController's transport commands (Play/Toggle/Next/Previous/ GoToClip) were never called by any test. - MainWindowViewModel's scan empty/error states and the keyboard transport shortcuts were unreachable; deleting the clip that is actually playing deadlocked on Dispatcher.Invoke, so RunOnUiThread got a `uiInvoker` seam. - Mp4DurationReader's box-parsing branches (truncated moov, size 0 and 1 forms, undersized box, moov without mvhd). - New CultureInvarianceTests pins the four load-bearing InvariantCulture arguments. Dropping one silently returns zero clips under ar-SA or mis-dates every clip by 543 years under th-TH, and no existing test could see it. Fixture and reliability work: - TestClipFiles wrote 60s chunks spaced 60s apart, which made probed duration and nominal spacing indistinguishable in every timeline calculation. It now takes per-chunk durations, so chunk-offset arithmetic is actually observable. - FakeClipMediaSourceBuilder exposed raw Lists that tests read while Build() appended off-thread; the bookkeeping is now private behind its lock and handed out as snapshots. - FfconcatMediaSourceBuilderTests no longer leaks a playlist file per test into %TEMP%, and CamStorageTests no longer recursively scans the build output directory. Removed or merged eight tests that duplicated a sibling's code path or asserted framework behavior, and fixed several that passed for the wrong reason -- notably two ClipPlaylist tests whose central assertions held before the act ran. Three production fixes, each pinned by a test verified to fail without it: - FfconcatMediaSourceBuilder registered a header-only playlist for a camera whose first chunk was unreadable, so a corrupt side camera surfaced "Failed to open" instead of the actionable "no footage" message. - ExtractFFmpegBin had no zip-slip containment check, and it extracts next to the app's own binaries. - NowPlayingConverter treated DependencyProperty.UnsetValue as a real value, so two unresolved bindings compared equal and could paint the now-playing badge on a row that is not playing. CI hardening: build and test in Release (three #if DEBUG blocks mean Debug green said nothing about shipped binaries), a --blame-hang inactivity guard so a deadlock regression fails in minutes instead of burning the runner, a trx artifact uploaded on failure, and a `dotnet format` gate -- which caught formatting drift in this very PR.
1 parent 86756c2 commit aa3b15e

25 files changed

Lines changed: 2300 additions & 342 deletions

.github/actions/full-build/action.yml

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,37 @@ runs:
1717
with:
1818
dotnet-version: 10.0.x
1919

20-
- name: Install WiX v6
20+
# Release, not the default Debug: shipped code has #if DEBUG blocks (log level, the update
21+
# check, drive scanning), so a Debug-only run says nothing about the binaries users get.
22+
- name: Build
2123
shell: bash
22-
run: |
23-
dotnet tool install --global wix --version 6.0.*
24+
run: dotnet build -c Release
2425

25-
- name: Build
26+
# AGENTS.md requires `dotnet format`, and without a gate the rule gets missed. Runs after
27+
# Build so a compile error is reported first — format's analyzers give a much worse message
28+
# on a tree that doesn't compile.
29+
- name: Verify formatting
2630
shell: bash
27-
run: dotnet build
31+
run: dotnet format --verify-no-changes --no-restore
2832

33+
# --blame-hang is an inactivity timeout on the test host: a deadlocked dispatcher call is not
34+
# covered by the suite's polling deadlines, and without this a hang regression burns the
35+
# runner's entire budget instead of failing in minutes with the offending test named.
36+
# The whole suite finishes in seconds, so 5m cannot fire on a healthy run.
2937
- name: Test
3038
shell: bash
31-
run: dotnet test
39+
run: dotnet test -c Release --no-build --blame-hang --blame-hang-timeout 5m --logger "trx;LogFileName=test-results.trx" --results-directory TestResults
40+
41+
# Explicitly named so it doesn't collide with the unnamed publish artifact below, and kept as
42+
# its own step so that one can keep if-no-files-found:error without a failed test run — which
43+
# never reaches the publish step — tripping it on an empty directory.
44+
- name: Upload test results
45+
if: always()
46+
uses: actions/upload-artifact@v7
47+
with:
48+
name: test-results
49+
path: TestResults/**
50+
if-no-files-found: ignore
3251

3352
- name: Create Binaries
3453
shell: bash
@@ -45,6 +64,13 @@ runs:
4564
Compress-Archive -Path "publish/$arch/*" -DestinationPath "publish/SentryDeck-${{ inputs.version }}-$arch.zip"
4665
}
4766
67+
# Installed here rather than up front so build, format, and test failures short-circuit ahead
68+
# of the tool install. setup-dotnet keeps ~/.dotnet/tools on PATH for the whole job.
69+
- name: Install WiX v6
70+
shell: bash
71+
run: |
72+
dotnet tool install --global wix --version 6.0.*
73+
4874
- name: Create MSI Installers
4975
shell: bash
5076
run: |

.github/workflows/build.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ on:
55
branches: [ main ]
66
pull_request:
77
branches: [ main ]
8+
workflow_dispatch:
9+
10+
# Superseded runs are cancelled, including on main. That's safe here specifically because this
11+
# workflow's artifacts are never consumed — releases are built by the tag-triggered deploy.yml.
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
815

916
permissions:
1017
contents: read
@@ -14,5 +21,5 @@ jobs:
1421
runs-on: windows-2025
1522
steps:
1623
- uses: actions/checkout@v5
17-
24+
1825
- uses: ./.github/actions/full-build

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,6 @@ These instructions apply to Codex and other coding agents working in this repo.
3434
## Testing/Validation
3535
- Always run `dotnet format`.
3636
- Tests live in `SentryDeck.Tests` and use xUnit/Shouldly.
37+
- Name tests `Subject_Scenario_Expectation` on a `public sealed class` with instance methods.
3738
- Add tests for new view-model and domain logic — view-models are plain objects that can be constructed directly in tests (see `MainWindowViewModelTests`).
3839
- If you change UI behavior, mention how to verify it (e.g., which view to open, what to click).

SentryDeck.Data/Playback/ClipExporter.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ public interface IClipExporter
3232
/// are fast and lossless. Stream copy cuts at keyframes, so the actual bounds can land up to a
3333
/// GOP (~1s in Tesla footage) before the requested ones.
3434
/// </summary>
35-
public sealed class ClipExporter(Func<string> ffmpegDirectoryResolver) : IClipExporter
35+
/// <param name="runFfmpeg">
36+
/// Runs FFmpeg with an executable path and an argument string. Defaults to launching the real
37+
/// process; overridable for tests, which must not spawn ffmpeg.
38+
/// </param>
39+
public sealed class ClipExporter(
40+
Func<string> ffmpegDirectoryResolver,
41+
Func<string, string, CancellationToken, Task> runFfmpeg = null) : IClipExporter
3642
{
3743
private static readonly string ExportScriptDirectory =
3844
Path.Combine(Path.GetTempPath(), "SentryDeck", "exports");
@@ -41,6 +47,8 @@ public async Task ExportAsync(ClipExportRequest request, CancellationToken cance
4147
{
4248
ArgumentNullException.ThrowIfNull(request);
4349

50+
runFfmpeg ??= RunFfmpegAsync;
51+
4452
var ffmpegDirectory = ffmpegDirectoryResolver()
4553
?? throw new InvalidOperationException("FFmpeg is not installed. Restart the app to download it.");
4654
var ffmpegPath = Path.Combine(ffmpegDirectory, "ffmpeg.exe");
@@ -58,7 +66,7 @@ public async Task ExportAsync(ClipExportRequest request, CancellationToken cance
5866

5967
try
6068
{
61-
await RunFfmpegAsync(ffmpegPath, BuildArguments(scriptPath, request.OutputPath), cancellationToken);
69+
await runFfmpeg(ffmpegPath, BuildArguments(scriptPath, request.OutputPath), cancellationToken);
6270
Log.Information(
6371
"Exported clip range. Clip={ClipName}; Camera={Camera}; Start={Start}; End={End}; Output={Output}; ElapsedMs={ElapsedMs}",
6472
request.Clip.Name,

SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,15 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet<int> excludedChunkIndice
103103
entries.Add((file.FullPath, chunkDurations[i]));
104104
}
105105

106+
// A camera whose first included file is unreadable ends up with nothing to play, so it is
107+
// omitted entirely just like one missing from that chunk (the guard above). Registering a
108+
// header-only playlist would advertise a camera that cannot be opened, hiding the
109+
// actionable "no footage" message behind an FFmpeg failure at play time.
110+
if (entries.Count == 0)
111+
{
112+
continue;
113+
}
114+
106115
var playlistPath = Path.Combine(PlaylistDirectory, $"{clipToken}-{camera}.ffconcat");
107116
WritePlaylist(playlistPath, entries);
108117
playlistPaths[camera] = playlistPath;

SentryDeck.Tests/CamDiscoveryResilienceTests.cs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ namespace SentryDeck.Tests;
66
/// Discovery must tolerate a single malformed/unreadable entry without discarding the whole
77
/// library (regression guard for the "one bad filename empties the timeline" bug).
88
/// </summary>
9-
public static class CamDiscoveryResilienceTests
9+
public sealed class CamDiscoveryResilienceTests
1010
{
1111
private static string CreateTempDir()
1212
{
@@ -22,7 +22,7 @@ private static void Touch(string dir, string name)
2222
=> File.WriteAllBytes(Path.Combine(dir, name), []);
2323

2424
[Fact]
25-
public static void FindFiles_SkipsCalendarInvalidFileName()
25+
public void FindFiles_SkipsCalendarInvalidFileName()
2626
{
2727
var dir = CreateTempDir();
2828
try
@@ -42,7 +42,7 @@ public static void FindFiles_SkipsCalendarInvalidFileName()
4242
}
4343

4444
[Fact]
45-
public static void FindFiles_CanonicalizesLegacyRearViewSuffixToBack()
45+
public void FindFiles_CanonicalizesLegacyRearViewSuffixToBack()
4646
{
4747
var dir = CreateTempDir();
4848
try
@@ -61,7 +61,7 @@ public static void FindFiles_CanonicalizesLegacyRearViewSuffixToBack()
6161
}
6262

6363
[Fact]
64-
public static void FindClips_OneCalendarInvalidFileDoesNotDiscardOtherClips()
64+
public void FindClips_OneCalendarInvalidFileDoesNotDiscardOtherClips()
6565
{
6666
var root = CreateTempDir();
6767
try
@@ -87,7 +87,7 @@ public static void FindClips_OneCalendarInvalidFileDoesNotDiscardOtherClips()
8787
}
8888

8989
[Fact]
90-
public static void Map_DateLessFolderWithoutEvent_FallsBackToFirstChunkTimestamp()
90+
public void Map_DateLessFolderWithoutEvent_FallsBackToFirstChunkTimestamp()
9191
{
9292
// A folder like Tesla's RecentClips: loose files directly inside, no date-named subfolder and
9393
// 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
110110
}
111111

112112
[Fact]
113-
public static void Map_CalendarInvalidFolderName_DoesNotThrowAndKeepsChunks()
113+
public void Map_CalendarInvalidFolderName_DoesNotThrowAndKeepsChunks()
114114
{
115115
var root = CreateTempDir();
116116
try
@@ -128,4 +128,34 @@ public static void Map_CalendarInvalidFolderName_DoesNotThrowAndKeepsChunks()
128128
Directory.Delete(root, true);
129129
}
130130
}
131+
132+
[Fact]
133+
public void Map_BackAndRearViewAtOneTimestamp_KeepsOneChunkAndDoesNotDropTheClip()
134+
{
135+
// What a drive spanning a firmware transition (or two drives merged by hand) actually holds:
136+
// both rear-camera suffixes at the same timestamp. CamFile canonicalizes rear_view to back, so
137+
// the two files collide on one camera key -- and an unguarded ToDictionary would throw there,
138+
// with CamClip.TryMap swallowing it and the whole clip folder vanishing from the library.
139+
var dir = CreateTempDir();
140+
try
141+
{
142+
Touch(dir, "2023-02-23_14-14-48-front.mp4");
143+
Touch(dir, "2023-02-23_14-14-48-back.mp4");
144+
Touch(dir, "2023-02-23_14-14-48-rear_view.mp4");
145+
146+
var chunks = CamChunk.Map(dir);
147+
148+
chunks.Count.ShouldBe(1);
149+
150+
// Exactly one of the two rear files survives; which one follows enumeration order, so the
151+
// winner is deliberately not pinned here.
152+
chunks[0].Files.Keys.ShouldBe([CameraNames.Front, CameraNames.Back], ignoreOrder: true);
153+
154+
CamClip.Map(dir).ShouldNotBeNull();
155+
}
156+
finally
157+
{
158+
Directory.Delete(dir, true);
159+
}
160+
}
131161
}

SentryDeck.Tests/CamEventTests.cs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
namespace SentryDeck.Tests;
22

3-
public static class CamEventTests
3+
public sealed class CamEventTests
44
{
55
[Fact]
6-
public static void Deserializes_Correctly()
6+
public void Deserialize_FullEventJson_PopulatesEveryField()
77
{
88
// Arrange
99
var json = """
@@ -31,7 +31,7 @@ public static void Deserializes_Correctly()
3131
}
3232

3333
[Fact]
34-
public static void Deserialization_OptionalProperties()
34+
public void Deserialization_OptionalProperties()
3535
{
3636
// Arrange
3737
var json = """
@@ -53,7 +53,7 @@ public static void Deserialization_OptionalProperties()
5353
}
5454

5555
[Fact]
56-
public static void Deserialization_RecoversValidFieldsFromMalformedJson()
56+
public void Deserialization_RecoversValidFieldsFromMalformedJson()
5757
{
5858
// Every field here is well-formed JSON but several are semantically bad (bad date, non-numeric
5959
// lat/lon, non-integer camera). Strict deserialization throws; the lenient fallback keeps the
@@ -83,7 +83,7 @@ public static void Deserialization_RecoversValidFieldsFromMalformedJson()
8383
}
8484

8585
[Fact]
86-
public static void Deserialization_BlankCoordinateKeepsCityAndTimestamp()
86+
public void Deserialization_BlankCoordinateKeepsCityAndTimestamp()
8787
{
8888
// Tesla occasionally writes an incomplete est_lat; a single blank field must not discard the
8989
// city and the event timestamp the clip name falls back to.
@@ -110,17 +110,23 @@ public static void Deserialization_BlankCoordinateKeepsCityAndTimestamp()
110110
}
111111

112112
[Fact]
113-
public static void Deserialization_ReturnsNullForNonObjectJson()
113+
public void Deserialization_ReturnsNullForNonObjectJson()
114114
{
115115
CamEvent.Deserialize("\"just a string\"").ShouldBeNull();
116116
CamEvent.Deserialize("not json at all {").ShouldBeNull();
117117
}
118118

119119
[Fact]
120-
public static void FromFile()
120+
public void FromFile_ReadsEventJsonFromDisk()
121121
{
122122
var camEvent = CamEvent.FromFile("Mocks/2023-02-23_14-16-15/event.json");
123123

124+
// Assert the parsed values, not just non-null: every field could silently fall back to its
125+
// default and still leave a CamEvent behind -- exactly what the sibling Deserialize tests
126+
// exist to catch, and this is the only one that goes through the file-reading path.
124127
camEvent.ShouldNotBeNull();
128+
camEvent.Timestamp.ShouldBe(new DateTime(2023, 2, 23, 14, 16, 7));
129+
camEvent.City.ShouldBe("Austin");
130+
camEvent.Reason.ShouldBe("user_interaction_honk");
125131
}
126132
}

SentryDeck.Tests/CamStorageTests.cs

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
namespace SentryDeck.Tests;
22

3-
public static class CamStorageTests
3+
public sealed class CamStorageTests
44
{
55
[Fact]
6-
public static void TraverseFindsAllClips()
6+
public void Map_RootWithMixedFolders_ReturnsOnlyPlayableClips()
77
{
8-
var storage = CamStorage.Map(".");
8+
var storage = CamStorage.Map("Mocks");
99

10-
storage.Clips.Count.ShouldBe(3); // Ignores the "No Camera Files" folder.
10+
// Two mock folders are deliberately unplayable and must not surface: "No Camera Files" holds
11+
// only an event.json, and "No Front Angle" has every angle except the front one -- CamChunk.Map
12+
// keeps only timestamp groups containing a front file, so it yields no chunks at all. The
13+
// "Mocks" root is itself a clip candidate, but it holds no media either.
14+
storage.Clips.Select(clip => clip.Name).ShouldBe(
15+
[
16+
"02/23/2023 14:16:15",
17+
"Custom Folder Name",
18+
"Missing Left Camera Angle on Second Chunk",
19+
],
20+
ignoreOrder: true);
1121
}
1222

1323
[Theory]
1424
[InlineData("Mocks/2023-02-23_14-16-15", "02/23/2023 14:16:15")]
1525
[InlineData("Mocks/Custom Folder Name", "Custom Folder Name")]
16-
public static void ClipName(string path, string expectedName)
26+
public void Map_ClipName_ComesFromFolderNameOrTimestamp(string path, string expectedName)
1727
{
1828
var clip = CamClip.Map(path);
1929

@@ -22,7 +32,7 @@ public static void ClipName(string path, string expectedName)
2232
}
2333

2434
[Fact]
25-
public static void MapClipWithNonstandardNameFallsBackToEventDataForTimestamp()
35+
public void MapClipWithNonstandardNameFallsBackToEventDataForTimestamp()
2636
{
2737
var clip = CamClip.Map("Mocks/Custom Folder Name");
2838

@@ -34,30 +44,26 @@ public static void MapClipWithNonstandardNameFallsBackToEventDataForTimestamp()
3444
[InlineData("Mocks/2023-02-23_14-16-15", 2)]
3545
[InlineData("Mocks/Missing Left Camera Angle on Second Chunk", 2)]
3646
[InlineData("Mocks/No Front Angle", 0)]
37-
public static void FindsAllChunks(string path, int expectedCount)
47+
public void FindsAllChunks(string path, int expectedCount)
3848
{
3949
var chunks = CamChunk.Map(path);
4050

4151
chunks.Count.ShouldBe(expectedCount);
4252
}
4353

44-
[Theory]
45-
[InlineData("Mocks/2023-02-23_14-16-15")]
46-
public static void ChunksAreInCorrectOrder(string path)
54+
[Fact]
55+
public void ChunksAreInCorrectOrder()
4756
{
48-
var chunks = CamChunk.Map(path);
49-
50-
for (var i = 1; i < chunks.Count; i++)
51-
{
52-
var currentTimestamp = chunks[i - 1].Timestamp;
53-
var nextTimestamp = chunks[i].Timestamp;
57+
var chunks = CamChunk.Map("Mocks/2023-02-23_14-16-15");
5458

55-
nextTimestamp.ShouldBeGreaterThan(currentTimestamp, "each timestamp should be more recent than the previous one");
56-
}
59+
// The count assertion is load-bearing: an ordering check alone passes vacuously on an empty
60+
// or single-chunk result, so it would stay green if discovery stopped finding chunks at all.
61+
chunks.Count.ShouldBe(2);
62+
chunks.Select(chunk => chunk.Timestamp).ShouldBeInOrder();
5763
}
5864

5965
[Fact]
60-
public static void MapRoot_WhenRootIsClipFolder_ReturnsThatClip()
66+
public void MapRoot_WhenRootIsClipFolder_ReturnsThatClip()
6167
{
6268
var storage = CamStorage.Map("Mocks/2023-02-23_14-16-15");
6369

@@ -68,7 +74,7 @@ public static void MapRoot_WhenRootIsClipFolder_ReturnsThatClip()
6874
[Theory]
6975
[InlineData("Mocks/2023-02-23_14-16-15", 8)]
7076
[InlineData("Mocks/Missing Left Camera Angle on Second Chunk", 7)]
71-
public static void FindsAllFiles(string path, int expectedCount)
77+
public void FindFiles_ReturnsEveryCameraFile(string path, int expectedCount)
7278
{
7379
var files = CamFile.FindFiles(path).ToList();
7480

0 commit comments

Comments
 (0)