390 lines
12 KiB
C#
390 lines
12 KiB
C#
|
|
using Service.IconSearch;
|
|||
|
|
using System.Text.Json;
|
|||
|
|
|
|||
|
|
namespace WebApi.Test.Service.IconSearch;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// IconifyApiService 单元测试
|
|||
|
|
/// </summary>
|
|||
|
|
public class IconifyApiServiceTest : BaseTest
|
|||
|
|
{
|
|||
|
|
private readonly IconifyApiService _service;
|
|||
|
|
private readonly IOptions<IconifySettings> _settings;
|
|||
|
|
private readonly ILogger<IconifyApiService> _logger;
|
|||
|
|
|
|||
|
|
public IconifyApiServiceTest()
|
|||
|
|
{
|
|||
|
|
_logger = Substitute.For<ILogger<IconifyApiService>>();
|
|||
|
|
_settings = Options.Create(new IconifySettings
|
|||
|
|
{
|
|||
|
|
ApiUrl = "https://api.iconify.design/search",
|
|||
|
|
DefaultLimit = 20,
|
|||
|
|
MaxRetryCount = 3,
|
|||
|
|
RetryDelayMs = 1000
|
|||
|
|
});
|
|||
|
|
_service = new IconifyApiService(_settings, _logger);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#region Response Parsing Tests
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 测试实际的 Iconify API 响应格式
|
|||
|
|
/// 实际 API 返回的 icons 是字符串数组,格式为 "collection:iconName"
|
|||
|
|
/// 例如:["mdi:home", "fa:food"]
|
|||
|
|
/// </summary>
|
|||
|
|
[Fact]
|
|||
|
|
public void IconifyApiResponse_应该正确解析实际API响应格式()
|
|||
|
|
{
|
|||
|
|
// Arrange - 这是从 Iconify API 实际返回的格式
|
|||
|
|
var json = @"{
|
|||
|
|
""icons"": [
|
|||
|
|
""svg-spinners:wind-toy"",
|
|||
|
|
""material-symbols:smart-toy"",
|
|||
|
|
""mdi:toy-brick"",
|
|||
|
|
""tabler:horse-toy""
|
|||
|
|
],
|
|||
|
|
""total"": 32,
|
|||
|
|
""limit"": 32,
|
|||
|
|
""start"": 0,
|
|||
|
|
""collections"": {
|
|||
|
|
""svg-spinners"": {
|
|||
|
|
""name"": ""SVG Spinners"",
|
|||
|
|
""total"": 46
|
|||
|
|
},
|
|||
|
|
""material-symbols"": {
|
|||
|
|
""name"": ""Material Symbols"",
|
|||
|
|
""total"": 15118
|
|||
|
|
},
|
|||
|
|
""mdi"": {
|
|||
|
|
""name"": ""Material Design Icons"",
|
|||
|
|
""total"": 7447
|
|||
|
|
},
|
|||
|
|
""tabler"": {
|
|||
|
|
""name"": ""Tabler Icons"",
|
|||
|
|
""total"": 5986
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}";
|
|||
|
|
|
|||
|
|
// Act
|
|||
|
|
var response = JsonSerializer.Deserialize<IconifyApiResponse>(json);
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
response.Should().NotBeNull();
|
|||
|
|
response!.Icons.Should().NotBeNull();
|
|||
|
|
response.Icons!.Count.Should().Be(4);
|
|||
|
|
|
|||
|
|
// 验证图标格式正确解析
|
|||
|
|
response.Icons[0].Should().Be("svg-spinners:wind-toy");
|
|||
|
|
response.Icons[1].Should().Be("material-symbols:smart-toy");
|
|||
|
|
response.Icons[2].Should().Be("mdi:toy-brick");
|
|||
|
|
response.Icons[3].Should().Be("tabler:horse-toy");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void IconifyApiResponse_旧格式测试_应该失败()
|
|||
|
|
{
|
|||
|
|
// Arrange - 旧的错误格式(这不是 Iconify 实际返回的)
|
|||
|
|
var json = @"{
|
|||
|
|
""icons"": [
|
|||
|
|
{
|
|||
|
|
""name"": ""home"",
|
|||
|
|
""collection"": {
|
|||
|
|
""name"": ""mdi""
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
""name"": ""food"",
|
|||
|
|
""collection"": {
|
|||
|
|
""name"": ""mdi""
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}";
|
|||
|
|
|
|||
|
|
// Act & Assert - 尝试用新的正确格式解析应该抛出异常
|
|||
|
|
var exception = Assert.Throws<JsonException>(() =>
|
|||
|
|
{
|
|||
|
|
JsonSerializer.Deserialize<IconifyApiResponse>(json);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 验证异常消息包含预期的错误信息
|
|||
|
|
exception.Message.Should().Contain("could not be converted to System.String");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void IconifyApiResponse_应该处理空Icons数组()
|
|||
|
|
{
|
|||
|
|
// Arrange
|
|||
|
|
var json = @"{ ""icons"": [] }";
|
|||
|
|
|
|||
|
|
// Act
|
|||
|
|
var response = JsonSerializer.Deserialize<IconifyApiResponse>(json);
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
response.Should().NotBeNull();
|
|||
|
|
response!.Icons.Should().NotBeNull();
|
|||
|
|
response.Icons!.Count.Should().Be(0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void IconifyApiResponse_应该处理null_Icons字段()
|
|||
|
|
{
|
|||
|
|
// Arrange
|
|||
|
|
var json = @"{}";
|
|||
|
|
|
|||
|
|
// Act
|
|||
|
|
var response = JsonSerializer.Deserialize<IconifyApiResponse>(json);
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
response.Should().NotBeNull();
|
|||
|
|
response!.Icons.Should().BeNull();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void IconCandidate_应该正确生成IconIdentifier()
|
|||
|
|
{
|
|||
|
|
// Arrange
|
|||
|
|
var candidate = new IconCandidate
|
|||
|
|
{
|
|||
|
|
CollectionName = "mdi",
|
|||
|
|
IconName = "home"
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Act
|
|||
|
|
var identifier = candidate.IconIdentifier;
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
identifier.Should().Be("mdi:home");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Theory]
|
|||
|
|
[InlineData("mdi", "food", "mdi:food")]
|
|||
|
|
[InlineData("fa", "shopping-cart", "fa:shopping-cart")]
|
|||
|
|
[InlineData("tabler", "airplane", "tabler:airplane")]
|
|||
|
|
[InlineData("fluent", "food-24-regular", "fluent:food-24-regular")]
|
|||
|
|
public void IconCandidate_应该支持多种图标集格式(string collection, string iconName, string expected)
|
|||
|
|
{
|
|||
|
|
// Arrange
|
|||
|
|
var candidate = new IconCandidate
|
|||
|
|
{
|
|||
|
|
CollectionName = collection,
|
|||
|
|
IconName = iconName
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Act
|
|||
|
|
var identifier = candidate.IconIdentifier;
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
identifier.Should().Be(expected);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Theory]
|
|||
|
|
[InlineData("mdi:food", "mdi", "food")]
|
|||
|
|
[InlineData("svg-spinners:wind-toy", "svg-spinners", "wind-toy")]
|
|||
|
|
[InlineData("material-symbols:smart-toy", "material-symbols", "smart-toy")]
|
|||
|
|
[InlineData("tabler:horse-toy", "tabler", "horse-toy")]
|
|||
|
|
[InlineData("game-icons:toy-mallet", "game-icons", "toy-mallet")]
|
|||
|
|
public void IconCandidate_应该从字符串标识符解析出CollectionName和IconName(
|
|||
|
|
string iconIdentifier,
|
|||
|
|
string expectedCollection,
|
|||
|
|
string expectedIconName)
|
|||
|
|
{
|
|||
|
|
// Arrange & Act - 从 "collection:iconName" 格式解析
|
|||
|
|
var parts = iconIdentifier.Split(':', 2);
|
|||
|
|
var candidate = new IconCandidate
|
|||
|
|
{
|
|||
|
|
CollectionName = parts[0],
|
|||
|
|
IconName = parts[1]
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
candidate.CollectionName.Should().Be(expectedCollection);
|
|||
|
|
candidate.IconName.Should().Be(expectedIconName);
|
|||
|
|
candidate.IconIdentifier.Should().Be(iconIdentifier);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void 从实际API响应解析IconCandidate列表()
|
|||
|
|
{
|
|||
|
|
// Arrange - 模拟实际 API 响应的字符串数组
|
|||
|
|
var iconStrings = new List<string>
|
|||
|
|
{
|
|||
|
|
"svg-spinners:wind-toy",
|
|||
|
|
"material-symbols:smart-toy",
|
|||
|
|
"mdi:toy-brick",
|
|||
|
|
"tabler:horse-toy"
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Act - 模拟 IconifyApiService 应该执行的解析逻辑
|
|||
|
|
var candidates = iconStrings
|
|||
|
|
.Select(iconStr =>
|
|||
|
|
{
|
|||
|
|
var parts = iconStr.Split(':', 2);
|
|||
|
|
return parts.Length == 2
|
|||
|
|
? new IconCandidate
|
|||
|
|
{
|
|||
|
|
CollectionName = parts[0],
|
|||
|
|
IconName = parts[1]
|
|||
|
|
}
|
|||
|
|
: null;
|
|||
|
|
})
|
|||
|
|
.Where(c => c != null)
|
|||
|
|
.ToList();
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
candidates.Should().HaveCount(4);
|
|||
|
|
candidates[0]!.CollectionName.Should().Be("svg-spinners");
|
|||
|
|
candidates[0]!.IconName.Should().Be("wind-toy");
|
|||
|
|
candidates[0]!.IconIdentifier.Should().Be("svg-spinners:wind-toy");
|
|||
|
|
|
|||
|
|
candidates[1]!.CollectionName.Should().Be("material-symbols");
|
|||
|
|
candidates[1]!.IconName.Should().Be("smart-toy");
|
|||
|
|
|
|||
|
|
candidates[2]!.CollectionName.Should().Be("mdi");
|
|||
|
|
candidates[2]!.IconName.Should().Be("toy-brick");
|
|||
|
|
|
|||
|
|
candidates[3]!.CollectionName.Should().Be("tabler");
|
|||
|
|
candidates[3]!.IconName.Should().Be("horse-toy");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region Settings Tests
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void IconifySettings_应该使用默认值()
|
|||
|
|
{
|
|||
|
|
// Arrange & Act
|
|||
|
|
var settings = new IconifySettings();
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
settings.ApiUrl.Should().Be("https://api.iconify.design/search");
|
|||
|
|
settings.DefaultLimit.Should().Be(20);
|
|||
|
|
settings.MaxRetryCount.Should().Be(3);
|
|||
|
|
settings.RetryDelayMs.Should().Be(1000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void IconifySettings_应该接受自定义配置()
|
|||
|
|
{
|
|||
|
|
// Arrange & Act
|
|||
|
|
var settings = new IconifySettings
|
|||
|
|
{
|
|||
|
|
ApiUrl = "https://custom-api.example.com/search",
|
|||
|
|
DefaultLimit = 50,
|
|||
|
|
MaxRetryCount = 5,
|
|||
|
|
RetryDelayMs = 2000
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
settings.ApiUrl.Should().Be("https://custom-api.example.com/search");
|
|||
|
|
settings.DefaultLimit.Should().Be(50);
|
|||
|
|
settings.MaxRetryCount.Should().Be(5);
|
|||
|
|
settings.RetryDelayMs.Should().Be(2000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region Integration Tests (需要网络连接)
|
|||
|
|
|
|||
|
|
// 注意:以下测试需要实际的网络连接,可能会失败
|
|||
|
|
// 在 CI/CD 环境中,建议使用 [Fact(Skip = "Requires network")] 或 mock HTTP 客户端
|
|||
|
|
|
|||
|
|
[Fact(Skip = "需要网络连接,仅用于手动测试")]
|
|||
|
|
public async Task SearchIconsAsync_应该返回有效图标列表()
|
|||
|
|
{
|
|||
|
|
// Arrange
|
|||
|
|
var keywords = new List<string> { "food", "restaurant" };
|
|||
|
|
|
|||
|
|
// Act
|
|||
|
|
var icons = await _service.SearchIconsAsync(keywords, 10);
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
icons.Should().NotBeNull();
|
|||
|
|
icons.Should().NotBeEmpty();
|
|||
|
|
icons.All(i => !string.IsNullOrEmpty(i.CollectionName)).Should().BeTrue();
|
|||
|
|
icons.All(i => !string.IsNullOrEmpty(i.IconName)).Should().BeTrue();
|
|||
|
|
icons.All(i => i.IconIdentifier.Contains(":")).Should().BeTrue();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact(Skip = "需要网络连接,仅用于手动测试")]
|
|||
|
|
public async Task SearchIconsAsync_应该处理无效关键字()
|
|||
|
|
{
|
|||
|
|
// Arrange
|
|||
|
|
var keywords = new List<string> { "xyzabc123nonexistent" };
|
|||
|
|
|
|||
|
|
// Act
|
|||
|
|
var icons = await _service.SearchIconsAsync(keywords, 10);
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
icons.Should().NotBeNull();
|
|||
|
|
// 可能返回空列表或少量图标
|
|||
|
|
icons.Count.Should().BeGreaterThanOrEqualTo(0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact(Skip = "需要网络连接,仅用于手动测试")]
|
|||
|
|
public async Task SearchIconsAsync_应该合并多个关键字的结果()
|
|||
|
|
{
|
|||
|
|
// Arrange
|
|||
|
|
var keywords = new List<string> { "home", "house" };
|
|||
|
|
|
|||
|
|
// Act
|
|||
|
|
var icons = await _service.SearchIconsAsync(keywords, 5);
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
icons.Should().NotBeNull();
|
|||
|
|
// 应该从两个关键字中获取图标
|
|||
|
|
icons.Count.Should().BeGreaterThan(0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public async Task SearchIconsAsync_应该处理空关键字列表()
|
|||
|
|
{
|
|||
|
|
// Arrange
|
|||
|
|
var keywords = new List<string>();
|
|||
|
|
|
|||
|
|
// Act
|
|||
|
|
var icons = await _service.SearchIconsAsync(keywords, 10);
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
icons.Should().NotBeNull();
|
|||
|
|
icons.Should().BeEmpty();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact(Skip = "需要网络连接,仅用于手动测试")]
|
|||
|
|
public async Task SearchIconsAsync_应该使用默认Limit()
|
|||
|
|
{
|
|||
|
|
// Arrange
|
|||
|
|
var keywords = new List<string> { "food" };
|
|||
|
|
|
|||
|
|
// Act
|
|||
|
|
var icons = await _service.SearchIconsAsync(keywords, 0); // limit=0 应该使用默认值20
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
icons.Should().NotBeNull();
|
|||
|
|
// 应该返回不超过20个图标(如果API有足够的结果)
|
|||
|
|
icons.Count.Should().BeLessThanOrEqualTo(20);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region Error Handling Tests
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public async Task SearchIconsAsync_应该处理部分关键字失败的情况()
|
|||
|
|
{
|
|||
|
|
// Arrange
|
|||
|
|
var keywords = new List<string> { "food" }; // 假设这个能成功
|
|||
|
|
|
|||
|
|
// Act
|
|||
|
|
var icons = await _service.SearchIconsAsync(keywords, 10);
|
|||
|
|
|
|||
|
|
// Assert
|
|||
|
|
// 即使部分关键字失败,也应该返回成功的结果
|
|||
|
|
icons.Should().NotBeNull();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
}
|