Files
EmailBill/WebApi.Test/Service/IconSearch/IconSearchServiceTest.cs
SunCheng 9921cd5fdf 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
2026-02-16 21:55:38 +08:00

464 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}