- 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
464 lines
15 KiB
C#
464 lines
15 KiB
C#
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
|
||
}
|