Add unit tests, dedupe FFmpeg output filtering, harden settings records
Implements the suggestions from the last review pass:
- Add AmiReel.Tests (MSTest), covering the pure logic in Models/ and
Services/: SupportedVideoFormats, AppSettings normalization,
UserFacingErrors.Summarize, the new FfmpegProgressParser/
FfmpegOutputFilter, RenderPipeline.Validate, BuildProgressMessage, and
MoveSourceVideos. 59 tests, all passing. UI code-behind and anything that
spawns an actual FFmpeg process are left to manual/integration testing.
Exclude AmiReel.Tests\**\*.cs from AmiReel.csproj's default item glob —
it's a subfolder of the app project now, so without the exclude the app
itself was compiling the MSTest-only test files.
- Extract the FFmpeg version/library-banner boilerplate list that
ProcessRunner (live log filter) and UserFacingErrors (error summarizer)
had each duplicated into a shared FfmpegOutputFilter.IsBoilerplateLine;
each caller keeps its own remaining context-specific checks on top.
Also extract ProcessRunner's line-parsing regexes into a standalone
FfmpegProgressParser so it's directly unit-testable without spawning a
process.
- Convert AppSettings and RenderSettings from positional record
constructors to named `required` init properties. Both records had
runs of same-typed consecutive parameters (three string timing fields
in AppSettings; three doubles then five ints in RenderSettings) that a
positional constructor would let get silently transposed at a call site
without the compiler catching it. Update the two call sites
(MainPage.xaml.cs) to object-initializer syntax.
- Add Properties/AssemblyInfo.cs with InternalsVisibleTo("AmiReel.Tests")
and make Validate/BuildProgressMessage/IsNoise internal so tests can
reach them directly instead of only through process-spawning entry
points.
- README: document `dotnet test`, and note that Package.appxmanifest's
Identity is a local-dev placeholder that needs a real publisher/cert
before MSIX distribution.
Verified: dotnet build (solution + test project) is 0 warnings/errors,
dotnet test is 59/59 passing, and the app still launches unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
using AmiReel.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AmiReel.Tests.Services;
|
||||
|
||||
[TestClass]
|
||||
public class FfmpegOutputFilterTests
|
||||
{
|
||||
[TestMethod]
|
||||
[DataRow("ffmpeg version 6.0-full_build-www.gyan.dev")]
|
||||
[DataRow("built with gcc 12.2.0")]
|
||||
[DataRow("configuration: --enable-gpl")]
|
||||
[DataRow("libavutil 58. 2.100 / 58. 2.100")]
|
||||
[DataRow("Input #0, avi, from 'clip.avi':")]
|
||||
[DataRow("Output #0, mp4, to 'out.mp4':")]
|
||||
[DataRow("Stream #0:0: Video: mjpeg")]
|
||||
[DataRow("Duration: 00:01:00.00, start: 0.000000, bitrate: 128 kb/s")]
|
||||
[DataRow("video:1024kB audio:128kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.5%")]
|
||||
public void IsBoilerplateLine_WithKnownFfmpegBanner_ReturnsTrue(string line)
|
||||
{
|
||||
// Act
|
||||
bool result = FfmpegOutputFilter.IsBoilerplateLine(line);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("Error while opening encoder for output stream #0:0")]
|
||||
[DataRow("Cannot load nvcuda.dll")]
|
||||
[DataRow("Unknown encoder 'h264_nvenc'")]
|
||||
public void IsBoilerplateLine_WithRealErrorText_ReturnsFalse(string line)
|
||||
{
|
||||
// Act
|
||||
bool result = FfmpegOutputFilter.IsBoilerplateLine(line);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IsBoilerplateLine_IsCaseInsensitive()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.IsTrue(FfmpegOutputFilter.IsBoilerplateLine("DURATION: 00:00:01.00"));
|
||||
Assert.IsTrue(FfmpegOutputFilter.IsBoilerplateLine("duration: 00:00:01.00"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using AmiReel.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AmiReel.Tests.Services;
|
||||
|
||||
[TestClass]
|
||||
public class FfmpegProgressParserTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Parse_WithFullProgressLine_ExtractsAllFields()
|
||||
{
|
||||
// Arrange
|
||||
const string line = "frame= 1234 fps=49.8 q=-1.0 size= 102400kB time=00:00:24.68 bitrate=33987.6kbits/s speed=0.996x";
|
||||
|
||||
// Act
|
||||
FfmpegOutputLine result = FfmpegProgressParser.Parse(line);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(new TimeSpan(0, 0, 0, 24, 680), result.Time);
|
||||
Assert.AreEqual(1234, result.Frame);
|
||||
Assert.AreEqual(49.8, result.FramesPerSecond);
|
||||
Assert.AreEqual(0.996, result.Speed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Parse_WithNoRecognizedFields_ReturnsAllNulls()
|
||||
{
|
||||
// Arrange
|
||||
const string line = "Metadata:";
|
||||
|
||||
// Act
|
||||
FfmpegOutputLine result = FfmpegProgressParser.Parse(line);
|
||||
|
||||
// Assert
|
||||
Assert.IsNull(result.Time);
|
||||
Assert.IsNull(result.Frame);
|
||||
Assert.IsNull(result.FramesPerSecond);
|
||||
Assert.IsNull(result.Speed);
|
||||
Assert.AreEqual(line, result.Line);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Parse_WithOnlyTimeField_ExtractsTimeAndLeavesFrameFieldsNull()
|
||||
{
|
||||
// Arrange: has a "time=" field but no matching frame=/fps=/speed=x group
|
||||
const string line = "size= 1024kB time=00:01:02.03 bitrate= 100.0kbits/s";
|
||||
|
||||
// Act
|
||||
FfmpegOutputLine result = FfmpegProgressParser.Parse(line);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(new TimeSpan(0, 0, 1, 2, 30), result.Time);
|
||||
Assert.IsNull(result.Frame);
|
||||
Assert.IsNull(result.FramesPerSecond);
|
||||
Assert.IsNull(result.Speed);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Parse_PreservesOriginalLineText()
|
||||
{
|
||||
// Arrange
|
||||
const string line = "some arbitrary ffmpeg output";
|
||||
|
||||
// Act
|
||||
FfmpegOutputLine result = FfmpegProgressParser.Parse(line);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(line, result.Line);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using AmiReel.Models;
|
||||
using AmiReel.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AmiReel.Tests.Services;
|
||||
|
||||
[TestClass]
|
||||
public class RenderPipelineTests
|
||||
{
|
||||
private readonly List<string> _tempDirectories = [];
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
foreach (string directory in _tempDirectories)
|
||||
{
|
||||
try { Directory.Delete(directory, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private string CreateTempDirectory()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "AmiReelTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
_tempDirectories.Add(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
private static RenderSettings ValidSettings(IReadOnlyList<string> inputFiles, string outputDirectory, string endCardPath = "") => new()
|
||||
{
|
||||
InputFiles = inputFiles,
|
||||
EndCardPath = endCardPath,
|
||||
OutputDirectory = outputDirectory,
|
||||
OutputName = "output",
|
||||
FfmpegPath = "",
|
||||
FfprobePath = "",
|
||||
Encoder = EncoderMode.Auto,
|
||||
TrimStart = 0,
|
||||
FadeSeconds = 1,
|
||||
EndCardHoldSeconds = 1,
|
||||
ThumbnailInterval = 10,
|
||||
};
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_WithNoInputFiles_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
RenderSettings settings = ValidSettings([], @"C:\out");
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<ArgumentException>(() => RenderPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_WithMissingInputFile_ThrowsFileNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
RenderSettings settings = ValidSettings([Path.Combine(directory, "missing.avi")], directory);
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<FileNotFoundException>(() => RenderPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_WithMissingEndCard_ThrowsFileNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
string input = Path.Combine(directory, "clip.avi");
|
||||
File.WriteAllText(input, "data");
|
||||
RenderSettings settings = ValidSettings([input], directory, endCardPath: Path.Combine(directory, "missing.png"));
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<FileNotFoundException>(() => RenderPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_WithBlankOutputDirectory_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
string input = Path.Combine(directory, "clip.avi");
|
||||
File.WriteAllText(input, "data");
|
||||
RenderSettings settings = ValidSettings([input], "");
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<ArgumentException>(() => RenderPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_WithInvalidOutputName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
string input = Path.Combine(directory, "clip.avi");
|
||||
File.WriteAllText(input, "data");
|
||||
RenderSettings settings = ValidSettings([input], directory) with { OutputName = "bad" + Path.GetInvalidFileNameChars()[0] };
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<ArgumentException>(() => RenderPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_WithThumbnailIntervalLessThanOne_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
string input = Path.Combine(directory, "clip.avi");
|
||||
File.WriteAllText(input, "data");
|
||||
RenderSettings settings = ValidSettings([input], directory) with { ThumbnailInterval = 0 };
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsExactly<ArgumentException>(() => RenderPipeline.Validate(settings));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validate_WithAllRequirementsMet_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
string input = Path.Combine(directory, "clip.avi");
|
||||
File.WriteAllText(input, "data");
|
||||
RenderSettings settings = ValidSettings([input], directory);
|
||||
|
||||
// Act & Assert (no exception)
|
||||
RenderPipeline.Validate(settings);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildProgressMessage_WithOnlyTime_ReturnsTimeRangeOnly()
|
||||
{
|
||||
// Act
|
||||
string message = RenderPipeline.BuildProgressMessage(TimeSpan.FromSeconds(30), 60, null, null, null, 50);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("00:00:30 / 00:01:00", message);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildProgressMessage_WithAllOptionalFields_IncludesFpsSpeedAndFrame()
|
||||
{
|
||||
// Arrange: numbers render using the current culture (this is user-facing text),
|
||||
// so build the expected fragments the same way rather than hard-coding "." / ",".
|
||||
const double fps = 49.8;
|
||||
const double speed = 0.99;
|
||||
const int frame = 1500;
|
||||
const int totalFrames = 3000; // ceil(60s * 50fps)
|
||||
|
||||
// Act
|
||||
string message = RenderPipeline.BuildProgressMessage(TimeSpan.FromSeconds(30), 60, fps, speed, frame, 50);
|
||||
|
||||
// Assert
|
||||
StringAssert.Contains(message, "00:00:30 / 00:01:00");
|
||||
StringAssert.Contains(message, $"{fps:0.#} fps");
|
||||
StringAssert.Contains(message, $"{speed:0.##}x");
|
||||
StringAssert.Contains(message, $"frame {frame:N0} / {totalFrames:N0}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MoveSourceVideos_WithNewDestination_MovesFileIntoOriginalsSubfolder()
|
||||
{
|
||||
// Arrange
|
||||
string outputDirectory = CreateTempDirectory();
|
||||
string sourceDirectory = CreateTempDirectory();
|
||||
string source = Path.Combine(sourceDirectory, "clip.avi");
|
||||
File.WriteAllText(source, "data");
|
||||
List<string> logs = [];
|
||||
|
||||
// Act
|
||||
IReadOnlyList<string> result = RenderPipeline.MoveSourceVideos([source], outputDirectory, logs.Add);
|
||||
|
||||
// Assert
|
||||
string expected = Path.Combine(outputDirectory, "originals", "clip.avi");
|
||||
Assert.AreEqual(expected, result.Single());
|
||||
Assert.IsTrue(File.Exists(expected));
|
||||
Assert.IsFalse(File.Exists(source));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MoveSourceVideos_WithDuplicateFilename_AppendsNumericSuffix()
|
||||
{
|
||||
// Arrange
|
||||
string outputDirectory = CreateTempDirectory();
|
||||
string sourceDirectory = CreateTempDirectory();
|
||||
string sourceA = Path.Combine(sourceDirectory, "a", "clip.avi");
|
||||
string sourceB = Path.Combine(sourceDirectory, "b", "clip.avi");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(sourceA)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(sourceB)!);
|
||||
File.WriteAllText(sourceA, "data-a");
|
||||
File.WriteAllText(sourceB, "data-b");
|
||||
|
||||
// Act
|
||||
IReadOnlyList<string> result = RenderPipeline.MoveSourceVideos([sourceA, sourceB], outputDirectory, _ => { });
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(2, result.Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
||||
Assert.IsTrue(result.All(File.Exists));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MoveSourceVideos_WithMissingSourceFile_LogsWarningAndKeepsOriginalPath()
|
||||
{
|
||||
// Arrange
|
||||
string outputDirectory = CreateTempDirectory();
|
||||
string missingSource = Path.Combine(CreateTempDirectory(), "gone.avi");
|
||||
List<string> logs = [];
|
||||
|
||||
// Act
|
||||
IReadOnlyList<string> result = RenderPipeline.MoveSourceVideos([missingSource], outputDirectory, logs.Add);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(missingSource, result.Single());
|
||||
Assert.IsTrue(logs.Any(line => line.Contains("no longer exists", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using AmiReel.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AmiReel.Tests.Services;
|
||||
|
||||
[TestClass]
|
||||
public class UserFacingErrorsTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Summarize_WithBlankMessage_ReturnsGenericMessage()
|
||||
{
|
||||
// Arrange
|
||||
Exception exception = new(" ");
|
||||
|
||||
// Act
|
||||
string result = UserFacingErrors.Summarize(exception);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("An unexpected error occurred.", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Summarize_WithNonProcessExitMessage_ReturnsMessageUnchanged()
|
||||
{
|
||||
// Arrange
|
||||
Exception exception = new("The output directory could not be created.");
|
||||
|
||||
// Act
|
||||
string result = UserFacingErrors.Summarize(exception);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("The output directory could not be created.", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Summarize_WithProcessExitAndOnlyBoilerplateLines_ReturnsFirstLineOnly()
|
||||
{
|
||||
// Arrange
|
||||
Exception exception = new(
|
||||
"Process exited with code 1.\n" +
|
||||
"ffmpeg version 6.0\n" +
|
||||
"built with gcc\n" +
|
||||
"Input #0, avi, from 'clip.avi':\n" +
|
||||
"Duration: 00:01:00.00\n");
|
||||
|
||||
// Act
|
||||
string result = UserFacingErrors.Summarize(exception);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("Process exited with code 1.", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Summarize_WithProcessExitAndRealErrorLines_IncludesTheRealErrorLines()
|
||||
{
|
||||
// Arrange
|
||||
Exception exception = new(
|
||||
"Process exited with code 1.\n" +
|
||||
"ffmpeg version 6.0\n" +
|
||||
"[h264_nvenc @ 0x1] Cannot load libnvidia-encode.so.1\n" +
|
||||
"Error initializing output stream 0:0 -- Error while opening encoder\n");
|
||||
|
||||
// Act
|
||||
string result = UserFacingErrors.Summarize(exception);
|
||||
|
||||
// Assert
|
||||
StringAssert.Contains(result, "Process exited with code 1.");
|
||||
StringAssert.Contains(result, "Cannot load libnvidia-encode.so.1");
|
||||
StringAssert.Contains(result, "Error while opening encoder");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Summarize_WithMoreThanThreeErrorLines_KeepsOnlyTheLastThree()
|
||||
{
|
||||
// Arrange
|
||||
Exception exception = new(
|
||||
"Process exited with code 1.\n" +
|
||||
"error line 1\n" +
|
||||
"error line 2\n" +
|
||||
"error line 3\n" +
|
||||
"error line 4\n");
|
||||
|
||||
// Act
|
||||
string result = UserFacingErrors.Summarize(exception);
|
||||
string[] lines = result.Split(Environment.NewLine);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(4, lines.Length);
|
||||
CollectionAssert.DoesNotContain(lines, "error line 1");
|
||||
CollectionAssert.Contains(lines, "error line 4");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("ffmpeg version 6.0")]
|
||||
[DataRow("built with gcc 12")]
|
||||
[DataRow("Input #0, avi, from 'clip.avi':")]
|
||||
[DataRow("Duration: 00:01:00.00, start: 0.000000")]
|
||||
[DataRow("Stream #0:0: Video: mjpeg")]
|
||||
public void IsNoise_WithFfmpegBoilerplate_ReturnsTrue(string line)
|
||||
{
|
||||
// Act
|
||||
bool result = UserFacingErrors.IsNoise(line);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("Error while opening encoder for output stream")]
|
||||
[DataRow("Cannot load libnvidia-encode.so.1")]
|
||||
[DataRow("No such file or directory")]
|
||||
public void IsNoise_WithRealErrorText_ReturnsFalse(string line)
|
||||
{
|
||||
// Act
|
||||
bool result = UserFacingErrors.IsNoise(line);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user