using Service.IconSearch; using System.Text.Json; namespace WebApi.Test.Service.IconSearch; /// /// IconifyApiService 单元测试 /// public class IconifyApiServiceTest : BaseTest { private readonly IconifyApiService _service; private readonly IOptions _settings; private readonly ILogger _logger; public IconifyApiServiceTest() { _logger = Substitute.For>(); _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 /// /// 测试实际的 Iconify API 响应格式 /// 实际 API 返回的 icons 是字符串数组,格式为 "collection:iconName" /// 例如:["mdi:home", "fa:food"] /// [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(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(() => { JsonSerializer.Deserialize(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(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(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 { "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 { "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 { "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 { "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(); // 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 { "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 { "food" }; // 假设这个能成功 // Act var icons = await _service.SearchIconsAsync(keywords, 10); // Assert // 即使部分关键字失败,也应该返回成功的结果 icons.Should().NotBeNull(); } #endregion }