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:
SunCheng
2026-02-16 21:55:38 +08:00
parent a88556c784
commit 9921cd5fdf
77 changed files with 6964 additions and 1632 deletions

View 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
}