using System; using System.Globalization; using System.IO; using System.Text.RegularExpressions; using Emgu.CV; using FsCheck; using FsCheck.Fluent; using FsCheck.Xunit; using XplorePlane.Services.Recording; namespace XplorePlane.Tests.Services { /// /// FsCheck property-based tests for ViewportRecordingService static helpers. /// public class ViewportRecordingPropertyTests { // ── Property 1: Timer formatting correctness ───────────────────────── // Feature: viewport-recording, Property 1: Timer formatting correctness /// /// **Validates: Requirements 2.2** /// For any elapsed time value in seconds between 0 and 3599 (inclusive), /// FormatElapsedTime SHALL produce a string in the format "MM:SS" where /// MM is zero-padded minutes (00–59) and SS is zero-padded seconds (00–59), /// and the numeric value represented equals the input seconds. /// [Property(MaxTest = 100)] public Property FormatElapsedTime_ProducesCorrectMMSS_ForAllValidSeconds() { var secondsGen = Gen.Choose(0, 3599); return Prop.ForAll( secondsGen.ToArbitrary(), totalSeconds => { string result = ViewportRecordingService.FormatElapsedTime(totalSeconds); // Verify format is exactly "MM:SS" (5 chars, colon at index 2) bool correctLength = result.Length == 5; bool hasColon = result[2] == ':'; bool matchesPattern = Regex.IsMatch(result, @"^\d{2}:\d{2}$"); // Parse back and verify numeric correctness int parsedMinutes = int.Parse(result.Substring(0, 2)); int parsedSeconds = int.Parse(result.Substring(3, 2)); bool minutesInRange = parsedMinutes >= 0 && parsedMinutes <= 59; bool secondsInRange = parsedSeconds >= 0 && parsedSeconds <= 59; // Verify the numeric value equals the input int reconstructed = parsedMinutes * 60 + parsedSeconds; bool valueCorrect = reconstructed == totalSeconds; return correctLength && hasColon && matchesPattern && minutesInRange && secondsInRange && valueCorrect; }); } // ── Property 2: Recording filename generation ──────────────────────── // Feature: viewport-recording, Property 2: Recording filename generation /// /// **Validates: Requirements 3.2** /// For any valid DateTime value, GenerateFileName SHALL produce a string /// matching the pattern REC_yyyyMMdd_HHmmss.mp4, and parsing the timestamp /// portion back to a DateTime SHALL yield the same year, month, day, hour, /// minute, and second as the input. /// [Property(MaxTest = 100)] public Property GenerateFileName_MatchesPattern_AndTimestampIsReversible() { var dateTimeGen = ArbMap.Default.GeneratorFor(); return Prop.ForAll( dateTimeGen.ToArbitrary(), (DateTime dateTime) => { var fileName = ViewportRecordingService.GenerateFileName(dateTime); // Verify pattern: REC_yyyyMMdd_HHmmss.mp4 var pattern = @"^REC_\d{8}_\d{6}\.mp4$"; bool matchesPattern = Regex.IsMatch(fileName, pattern); // Extract timestamp portion and parse back var timestampStr = fileName.Substring(4, 15); // "yyyyMMdd_HHmmss" bool canParse = DateTime.TryParseExact( timestampStr, "yyyyMMdd_HHmmss", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsed); // Verify reversibility: year, month, day, hour, minute, second match bool isReversible = canParse && parsed.Year == dateTime.Year && parsed.Month == dateTime.Month && parsed.Day == dateTime.Day && parsed.Hour == dateTime.Hour && parsed.Minute == dateTime.Minute && parsed.Second == dateTime.Second; return matchesPattern && isReversible; }); } // ── Property 3: VIDEO folder path construction ─────────────────────── // Feature: viewport-recording, Property 3: VIDEO folder path construction /// /// **Validates: Requirements 4.2** /// For any non-empty string rootPath, GetVideoFolderPath SHALL produce a path /// equal to Path.Combine(rootPath, "VIDEO"), ensuring the VIDEO subfolder /// is always constructed relative to the given root path. /// [Property(MaxTest = 100)] public Property GetVideoFolderPath_AlwaysReturnsVideoSubfolder() { // Generate non-empty strings (filter out empty/whitespace) var nonEmptyStringGen = ArbMap.Default.GeneratorFor(); return Prop.ForAll( nonEmptyStringGen.ToArbitrary(), (NonEmptyString rootPath) => { string result = ViewportRecordingService.GetVideoFolderPath(rootPath.Get); // Verify the result equals Path.Combine(rootPath, "VIDEO") string expected = System.IO.Path.Combine(rootPath.Get, "VIDEO"); bool equalsExpected = result == expected; // Verify the result ends with "VIDEO" segment bool endsWithVideo = result.EndsWith("VIDEO") || result.EndsWith("VIDEO/") || result.EndsWith("VIDEO\\"); return equalsExpected && endsWithVideo; }); } // ── Property 4: Null-source frame produces black frame ─────────────── // Feature: viewport-recording, Property 4: Null-source frame produces black frame /// /// **Validates: Requirements 6.3** /// For any positive integer dimensions (width, height), CreateBlackFrame SHALL /// produce a Mat with the correct dimensions, 3 channels (BGR24), and all pixel /// values equal to zero (pure black). /// [Property(MaxTest = 50)] public Property CreateBlackFrame_ProducesCorrectBlackFrame_ForAnyPositiveDimensions() { // Generate positive integers for width and height (1..512 to keep tests fast) var widthGen = Gen.Choose(1, 512); var heightGen = Gen.Choose(1, 512); var dimensionsGen = widthGen.SelectMany(w => heightGen.Select(h => (w, h))); return Prop.ForAll( dimensionsGen.ToArbitrary(), ((int width, int height) dims) => { using var frame = ViewportRecordingService.CreateBlackFrame(dims.width, dims.height); // Verify dimensions bool correctWidth = frame.Width == dims.width; bool correctHeight = frame.Height == dims.height; // Verify 3 channels (BGR24) bool correctChannels = frame.NumberOfChannels == 3; // Verify all pixels are zero (black) // Get raw byte data and check all zeros int totalBytes = dims.width * dims.height * 3; byte[] pixelData = new byte[totalBytes]; System.Runtime.InteropServices.Marshal.Copy(frame.DataPointer, pixelData, 0, totalBytes); bool allZero = System.Array.TrueForAll(pixelData, b => b == 0); return correctWidth && correctHeight && correctChannels && allZero; }); } } }