using AmiReel.Models; using AmiReel.Services; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace AmiReel.Tests.Services; [TestClass] public class RenderPipelineTests { private readonly List _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 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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 ValidatePreview_WithMissingOutputName_DoesNotThrow() { // Arrange string directory = CreateTempDirectory(); string input = Path.Combine(directory, "clip.avi"); File.WriteAllText(input, "data"); RenderSettings settings = ValidSettings([input], "") with { OutputName = "" }; // Act & Assert (no exception) RenderPipeline.ValidatePreview(settings); } [TestMethod] public void ValidatePreview_WithNoInputFiles_ThrowsArgumentException() { // Arrange RenderSettings settings = ValidSettings([], @"C:\out"); // Act & Assert Assert.ThrowsExactly(() => RenderPipeline.ValidatePreview(settings)); } [TestMethod] public void BuildJoinVideoFilter_IncludesCropThenScaleAndPad() { // Arrange RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out"); // Act string filter = RenderPipeline.BuildJoinVideoFilter(settings, "crop=iw-10-10:ih-4-4:10:4"); // Assert StringAssert.StartsWith(filter, "crop=iw-10-10:ih-4-4:10:4,scale=2880:2160"); StringAssert.Contains(filter, "pad=3840:2160"); } [TestMethod] public void BuildSourceCropFilter_WithManualInsets_UsesEdgeCrop() { // Arrange RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with { CropLeft = 20, CropRight = 10, CropTop = 4, CropBottom = 6, }; // Act string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0"); // Assert Assert.AreEqual("crop=iw-20-10:ih-4-6:20:4", filter); } [TestMethod] public void BuildSourceCropFilter_WithAutoDetection_UsesDetectedFilter() { // Arrange RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with { SourceCropMode = ShortSourceCropMode.Auto, }; // Act string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0"); // Assert Assert.AreEqual("crop=800:ih:40:0", filter); } [TestMethod] public void BuildSourceCropFilter_WithCenterMode_UsesFourByThreeCrop() { // Arrange RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with { SourceCropMode = ShortSourceCropMode.Center, }; // Act string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0"); // Assert Assert.AreEqual(ShortsPipeline.DefaultSourceCropFilter, filter); } [TestMethod] public void BuildSourceCropFilter_WithNoneMode_AppliesNoCrop() { // Arrange RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with { SourceCropMode = ShortSourceCropMode.None, }; // Act string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0"); // Assert Assert.AreEqual("", filter); } [TestMethod] public void BuildJoinVideoFilter_WithNoCrop_StartsAtScale() { // Arrange RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out"); // Act string filter = RenderPipeline.BuildJoinVideoFilter(settings, ""); // Assert StringAssert.StartsWith(filter, "scale=2880:2160"); Assert.IsFalse(filter.Contains("crop=", StringComparison.Ordinal)); } [TestMethod] public void PreviewSeekSeconds_WithDurationAfterTrim_StaysInsideRemainingClip() { // Act double seek = RenderPipeline.PreviewSeekSeconds(4, 30); // Assert Assert.AreEqual(5.2, seek, 0.001); } [TestMethod] public void PreviewSeekSeconds_WhenTrimIsPastDuration_ReturnsTrimStart() { // Act double seek = RenderPipeline.PreviewSeekSeconds(40, 10); // Assert Assert.AreEqual(40, seek, 0.001); } [TestMethod] public void FormatTimestamp_FormatsInvariantHoursMinutesSeconds() { // Act string stamp = RenderPipeline.FormatTimestamp(4.414); // Assert Assert.AreEqual("00:00:04.414", stamp); } [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 logs = []; // Act IReadOnlyList 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 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 logs = []; // Act IReadOnlyList 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))); } }