using FsCheck; using FsCheck.Xunit; using System.Net; using WorkCameraExport.Services; using Xunit; namespace WorkCameraExport.Tests { /// /// ImageService 属性测试 /// Feature: work-camera-2.0.1, Property 7: 并发下载控制 /// Validates: Requirements 6.4 /// public class ImageServicePropertyTests : IDisposable { private readonly string _testOutputDir; public ImageServicePropertyTests() { _testOutputDir = Path.Combine(Path.GetTempPath(), $"ImageService_Test_{Guid.NewGuid():N}"); Directory.CreateDirectory(_testOutputDir); } public void Dispose() { try { if (Directory.Exists(_testOutputDir)) { Directory.Delete(_testOutputDir, true); } } catch { // 忽略清理错误 } } /// /// Property 7: 并发下载控制 /// For any 图片下载任务,同时进行的下载数应不超过配置的并发数(默认 5)。 /// 测试:并发下载时,同时活动的下载数不应超过指定的并发数 /// [Property(MaxTest = 100)] public Property ConcurrentDownload_ActiveDownloads_ShouldNotExceedConcurrency() { return Prop.ForAll( Arb.From(), Arb.From(), (urlCountGen, concurrencyGen) => { // 限制 URL 数量和并发数在合理范围内 var urlCount = (urlCountGen.Get % 20) + 1; // 1-20 个 URL var concurrency = (concurrencyGen.Get % 10) + 1; // 1-10 并发 // 创建跟踪并发的处理器 var handler = new ConcurrencyTrackingHandler(); using var httpClient = new HttpClient(handler); using var imageService = new ImageService(httpClient, null); // 生成测试 URL var urls = Enumerable.Range(1, urlCount) .Select(i => $"http://test.local/image{i}.jpg") .ToList(); // 执行并发下载 var task = imageService.DownloadImagesAsync( urls, _testOutputDir, concurrency, null, CancellationToken.None); task.Wait(TimeSpan.FromSeconds(30)); // 验证最大并发数不超过配置值 return (handler.MaxConcurrentRequests <= concurrency) .Label($"Max concurrent requests {handler.MaxConcurrentRequests} should be <= {concurrency}"); }); } /// /// Property 7 扩展: 并发数应被限制在有效范围内 (1-10) /// [Property(MaxTest = 100)] public Property ConcurrentDownload_ConcurrencyValue_ShouldBeClampedToValidRange() { return Prop.ForAll( Arb.From(), (concurrency) => { var handler = new ConcurrencyTrackingHandler(); using var httpClient = new HttpClient(handler); using var imageService = new ImageService(httpClient, null); // 生成少量测试 URL var urls = Enumerable.Range(1, 15) .Select(i => $"http://test.local/image{i}.jpg") .ToList(); // 执行并发下载(使用可能超出范围的并发数) var task = imageService.DownloadImagesAsync( urls, _testOutputDir, concurrency, null, CancellationToken.None); task.Wait(TimeSpan.FromSeconds(30)); // 验证实际并发数在有效范围内 (1-10) return (handler.MaxConcurrentRequests >= 1 && handler.MaxConcurrentRequests <= 10) .Label($"Max concurrent requests {handler.MaxConcurrentRequests} should be between 1 and 10"); }); } /// /// Property 7 扩展: 所有 URL 都应被处理 /// [Property(MaxTest = 100)] public Property ConcurrentDownload_AllUrls_ShouldBeProcessed() { return Prop.ForAll( Arb.From(), Arb.From(), (urlCountGen, concurrencyGen) => { var urlCount = (urlCountGen.Get % 20) + 1; var concurrency = (concurrencyGen.Get % 10) + 1; var handler = new ConcurrencyTrackingHandler(); using var httpClient = new HttpClient(handler); using var imageService = new ImageService(httpClient, null); var urls = Enumerable.Range(1, urlCount) .Select(i => $"http://test.local/image{i}.jpg") .ToList(); var task = imageService.DownloadImagesAsync( urls, _testOutputDir, concurrency, null, CancellationToken.None); var results = task.Result; // 验证所有 URL 都被处理 return (results.Count == urlCount) .Label($"Result count {results.Count} should equal URL count {urlCount}"); }); } /// /// Property 7 扩展: 进度报告应正确递增 /// [Property(MaxTest = 100)] public Property ConcurrentDownload_Progress_ShouldIncrementCorrectly() { return Prop.ForAll( Arb.From(), (urlCountGen) => { var urlCount = (urlCountGen.Get % 10) + 1; var progressValues = new List(); var progress = new Progress(value => { lock (progressValues) { progressValues.Add(value); } }); var handler = new ConcurrencyTrackingHandler(); using var httpClient = new HttpClient(handler); using var imageService = new ImageService(httpClient, null); var urls = Enumerable.Range(1, urlCount) .Select(i => $"http://test.local/image{i}.jpg") .ToList(); var task = imageService.DownloadImagesAsync( urls, _testOutputDir, 5, progress, CancellationToken.None); task.Wait(TimeSpan.FromSeconds(30)); // 等待进度回调完成 Thread.Sleep(100); // 验证最终进度值等于 URL 数量 var maxProgress = progressValues.Count > 0 ? progressValues.Max() : 0; return (maxProgress == urlCount) .Label($"Max progress {maxProgress} should equal URL count {urlCount}"); }); } /// /// Property 7 扩展: 空 URL 列表应返回空结果 /// [Fact] public async Task ConcurrentDownload_EmptyUrlList_ShouldReturnEmptyResult() { var handler = new ConcurrencyTrackingHandler(); using var httpClient = new HttpClient(handler); using var imageService = new ImageService(httpClient, null); var results = await imageService.DownloadImagesAsync( new List(), _testOutputDir, 5, null, CancellationToken.None); Assert.Empty(results); Assert.Equal(0, handler.TotalRequests); } /// /// Property 7 扩展: 取消操作应停止下载 /// [Fact] public async Task ConcurrentDownload_Cancellation_ShouldStopDownloads() { var handler = new SlowResponseHandler(500); // 每个请求延迟 500ms using var httpClient = new HttpClient(handler); using var imageService = new ImageService(httpClient, null); var urls = Enumerable.Range(1, 20) .Select(i => $"http://test.local/image{i}.jpg") .ToList(); using var cts = new CancellationTokenSource(); // 启动下载任务 var downloadTask = imageService.DownloadImagesAsync( urls, _testOutputDir, 2, null, cts.Token); // 短暂延迟后取消 await Task.Delay(200); cts.Cancel(); // 等待任务完成 var results = await downloadTask; // 验证不是所有 URL 都被处理(因为被取消了) var successCount = results.Count(r => r.Value != null); Assert.True(successCount < urls.Count, $"Should have processed fewer than {urls.Count} URLs due to cancellation, but processed {successCount}"); } } #region Mock HTTP Handlers for ImageService Tests /// /// 跟踪并发请求的处理器 /// public class ConcurrencyTrackingHandler : HttpMessageHandler { private int _currentConcurrent; private readonly object _lock = new(); public int MaxConcurrentRequests { get; private set; } public int TotalRequests { get; private set; } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { lock (_lock) { _currentConcurrent++; TotalRequests++; if (_currentConcurrent > MaxConcurrentRequests) { MaxConcurrentRequests = _currentConcurrent; } } try { // 模拟网络延迟 await Task.Delay(50, cancellationToken); // 返回模拟图片数据 var imageData = CreateTestImageData(); var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(imageData) }; response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg"); return response; } finally { lock (_lock) { _currentConcurrent--; } } } private static byte[] CreateTestImageData() { // 创建最小的有效 JPEG 数据 // 这是一个 1x1 像素的红色 JPEG 图片 return new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20, 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0xFB, 0xD5, 0xDB, 0x20, 0xA8, 0xF1, 0x7E, 0xA9, 0x00, 0x0C, 0x3E, 0xF8, 0xA8, 0x6E, 0x2D, 0xA3, 0x80, 0x0F, 0x2A, 0x36, 0x07, 0xB0, 0xA0, 0x0F, 0xFF, 0xD9 }; } } /// /// 慢响应处理器 - 用于测试取消操作 /// public class SlowResponseHandler : HttpMessageHandler { private readonly int _delayMs; public SlowResponseHandler(int delayMs) { _delayMs = delayMs; } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { await Task.Delay(_delayMs, cancellationToken); var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 }) }; return response; } } #endregion }