Files
klevze c412469773 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>
2026-08-13 14:27:57 +02:00

217 lines
7.6 KiB
C#

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)));
}
}