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:
463
WebApi.Test/Service/IconSearch/IconSearchServiceTest.cs
Normal file
463
WebApi.Test/Service/IconSearch/IconSearchServiceTest.cs
Normal file
@@ -0,0 +1,463 @@
|
||||
using Service.IconSearch;
|
||||
|
||||
namespace WebApi.Test.Service.IconSearch;
|
||||
|
||||
/// <summary>
|
||||
/// IconSearchService 单元测试
|
||||
/// </summary>
|
||||
public class IconSearchServiceTest : BaseTest
|
||||
{
|
||||
private readonly IconSearchService _service;
|
||||
private readonly ISearchKeywordGeneratorService _keywordGeneratorService;
|
||||
private readonly IIconifyApiService _iconifyApiService;
|
||||
private readonly ITransactionCategoryRepository _categoryRepository;
|
||||
private readonly ILogger<IconSearchService> _logger;
|
||||
|
||||
public IconSearchServiceTest()
|
||||
{
|
||||
_keywordGeneratorService = Substitute.For<ISearchKeywordGeneratorService>();
|
||||
_iconifyApiService = Substitute.For<IIconifyApiService>();
|
||||
_categoryRepository = Substitute.For<ITransactionCategoryRepository>();
|
||||
_logger = Substitute.For<ILogger<IconSearchService>>();
|
||||
|
||||
_service = new IconSearchService(
|
||||
_keywordGeneratorService,
|
||||
_iconifyApiService,
|
||||
_categoryRepository,
|
||||
_logger
|
||||
);
|
||||
}
|
||||
|
||||
#region GenerateSearchKeywordsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateSearchKeywordsAsync_应该委托给KeywordGeneratorService()
|
||||
{
|
||||
// Arrange
|
||||
const string categoryName = "餐饮";
|
||||
var expectedKeywords = new List<string> { "food", "restaurant", "dining" };
|
||||
|
||||
_keywordGeneratorService.GenerateKeywordsAsync(categoryName)
|
||||
.Returns(Task.FromResult(expectedKeywords));
|
||||
|
||||
// Act
|
||||
var keywords = await _service.GenerateSearchKeywordsAsync(categoryName);
|
||||
|
||||
// Assert
|
||||
keywords.Should().NotBeNull();
|
||||
keywords.Count.Should().Be(3);
|
||||
keywords.Should().BeEquivalentTo(expectedKeywords);
|
||||
|
||||
await _keywordGeneratorService.Received(1).GenerateKeywordsAsync(categoryName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateSearchKeywordsAsync_应该返回空列表当关键字生成失败()
|
||||
{
|
||||
// Arrange
|
||||
const string categoryName = "测试";
|
||||
_keywordGeneratorService.GenerateKeywordsAsync(categoryName)
|
||||
.Returns(Task.FromResult(new List<string>()));
|
||||
|
||||
// Act
|
||||
var keywords = await _service.GenerateSearchKeywordsAsync(categoryName);
|
||||
|
||||
// Assert
|
||||
keywords.Should().NotBeNull();
|
||||
keywords.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SearchIconsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SearchIconsAsync_应该返回图标候选列表()
|
||||
{
|
||||
// Arrange
|
||||
var keywords = new List<string> { "food", "restaurant" };
|
||||
var expectedIcons = new List<IconCandidate>
|
||||
{
|
||||
new() { CollectionName = "mdi", IconName = "food" },
|
||||
new() { CollectionName = "mdi", IconName = "restaurant" }
|
||||
};
|
||||
|
||||
_iconifyApiService.SearchIconsAsync(keywords, 20)
|
||||
.Returns(Task.FromResult(expectedIcons));
|
||||
|
||||
// Act
|
||||
var icons = await _service.SearchIconsAsync(keywords, 20);
|
||||
|
||||
// Assert
|
||||
icons.Should().NotBeNull();
|
||||
icons.Count.Should().Be(2);
|
||||
icons[0].IconIdentifier.Should().Be("mdi:food");
|
||||
icons[1].IconIdentifier.Should().Be("mdi:restaurant");
|
||||
|
||||
await _iconifyApiService.Received(1).SearchIconsAsync(keywords, 20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchIconsAsync_应该处理空关键字列表()
|
||||
{
|
||||
// Arrange
|
||||
var keywords = new List<string>();
|
||||
|
||||
// Act
|
||||
var icons = await _service.SearchIconsAsync(keywords, 20);
|
||||
|
||||
// Assert
|
||||
icons.Should().NotBeNull();
|
||||
icons.Should().BeEmpty();
|
||||
|
||||
// 验证没有调用 Iconify API
|
||||
await _iconifyApiService.DidNotReceive().SearchIconsAsync(Arg.Any<List<string>>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchIconsAsync_应该处理null关键字列表()
|
||||
{
|
||||
// Arrange
|
||||
List<string>? keywords = null;
|
||||
|
||||
// Act
|
||||
var icons = await _service.SearchIconsAsync(keywords!, 20);
|
||||
|
||||
// Assert
|
||||
icons.Should().NotBeNull();
|
||||
icons.Should().BeEmpty();
|
||||
|
||||
// 验证没有调用 Iconify API
|
||||
await _iconifyApiService.DidNotReceive().SearchIconsAsync(Arg.Any<List<string>>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchIconsAsync_应该使用指定的Limit()
|
||||
{
|
||||
// Arrange
|
||||
var keywords = new List<string> { "food" };
|
||||
var icons = new List<IconCandidate> { new() { CollectionName = "mdi", IconName = "food" } };
|
||||
|
||||
_iconifyApiService.SearchIconsAsync(keywords, 50)
|
||||
.Returns(Task.FromResult(icons));
|
||||
|
||||
// Act
|
||||
await _service.SearchIconsAsync(keywords, 50);
|
||||
|
||||
// Assert
|
||||
await _iconifyApiService.Received(1).SearchIconsAsync(keywords, 50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchIconsAsync_应该处理API返回空结果()
|
||||
{
|
||||
// Arrange
|
||||
var keywords = new List<string> { "nonexistent" };
|
||||
_iconifyApiService.SearchIconsAsync(keywords, 20)
|
||||
.Returns(Task.FromResult(new List<IconCandidate>()));
|
||||
|
||||
// Act
|
||||
var icons = await _service.SearchIconsAsync(keywords, 20);
|
||||
|
||||
// Assert
|
||||
icons.Should().NotBeNull();
|
||||
icons.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateCategoryIconAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateCategoryIconAsync_应该更新分类图标()
|
||||
{
|
||||
// Arrange
|
||||
const long categoryId = 12345L;
|
||||
const string iconIdentifier = "mdi:food";
|
||||
|
||||
var category = new TransactionCategory
|
||||
{
|
||||
Id = categoryId,
|
||||
Name = "餐饮",
|
||||
Type = TransactionType.Expense,
|
||||
Icon = null,
|
||||
IconKeywords = "[\"food\"]"
|
||||
};
|
||||
|
||||
_categoryRepository.GetByIdAsync(categoryId)
|
||||
.Returns(Task.FromResult<TransactionCategory?>(category));
|
||||
|
||||
// Act
|
||||
await _service.UpdateCategoryIconAsync(categoryId, iconIdentifier);
|
||||
|
||||
// Assert
|
||||
category.Icon.Should().Be(iconIdentifier);
|
||||
category.IconKeywords.Should().BeNull(); // IconKeywords 应该被清空
|
||||
|
||||
await _categoryRepository.Received(1).GetByIdAsync(categoryId);
|
||||
await _categoryRepository.Received(1).UpdateAsync(category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateCategoryIconAsync_应该抛出异常当图标标识符为空()
|
||||
{
|
||||
// Arrange
|
||||
const long categoryId = 12345L;
|
||||
const string iconIdentifier = "";
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(() => _service.UpdateCategoryIconAsync(categoryId, iconIdentifier))
|
||||
.Should().ThrowAsync<ArgumentException>()
|
||||
.WithMessage("*图标标识符不能为空*");
|
||||
|
||||
// 验证没有调用 repository
|
||||
await _categoryRepository.DidNotReceive().GetByIdAsync(Arg.Any<long>());
|
||||
await _categoryRepository.DidNotReceive().UpdateAsync(Arg.Any<TransactionCategory>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public async Task UpdateCategoryIconAsync_应该抛出异常当图标标识符无效(string iconIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
const long categoryId = 12345L;
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(() => _service.UpdateCategoryIconAsync(categoryId, iconIdentifier))
|
||||
.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateCategoryIconAsync_应该抛出异常当分类不存在()
|
||||
{
|
||||
// Arrange
|
||||
const long categoryId = 99999L;
|
||||
const string iconIdentifier = "mdi:food";
|
||||
|
||||
_categoryRepository.GetByIdAsync(categoryId)
|
||||
.Returns(Task.FromResult<TransactionCategory?>(null));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(() => _service.UpdateCategoryIconAsync(categoryId, iconIdentifier))
|
||||
.Should().ThrowAsync<Exception>()
|
||||
.WithMessage($"*分类不存在,ID:{categoryId}*");
|
||||
|
||||
// 验证没有调用 Update
|
||||
await _categoryRepository.DidNotReceive().UpdateAsync(Arg.Any<TransactionCategory>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateCategoryIconAsync_应该清空IconKeywords字段()
|
||||
{
|
||||
// Arrange
|
||||
const long categoryId = 12345L;
|
||||
const string iconIdentifier = "mdi:restaurant";
|
||||
|
||||
var category = new TransactionCategory
|
||||
{
|
||||
Id = categoryId,
|
||||
Name = "餐饮",
|
||||
Type = TransactionType.Expense,
|
||||
Icon = "mdi:food",
|
||||
IconKeywords = "[\"food\", \"restaurant\"]"
|
||||
};
|
||||
|
||||
_categoryRepository.GetByIdAsync(categoryId)
|
||||
.Returns(Task.FromResult<TransactionCategory?>(category));
|
||||
|
||||
// Act
|
||||
await _service.UpdateCategoryIconAsync(categoryId, iconIdentifier);
|
||||
|
||||
// Assert
|
||||
category.IconKeywords.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-to-End Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EndToEnd_完整流程_生成关键字到搜索图标到更新分类()
|
||||
{
|
||||
// Arrange - Step 1: 生成搜索关键字
|
||||
const string categoryName = "餐饮";
|
||||
var keywords = new List<string> { "food", "restaurant", "dining" };
|
||||
_keywordGeneratorService.GenerateKeywordsAsync(categoryName)
|
||||
.Returns(Task.FromResult(keywords));
|
||||
|
||||
// Arrange - Step 2: 搜索图标
|
||||
var icons = new List<IconCandidate>
|
||||
{
|
||||
new() { CollectionName = "mdi", IconName = "food" },
|
||||
new() { CollectionName = "mdi", IconName = "restaurant" },
|
||||
new() { CollectionName = "fa", IconName = "utensils" }
|
||||
};
|
||||
_iconifyApiService.SearchIconsAsync(keywords, 20)
|
||||
.Returns(Task.FromResult(icons));
|
||||
|
||||
// Arrange - Step 3: 更新分类图标
|
||||
const long categoryId = 12345L;
|
||||
const string selectedIconIdentifier = "mdi:food";
|
||||
var category = new TransactionCategory
|
||||
{
|
||||
Id = categoryId,
|
||||
Name = categoryName,
|
||||
Type = TransactionType.Expense,
|
||||
Icon = null,
|
||||
IconKeywords = null
|
||||
};
|
||||
_categoryRepository.GetByIdAsync(categoryId)
|
||||
.Returns(Task.FromResult<TransactionCategory?>(category));
|
||||
|
||||
// Act - Step 1: 生成关键字
|
||||
var generatedKeywords = await _service.GenerateSearchKeywordsAsync(categoryName);
|
||||
|
||||
// Assert - Step 1
|
||||
generatedKeywords.Should().BeEquivalentTo(keywords);
|
||||
|
||||
// Act - Step 2: 搜索图标
|
||||
var searchedIcons = await _service.SearchIconsAsync(generatedKeywords, 20);
|
||||
|
||||
// Assert - Step 2
|
||||
searchedIcons.Should().HaveCount(3);
|
||||
searchedIcons[0].IconIdentifier.Should().Be("mdi:food");
|
||||
searchedIcons[1].IconIdentifier.Should().Be("mdi:restaurant");
|
||||
searchedIcons[2].IconIdentifier.Should().Be("fa:utensils");
|
||||
|
||||
// Act - Step 3: 更新分类图标
|
||||
await _service.UpdateCategoryIconAsync(categoryId, selectedIconIdentifier);
|
||||
|
||||
// Assert - Step 3
|
||||
category.Icon.Should().Be(selectedIconIdentifier);
|
||||
category.IconKeywords.Should().BeNull();
|
||||
|
||||
// 验证所有服务都被调用
|
||||
await _keywordGeneratorService.Received(1).GenerateKeywordsAsync(categoryName);
|
||||
await _iconifyApiService.Received(1).SearchIconsAsync(keywords, 20);
|
||||
await _categoryRepository.Received(1).GetByIdAsync(categoryId);
|
||||
await _categoryRepository.Received(1).UpdateAsync(category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndToEnd_关键字生成失败_应该返回空图标列表()
|
||||
{
|
||||
// Arrange
|
||||
const string categoryName = "未知分类";
|
||||
_keywordGeneratorService.GenerateKeywordsAsync(categoryName)
|
||||
.Returns(Task.FromResult(new List<string>()));
|
||||
|
||||
// Act - Step 1: 生成关键字
|
||||
var keywords = await _service.GenerateSearchKeywordsAsync(categoryName);
|
||||
|
||||
// Act - Step 2: 搜索图标(使用空关键字)
|
||||
var icons = await _service.SearchIconsAsync(keywords, 20);
|
||||
|
||||
// Assert
|
||||
keywords.Should().BeEmpty();
|
||||
icons.Should().BeEmpty();
|
||||
|
||||
// 验证 Iconify API 没有被调用
|
||||
await _iconifyApiService.DidNotReceive().SearchIconsAsync(Arg.Any<List<string>>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndToEnd_图标搜索返回空_应该处理正常()
|
||||
{
|
||||
// Arrange
|
||||
const string categoryName = "测试分类";
|
||||
var keywords = new List<string> { "test" };
|
||||
_keywordGeneratorService.GenerateKeywordsAsync(categoryName)
|
||||
.Returns(Task.FromResult(keywords));
|
||||
_iconifyApiService.SearchIconsAsync(keywords, 20)
|
||||
.Returns(Task.FromResult(new List<IconCandidate>()));
|
||||
|
||||
// Act
|
||||
var generatedKeywords = await _service.GenerateSearchKeywordsAsync(categoryName);
|
||||
var icons = await _service.SearchIconsAsync(generatedKeywords, 20);
|
||||
|
||||
// Assert
|
||||
generatedKeywords.Should().NotBeEmpty();
|
||||
icons.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndToEnd_更新不存在的分类_应该抛出异常()
|
||||
{
|
||||
// Arrange
|
||||
const string categoryName = "餐饮";
|
||||
var keywords = new List<string> { "food" };
|
||||
var icons = new List<IconCandidate> { new() { CollectionName = "mdi", IconName = "food" } };
|
||||
|
||||
_keywordGeneratorService.GenerateKeywordsAsync(categoryName)
|
||||
.Returns(Task.FromResult(keywords));
|
||||
_iconifyApiService.SearchIconsAsync(keywords, 20)
|
||||
.Returns(Task.FromResult(icons));
|
||||
_categoryRepository.GetByIdAsync(Arg.Any<long>())
|
||||
.Returns(Task.FromResult<TransactionCategory?>(null));
|
||||
|
||||
// Act
|
||||
var generatedKeywords = await _service.GenerateSearchKeywordsAsync(categoryName);
|
||||
var searchedIcons = await _service.SearchIconsAsync(generatedKeywords, 20);
|
||||
|
||||
// Assert - 前两步成功
|
||||
generatedKeywords.Should().NotBeEmpty();
|
||||
searchedIcons.Should().NotBeEmpty();
|
||||
|
||||
// Act & Assert - 第三步失败
|
||||
await FluentActions.Invoking(() => _service.UpdateCategoryIconAsync(99999L, "mdi:food"))
|
||||
.Should().ThrowAsync<Exception>()
|
||||
.WithMessage("*分类不存在*");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public async Task SearchIconsAsync_应该处理大量关键字()
|
||||
{
|
||||
// Arrange
|
||||
var keywords = Enumerable.Range(1, 100).Select(i => $"keyword{i}").ToList();
|
||||
var icons = new List<IconCandidate> { new() { CollectionName = "mdi", IconName = "test" } };
|
||||
|
||||
_iconifyApiService.SearchIconsAsync(keywords, 20)
|
||||
.Returns(Task.FromResult(icons));
|
||||
|
||||
// Act
|
||||
var result = await _service.SearchIconsAsync(keywords, 20);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
await _iconifyApiService.Received(1).SearchIconsAsync(keywords, 20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateCategoryIconAsync_应该处理超长图标标识符()
|
||||
{
|
||||
// Arrange
|
||||
const long categoryId = 12345L;
|
||||
var longIconIdentifier = new string('a', 100); // 超过50字符的限制
|
||||
|
||||
var category = new TransactionCategory
|
||||
{
|
||||
Id = categoryId,
|
||||
Name = "测试",
|
||||
Type = TransactionType.Expense
|
||||
};
|
||||
|
||||
_categoryRepository.GetByIdAsync(categoryId)
|
||||
.Returns(Task.FromResult<TransactionCategory?>(category));
|
||||
|
||||
// Act
|
||||
await _service.UpdateCategoryIconAsync(categoryId, longIconIdentifier);
|
||||
|
||||
// Assert
|
||||
// 服务层不验证长度,由数据库层处理
|
||||
category.Icon.Should().Be(longIconIdentifier);
|
||||
await _categoryRepository.Received(1).UpdateAsync(category);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user