376 lines
15 KiB
C#
376 lines
15 KiB
C#
using FsCheck;
|
||
using FsCheck.Xunit;
|
||
using System.Net;
|
||
using WorkCameraExport.Services;
|
||
using Xunit;
|
||
|
||
namespace WorkCameraExport.Tests
|
||
{
|
||
/// <summary>
|
||
/// ImageService 属性测试
|
||
/// Feature: work-camera-2.0.1, Property 7: 并发下载控制
|
||
/// Validates: Requirements 6.4
|
||
/// </summary>
|
||
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
|
||
{
|
||
// 忽略清理错误
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Property 7: 并发下载控制
|
||
/// For any 图片下载任务,同时进行的下载数应不超过配置的并发数(默认 5)。
|
||
/// 测试:并发下载时,同时活动的下载数不应超过指定的并发数
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public Property ConcurrentDownload_ActiveDownloads_ShouldNotExceedConcurrency()
|
||
{
|
||
return Prop.ForAll(
|
||
Arb.From<PositiveInt>(),
|
||
Arb.From<PositiveInt>(),
|
||
(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}");
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Property 7 扩展: 并发数应被限制在有效范围内 (1-10)
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public Property ConcurrentDownload_ConcurrencyValue_ShouldBeClampedToValidRange()
|
||
{
|
||
return Prop.ForAll(
|
||
Arb.From<int>(),
|
||
(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");
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Property 7 扩展: 所有 URL 都应被处理
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public Property ConcurrentDownload_AllUrls_ShouldBeProcessed()
|
||
{
|
||
return Prop.ForAll(
|
||
Arb.From<PositiveInt>(),
|
||
Arb.From<PositiveInt>(),
|
||
(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}");
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Property 7 扩展: 进度报告应正确递增
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public Property ConcurrentDownload_Progress_ShouldIncrementCorrectly()
|
||
{
|
||
return Prop.ForAll(
|
||
Arb.From<PositiveInt>(),
|
||
(urlCountGen) =>
|
||
{
|
||
var urlCount = (urlCountGen.Get % 10) + 1;
|
||
var progressValues = new List<int>();
|
||
var progress = new Progress<int>(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}");
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Property 7 扩展: 空 URL 列表应返回空结果
|
||
/// </summary>
|
||
[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<string>(),
|
||
_testOutputDir,
|
||
5,
|
||
null,
|
||
CancellationToken.None);
|
||
|
||
Assert.Empty(results);
|
||
Assert.Equal(0, handler.TotalRequests);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Property 7 扩展: 取消操作应停止下载
|
||
/// </summary>
|
||
[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
|
||
|
||
/// <summary>
|
||
/// 跟踪并发请求的处理器
|
||
/// </summary>
|
||
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<HttpResponseMessage> 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
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 慢响应处理器 - 用于测试取消操作
|
||
/// </summary>
|
||
public class SlowResponseHandler : HttpMessageHandler
|
||
{
|
||
private readonly int _delayMs;
|
||
|
||
public SlowResponseHandler(int delayMs)
|
||
{
|
||
_delayMs = delayMs;
|
||
}
|
||
|
||
protected override async Task<HttpResponseMessage> 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
|
||
}
|