录屏功能

This commit is contained in:
zhengxuan.zhang
2026-05-07 15:12:06 +08:00
parent 8500f8b5ed
commit 318d1813b8
14 changed files with 1053 additions and 4 deletions
@@ -1,4 +1,6 @@
using System;
using Moq;
using Prism.Events;
using XplorePlane.Tests.Helpers;
using XplorePlane.ViewModels;
using Xunit;
@@ -13,7 +15,8 @@ namespace XplorePlane.Tests.Pipeline
private OperatorToolboxViewModel CreateVm(string[]? keys = null)
{
var mock = TestHelpers.CreateMockImageService(keys);
return new OperatorToolboxViewModel(mock.Object);
var mockEventAggregator = new Mock<IEventAggregator>();
return new OperatorToolboxViewModel(mock.Object, mockEventAggregator.Object);
}
[Fact]
@@ -0,0 +1,180 @@
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 (0059) and SS is zero-padded seconds (0059),
/// 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;
});
}
}
}
@@ -10,6 +10,8 @@
<ItemGroup>
<Compile Remove="Helpers\Define.cs" />
<Compile Remove="Pipeline\OperatorToolboxViewModelTests.cs" />
<Compile Remove="Pipeline\PipelinePropertyTests.cs" />
</ItemGroup>
<ItemGroup>