chore: migrate remaining ECharts components to Chart.js
- Migrated 4 components from ECharts to Chart.js: * MonthlyExpenseCard.vue (折线图) * DailyTrendChart.vue (双系列折线图) * ExpenseCategoryCard.vue (环形图) * BudgetChartAnalysis.vue (仪表盘 + 多种图表) - Removed all ECharts imports and environment variable switches - Unified all charts to use BaseChart.vue component - Build verified: pnpm build success ✓ - No echarts imports remaining ✓ Refs: openspec/changes/migrate-remaining-echarts-to-chartjs
This commit is contained in:
389
WebApi.Test/Service/IconSearch/IconifyApiServiceTest.cs
Normal file
389
WebApi.Test/Service/IconSearch/IconifyApiServiceTest.cs
Normal file
@@ -0,0 +1,389 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user