WorkCamera/client/WorkCameraExport.Tests/ImageServicePropertyTests.cs
2026-01-05 23:58:56 +08:00

376 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}