181 lines
8.2 KiB
C#
181 lines
8.2 KiB
C#
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
|
||
{
|
||
/// <summary>
|
||
/// FsCheck property-based tests for ViewportRecordingService static helpers.
|
||
/// </summary>
|
||
public class ViewportRecordingPropertyTests
|
||
{
|
||
// ── Property 1: Timer formatting correctness ─────────────────────────
|
||
|
||
// Feature: viewport-recording, Property 1: Timer formatting correctness
|
||
/// <summary>
|
||
/// **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.
|
||
/// </summary>
|
||
[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
|
||
/// <summary>
|
||
/// **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.
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public Property GenerateFileName_MatchesPattern_AndTimestampIsReversible()
|
||
{
|
||
var dateTimeGen = ArbMap.Default.GeneratorFor<DateTime>();
|
||
|
||
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
|
||
/// <summary>
|
||
/// **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.
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public Property GetVideoFolderPath_AlwaysReturnsVideoSubfolder()
|
||
{
|
||
// Generate non-empty strings (filter out empty/whitespace)
|
||
var nonEmptyStringGen = ArbMap.Default.GeneratorFor<NonEmptyString>();
|
||
|
||
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
|
||
/// <summary>
|
||
/// **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).
|
||
/// </summary>
|
||
[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;
|
||
});
|
||
}
|
||
}
|
||
}
|