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,443 @@
using Application.Dto.Icon;
using Service.IconSearch;
using WebApi.Controllers;
namespace WebApi.Test.Controllers;
/// <summary>
/// IconController 集成测试
/// </summary>
public class IconControllerTest : BaseTest
{
private readonly IconController _controller;
private readonly IIconSearchService _iconSearchService;
private readonly ILogger<IconController> _logger;
public IconControllerTest()
{
_iconSearchService = Substitute.For<IIconSearchService>();
_logger = Substitute.For<ILogger<IconController>>();
_controller = new IconController(_iconSearchService, _logger);
}
#region POST /api/icons/search-keywords Tests
[Fact]
public async Task GenerateSearchKeywords_应该返回成功响应()
{
// Arrange
var request = new SearchKeywordsRequest { CategoryName = "餐饮" };
var expectedKeywords = new List<string> { "food", "restaurant", "dining" };
_iconSearchService.GenerateSearchKeywordsAsync(request.CategoryName)
.Returns(Task.FromResult(expectedKeywords));
// Act
var response = await _controller.GenerateSearchKeywordsAsync(request);
// Assert
response.Should().NotBeNull();
response.Success.Should().BeTrue();
response.Data.Should().NotBeNull();
response.Data!.Keywords.Should().BeEquivalentTo(expectedKeywords);
await _iconSearchService.Received(1).GenerateSearchKeywordsAsync(request.CategoryName);
}
[Fact]
public async Task GenerateSearchKeywords_应该处理空关键字列表()
{
// Arrange
var request = new SearchKeywordsRequest { CategoryName = "未知分类" };
_iconSearchService.GenerateSearchKeywordsAsync(request.CategoryName)
.Returns(Task.FromResult(new List<string>()));
// Act
var response = await _controller.GenerateSearchKeywordsAsync(request);
// Assert
response.Should().NotBeNull();
response.Success.Should().BeTrue();
response.Data.Should().NotBeNull();
response.Data!.Keywords.Should().BeEmpty();
}
[Theory]
[InlineData("餐饮")]
[InlineData("交通")]
[InlineData("购物")]
[InlineData("医疗")]
public async Task GenerateSearchKeywords_应该处理不同的分类名称(string categoryName)
{
// Arrange
var request = new SearchKeywordsRequest { CategoryName = categoryName };
var keywords = new List<string> { "test1", "test2" };
_iconSearchService.GenerateSearchKeywordsAsync(categoryName)
.Returns(Task.FromResult(keywords));
// Act
var response = await _controller.GenerateSearchKeywordsAsync(request);
// Assert
response.Should().NotBeNull();
response.Success.Should().BeTrue();
response.Data!.Keywords.Should().HaveCount(2);
}
[Fact]
public async Task GenerateSearchKeywords_应该处理服务层异常()
{
// Arrange
var request = new SearchKeywordsRequest { CategoryName = "测试" };
_iconSearchService.GenerateSearchKeywordsAsync(request.CategoryName)
.Returns<List<string>>(_ => throw new Exception("服务异常"));
// Act & Assert
await FluentActions.Invoking(() => _controller.GenerateSearchKeywordsAsync(request))
.Should().ThrowAsync<Exception>()
.WithMessage("服务异常");
}
#endregion
#region POST /api/icons/search Tests
[Fact]
public async Task SearchIcons_应该返回图标候选列表()
{
// Arrange
var request = new SearchIconsRequest { Keywords = new List<string> { "food", "restaurant" } };
var icons = new List<IconCandidate>
{
new() { CollectionName = "mdi", IconName = "food" },
new() { CollectionName = "mdi", IconName = "restaurant" },
new() { CollectionName = "fa", IconName = "utensils" }
};
_iconSearchService.SearchIconsAsync(request.Keywords, 20)
.Returns(Task.FromResult(icons));
// Act
var response = await _controller.SearchIconsAsync(request);
// Assert
response.Should().NotBeNull();
response.Success.Should().BeTrue();
response.Data.Should().NotBeNull();
response.Data!.Count.Should().Be(3);
response.Data[0].IconIdentifier.Should().Be("mdi:food");
response.Data[1].IconIdentifier.Should().Be("mdi:restaurant");
response.Data[2].IconIdentifier.Should().Be("fa:utensils");
await _iconSearchService.Received(1).SearchIconsAsync(request.Keywords, 20);
}
[Fact]
public async Task SearchIcons_应该处理空结果()
{
// Arrange
var request = new SearchIconsRequest { Keywords = new List<string> { "nonexistent" } };
_iconSearchService.SearchIconsAsync(request.Keywords, 20)
.Returns(Task.FromResult(new List<IconCandidate>()));
// Act
var response = await _controller.SearchIconsAsync(request);
// Assert
response.Should().NotBeNull();
response.Success.Should().BeTrue();
response.Data.Should().NotBeNull();
response.Data!.Should().BeEmpty();
}
[Fact]
public async Task SearchIcons_应该正确映射IconCandidate到IconCandidateDto()
{
// Arrange
var request = new SearchIconsRequest { Keywords = new List<string> { "home" } };
var icons = new List<IconCandidate>
{
new() { CollectionName = "mdi", IconName = "home" }
};
_iconSearchService.SearchIconsAsync(request.Keywords, 20)
.Returns(Task.FromResult(icons));
// Act
var response = await _controller.SearchIconsAsync(request);
// Assert
response.Data!.Count.Should().Be(1);
var dto = response.Data[0];
dto.CollectionName.Should().Be("mdi");
dto.IconName.Should().Be("home");
dto.IconIdentifier.Should().Be("mdi:home");
}
[Fact]
public async Task SearchIcons_应该处理多个关键字()
{
// Arrange
var keywords = new List<string> { "food", "restaurant", "dining", "eat", "meal" };
var request = new SearchIconsRequest { Keywords = keywords };
var icons = new List<IconCandidate>
{
new() { CollectionName = "mdi", IconName = "food" }
};
_iconSearchService.SearchIconsAsync(keywords, 20)
.Returns(Task.FromResult(icons));
// Act
var response = await _controller.SearchIconsAsync(request);
// Assert
response.Should().NotBeNull();
response.Success.Should().BeTrue();
await _iconSearchService.Received(1).SearchIconsAsync(keywords, 20);
}
[Fact]
public async Task SearchIcons_应该使用固定的Limit值20()
{
// Arrange
var request = new SearchIconsRequest { Keywords = new List<string> { "test" } };
_iconSearchService.SearchIconsAsync(request.Keywords, 20)
.Returns(Task.FromResult(new List<IconCandidate>()));
// Act
await _controller.SearchIconsAsync(request);
// Assert
// 验证使用的是固定的 limit=20
await _iconSearchService.Received(1).SearchIconsAsync(request.Keywords, 20);
}
#endregion
#region PUT /api/icons/categories/{categoryId}/icon Tests
[Fact]
public async Task UpdateCategoryIcon_应该返回成功响应()
{
// Arrange
const long categoryId = 12345L;
var request = new UpdateCategoryIconRequest { IconIdentifier = "mdi:food" };
_iconSearchService.UpdateCategoryIconAsync(categoryId, request.IconIdentifier)
.Returns(Task.CompletedTask);
// Act
var response = await _controller.UpdateCategoryIconAsync(categoryId, request);
// Assert
response.Should().NotBeNull();
response.Success.Should().BeTrue();
response.Message.Should().Be("更新分类图标成功");
await _iconSearchService.Received(1).UpdateCategoryIconAsync(categoryId, request.IconIdentifier);
}
[Fact]
public async Task UpdateCategoryIcon_应该处理不同的分类ID()
{
// Arrange
const long categoryId = 99999L;
var request = new UpdateCategoryIconRequest { IconIdentifier = "fa:shopping-cart" };
_iconSearchService.UpdateCategoryIconAsync(categoryId, request.IconIdentifier)
.Returns(Task.CompletedTask);
// Act
var response = await _controller.UpdateCategoryIconAsync(categoryId, request);
// Assert
response.Success.Should().BeTrue();
await _iconSearchService.Received(1).UpdateCategoryIconAsync(categoryId, request.IconIdentifier);
}
[Fact]
public async Task UpdateCategoryIcon_应该处理不同的图标标识符()
{
// Arrange
const long categoryId = 12345L;
var request = new UpdateCategoryIconRequest { IconIdentifier = "tabler:airplane" };
_iconSearchService.UpdateCategoryIconAsync(categoryId, request.IconIdentifier)
.Returns(Task.CompletedTask);
// Act
var response = await _controller.UpdateCategoryIconAsync(categoryId, request);
// Assert
response.Success.Should().BeTrue();
await _iconSearchService.Received(1).UpdateCategoryIconAsync(categoryId, request.IconIdentifier);
}
[Fact]
public async Task UpdateCategoryIcon_应该处理分类不存在异常()
{
// Arrange
const long categoryId = 99999L;
var request = new UpdateCategoryIconRequest { IconIdentifier = "mdi:food" };
_iconSearchService.UpdateCategoryIconAsync(categoryId, request.IconIdentifier)
.Returns<Task>(_ => throw new Exception("分类不存在"));
// Act & Assert
await FluentActions.Invoking(() => _controller.UpdateCategoryIconAsync(categoryId, request))
.Should().ThrowAsync<Exception>()
.WithMessage("分类不存在");
}
[Fact]
public async Task UpdateCategoryIcon_应该处理无效图标标识符异常()
{
// Arrange
const long categoryId = 12345L;
var request = new UpdateCategoryIconRequest { IconIdentifier = "" };
_iconSearchService.UpdateCategoryIconAsync(categoryId, request.IconIdentifier)
.Returns<Task>(_ => throw new ArgumentException("图标标识符不能为空"));
// Act & Assert
await FluentActions.Invoking(() => _controller.UpdateCategoryIconAsync(categoryId, request))
.Should().ThrowAsync<ArgumentException>()
.WithMessage("*图标标识符不能为空*");
}
#endregion
#region Integration Flow Tests
[Fact]
public async Task IntegrationFlow_完整流程_生成关键字到搜索到更新()
{
// Arrange - Step 1: 生成搜索关键字
var keywordsRequest = new SearchKeywordsRequest { CategoryName = "餐饮" };
var keywords = new List<string> { "food", "restaurant" };
_iconSearchService.GenerateSearchKeywordsAsync(keywordsRequest.CategoryName)
.Returns(Task.FromResult(keywords));
// Arrange - Step 2: 搜索图标
var searchRequest = new SearchIconsRequest { Keywords = keywords };
var icons = new List<IconCandidate>
{
new() { CollectionName = "mdi", IconName = "food" },
new() { CollectionName = "mdi", IconName = "restaurant" }
};
_iconSearchService.SearchIconsAsync(keywords, 20)
.Returns(Task.FromResult(icons));
// Arrange - Step 3: 更新分类图标
const long categoryId = 12345L;
var updateRequest = new UpdateCategoryIconRequest { IconIdentifier = "mdi:food" };
_iconSearchService.UpdateCategoryIconAsync(categoryId, updateRequest.IconIdentifier)
.Returns(Task.CompletedTask);
// Act - Step 1: 生成关键字
var keywordsResponse = await _controller.GenerateSearchKeywordsAsync(keywordsRequest);
// Assert - Step 1
keywordsResponse.Success.Should().BeTrue();
keywordsResponse.Data!.Keywords.Should().BeEquivalentTo(keywords);
// Act - Step 2: 搜索图标
var searchResponse = await _controller.SearchIconsAsync(searchRequest);
// Assert - Step 2
searchResponse.Success.Should().BeTrue();
searchResponse.Data!.Count.Should().Be(2);
// Act - Step 3: 更新分类图标
var updateResponse = await _controller.UpdateCategoryIconAsync(categoryId, updateRequest);
// Assert - Step 3
updateResponse.Success.Should().BeTrue();
// 验证所有服务调用
await _iconSearchService.Received(1).GenerateSearchKeywordsAsync(keywordsRequest.CategoryName);
await _iconSearchService.Received(1).SearchIconsAsync(keywords, 20);
await _iconSearchService.Received(1).UpdateCategoryIconAsync(categoryId, updateRequest.IconIdentifier);
}
[Fact]
public async Task IntegrationFlow_部分失败_关键字生成返回空()
{
// Arrange
var keywordsRequest = new SearchKeywordsRequest { CategoryName = "未知" };
_iconSearchService.GenerateSearchKeywordsAsync(keywordsRequest.CategoryName)
.Returns(Task.FromResult(new List<string>()));
// Act - Step 1: 生成关键字
var keywordsResponse = await _controller.GenerateSearchKeywordsAsync(keywordsRequest);
// Assert
keywordsResponse.Success.Should().BeTrue();
keywordsResponse.Data!.Keywords.Should().BeEmpty();
// 空关键字列表仍然可以传递给搜索接口,但通常会返回空结果
var searchRequest = new SearchIconsRequest { Keywords = keywordsResponse.Data.Keywords };
_iconSearchService.SearchIconsAsync(searchRequest.Keywords, 20)
.Returns(Task.FromResult(new List<IconCandidate>()));
var searchResponse = await _controller.SearchIconsAsync(searchRequest);
searchResponse.Success.Should().BeTrue();
searchResponse.Data!.Should().BeEmpty();
}
#endregion
#region DTO Validation Tests
[Fact]
public async Task SearchKeywordsRequest_应该包含CategoryName字段()
{
// Arrange
var request = new SearchKeywordsRequest { CategoryName = "测试" };
// Assert
request.CategoryName.Should().Be("测试");
}
[Fact]
public async Task SearchIconsRequest_应该包含Keywords字段()
{
// Arrange
var keywords = new List<string> { "test1", "test2" };
var request = new SearchIconsRequest { Keywords = keywords };
// Assert
request.Keywords.Should().BeEquivalentTo(keywords);
}
[Fact]
public async Task UpdateCategoryIconRequest_应该包含IconIdentifier字段()
{
// Arrange
var request = new UpdateCategoryIconRequest { IconIdentifier = "mdi:test" };
// Assert
request.IconIdentifier.Should().Be("mdi:test");
}
[Fact]
public async Task IconCandidateDto_应该包含所有必需字段()
{
// Arrange
var dto = new IconCandidateDto
{
CollectionName = "mdi",
IconName = "food",
IconIdentifier = "mdi:food"
};
// Assert
dto.CollectionName.Should().Be("mdi");
dto.IconName.Should().Be("food");
dto.IconIdentifier.Should().Be("mdi:food");
}
#endregion
}

View File

@@ -0,0 +1,181 @@
namespace WebApi.Test.Entity;
/// <summary>
/// TransactionCategory 实体测试
/// </summary>
public class TransactionCategoryTest : BaseTest
{
[Fact]
public void Icon字段_应该接受Iconify标识符格式()
{
// Arrange
var category = new TransactionCategory
{
Name = "餐饮",
Type = TransactionType.Expense
};
// Act
category.Icon = "mdi:food";
// Assert
category.Icon.Should().Be("mdi:food");
category.Icon.Should().Contain(":");
category.Icon.Length.Should().BeLessThanOrEqualTo(50);
}
[Fact]
public void Icon字段_应该允许为空()
{
// Arrange & Act
var category = new TransactionCategory
{
Name = "交通",
Type = TransactionType.Expense,
Icon = null
};
// Assert
category.Icon.Should().BeNull();
}
[Fact]
public void Icon字段_应该遵守长度限制50字符()
{
// Arrange
var category = new TransactionCategory
{
Name = "购物",
Type = TransactionType.Expense
};
var validIcon = "mdi:shopping-cart"; // 合法长度
var tooLongIcon = new string('a', 51); // 超过50字符
// Act
category.Icon = validIcon;
// Assert
category.Icon.Should().Be(validIcon);
category.Icon.Length.Should().BeLessThanOrEqualTo(50);
// 验证长度限制(实际数据库插入时会被截断或报错)
tooLongIcon.Length.Should().BeGreaterThan(50);
}
[Fact]
public void IconKeywords字段_应该接受JSON数组格式()
{
// Arrange
var category = new TransactionCategory
{
Name = "餐饮",
Type = TransactionType.Expense
};
// Act
category.IconKeywords = "[\"food\", \"restaurant\", \"dining\"]";
// Assert
category.IconKeywords.Should().NotBeNullOrEmpty();
category.IconKeywords.Should().StartWith("[");
category.IconKeywords.Should().EndWith("]");
category.IconKeywords.Should().Contain("food");
}
[Fact]
public void IconKeywords字段_应该允许为空()
{
// Arrange & Act
var category = new TransactionCategory
{
Name = "交通",
Type = TransactionType.Expense,
IconKeywords = null
};
// Assert
category.IconKeywords.Should().BeNull();
}
[Fact]
public void IconKeywords字段_应该遵守长度限制200字符()
{
// Arrange
var category = new TransactionCategory
{
Name = "旅游",
Type = TransactionType.Expense
};
var validKeywords = "[\"travel\", \"vacation\", \"tourism\", \"trip\"]"; // 合法长度
var tooLongKeywords = "[" + string.Join(",", Enumerable.Repeat("\"keyword\"", 30)) + "]"; // 超过200字符
// Act
category.IconKeywords = validKeywords;
// Assert
category.IconKeywords.Should().Be(validKeywords);
category.IconKeywords.Length.Should().BeLessThanOrEqualTo(200);
// 验证长度限制
tooLongKeywords.Length.Should().BeGreaterThan(200);
}
[Fact]
public void TransactionCategory_应该继承自BaseEntity()
{
// Arrange & Act
var category = new TransactionCategory
{
Name = "测试分类",
Type = TransactionType.Expense
};
// Assert
category.Should().BeAssignableTo<BaseEntity>();
category.Id.Should().BeGreaterThan(0); // Snowflake ID
category.CreateTime.Should().BeCloseTo(DateTime.Now, TimeSpan.FromSeconds(1));
}
[Fact]
public void TransactionCategory_应该包含必需字段()
{
// Arrange & Act
var category = new TransactionCategory
{
Name = "餐饮",
Type = TransactionType.Expense,
Icon = "mdi:food",
IconKeywords = "[\"food\"]"
};
// Assert
category.Name.Should().NotBeNullOrEmpty();
category.Type.Should().Be(TransactionType.Expense);
category.Icon.Should().NotBeNullOrEmpty();
category.IconKeywords.Should().NotBeNullOrEmpty();
}
[Theory]
[InlineData("mdi:home")]
[InlineData("fa:shopping-cart")]
[InlineData("tabler:airplane")]
[InlineData("fluent:food-24-regular")]
public void Icon字段_应该支持多种图标集格式(string iconIdentifier)
{
// Arrange
var category = new TransactionCategory
{
Name = "测试",
Type = TransactionType.Expense
};
// Act
category.Icon = iconIdentifier;
// Assert
category.Icon.Should().Be(iconIdentifier);
category.Icon.Should().Contain(":");
}
}

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

View File

@@ -0,0 +1,96 @@
using Service.IconSearch;
namespace WebApi.Test.Service.IconSearch;
/// <summary>
/// Iconify API 集成测试(需要网络连接)
/// 用于验证完整的图标搜索流程
/// </summary>
public class IconifyApiIntegrationTest : BaseTest
{
private readonly IconifyApiService _service;
private readonly ILogger<IconifyApiService> _logger;
public IconifyApiIntegrationTest()
{
_logger = Substitute.For<ILogger<IconifyApiService>>();
var settings = Options.Create(new IconifySettings
{
ApiUrl = "https://api.iconify.design/search",
DefaultLimit = 20,
MaxRetryCount = 3,
RetryDelayMs = 1000
});
_service = new IconifyApiService(settings, _logger);
}
[Fact]
public async Task _搜索玩具图标()
{
// Arrange
var keywords = new List<string> { "toy", "play" };
// Act
var icons = await _service.SearchIconsAsync(keywords, 10);
// Assert
icons.Should().NotBeNull();
icons.Should().NotBeEmpty("应该返回玩具相关的图标");
// 验证图标格式
icons.All(i => !string.IsNullOrEmpty(i.CollectionName)).Should().BeTrue("所有图标应该有 CollectionName");
icons.All(i => !string.IsNullOrEmpty(i.IconName)).Should().BeTrue("所有图标应该有 IconName");
icons.All(i => i.IconIdentifier.Contains(":")).Should().BeTrue("所有图标的 IconIdentifier 应该包含 ':'");
// 打印前5个图标用于验证
_logger.LogInformation("搜索到 {Count} 个图标", icons.Count);
foreach (var icon in icons.Take(5))
{
_logger.LogInformation(" - {Identifier} (Collection: {Collection}, Name: {Name})",
icon.IconIdentifier, icon.CollectionName, icon.IconName);
}
}
[Fact]
public async Task _搜索食物图标()
{
// Arrange
var keywords = new List<string> { "food", "meal", "restaurant" };
// Act
var icons = await _service.SearchIconsAsync(keywords, 15);
// Assert
icons.Should().NotBeNull();
icons.Should().NotBeEmpty("应该返回食物相关的图标");
icons.Count.Should().BeGreaterThan(0);
// 验证至少有一些常见的图标集
var collections = icons.Select(i => i.CollectionName).Distinct().ToList();
collections.Should().Contain(c => c == "mdi" || c == "material-symbols" || c == "tabler",
"应该包含常见的图标集");
}
[Fact]
public async Task ()
{
// Arrange
var keywords = new List<string> { "home" };
// Act
var icons = await _service.SearchIconsAsync(keywords, 5);
// Assert
icons.Should().NotBeNull();
icons.Should().NotBeEmpty();
foreach (var icon in icons)
{
// 验证标识符格式: "collection:iconName"
var parts = icon.IconIdentifier.Split(':');
parts.Should().HaveCount(2, $"图标标识符 '{icon.IconIdentifier}' 应该是 'collection:name' 格式");
parts[0].Should().Be(icon.CollectionName);
parts[1].Should().Be(icon.IconName);
}
}
}

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
}

View File

@@ -0,0 +1,304 @@
using Service.AI;
using Service.IconSearch;
namespace WebApi.Test.Service.IconSearch;
/// <summary>
/// SearchKeywordGeneratorService 单元测试
/// </summary>
public class SearchKeywordGeneratorServiceTest : BaseTest
{
private readonly SearchKeywordGeneratorService _service;
private readonly IOpenAiService _openAiService;
private readonly IOptions<SearchKeywordSettings> _settings;
private readonly ILogger<SearchKeywordGeneratorService> _logger;
public SearchKeywordGeneratorServiceTest()
{
_openAiService = Substitute.For<IOpenAiService>();
_logger = Substitute.For<ILogger<SearchKeywordGeneratorService>>();
_settings = Options.Create(new SearchKeywordSettings
{
KeywordPromptTemplate = "为以下中文分类名称生成3-5个相关的英文搜索关键字用于搜索图标{categoryName}。" +
"输出格式为JSON数组例如[\"food\", \"restaurant\", \"dining\"]。"
});
_service = new SearchKeywordGeneratorService(_openAiService, _settings, _logger);
}
#region GenerateKeywordsAsync Tests
[Fact]
public async Task GenerateKeywordsAsync_应该返回有效关键字数组()
{
// Arrange
const string categoryName = "餐饮";
const string aiResponse = "[\"food\", \"restaurant\", \"dining\", \"eat\"]";
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.FromResult<string?>(aiResponse));
// Act
var keywords = await _service.GenerateKeywordsAsync(categoryName);
// Assert
keywords.Should().NotBeNull();
keywords.Count.Should().Be(4);
keywords.Should().Contain("food");
keywords.Should().Contain("restaurant");
keywords.Should().Contain("dining");
keywords.Should().Contain("eat");
}
[Fact]
public async Task GenerateKeywordsAsync_应该处理JSON对象格式响应()
{
// Arrange
const string categoryName = "交通";
const string aiResponse = "{\"keywords\": [\"transport\", \"traffic\", \"vehicle\"]}";
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.FromResult<string?>(aiResponse));
// Act
var keywords = await _service.GenerateKeywordsAsync(categoryName);
// Assert
keywords.Should().NotBeNull();
keywords.Count.Should().Be(3);
keywords.Should().Contain("transport");
keywords.Should().Contain("traffic");
keywords.Should().Contain("vehicle");
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public async Task GenerateKeywordsAsync_应该处理空或无效分类名称(string categoryName)
{
// Act
var keywords = await _service.GenerateKeywordsAsync(categoryName);
// Assert
keywords.Should().NotBeNull();
keywords.Should().BeEmpty();
// 验证没有调用AI服务
await _openAiService.DidNotReceive().ChatAsync(Arg.Any<string>(), Arg.Any<int>());
}
[Fact]
public async Task GenerateKeywordsAsync_应该处理AI返回空响应()
{
// Arrange
const string categoryName = "购物";
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.FromResult<string?>(string.Empty));
// Act
var keywords = await _service.GenerateKeywordsAsync(categoryName);
// Assert
keywords.Should().NotBeNull();
keywords.Should().BeEmpty();
}
[Fact]
public async Task GenerateKeywordsAsync_应该处理AI返回null()
{
// Arrange
const string categoryName = "旅游";
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.FromResult<string?>(null));
// Act
var keywords = await _service.GenerateKeywordsAsync(categoryName);
// Assert
keywords.Should().NotBeNull();
keywords.Should().BeEmpty();
}
[Fact]
public async Task GenerateKeywordsAsync_应该处理AI返回无效JSON()
{
// Arrange
const string categoryName = "医疗";
const string aiResponse = "这不是一个有效的JSON";
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.FromResult<string?>(aiResponse));
// Act
var keywords = await _service.GenerateKeywordsAsync(categoryName);
// Assert
keywords.Should().NotBeNull();
keywords.Should().BeEmpty();
}
[Fact]
public async Task GenerateKeywordsAsync_应该处理AI服务异常()
{
// Arrange
const string categoryName = "教育";
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns<string?>(_ => throw new HttpRequestException("API调用失败"));
// Act
var keywords = await _service.GenerateKeywordsAsync(categoryName);
// Assert
keywords.Should().NotBeNull();
keywords.Should().BeEmpty();
}
[Fact]
public async Task GenerateKeywordsAsync_应该调用AI服务时使用正确的Prompt()
{
// Arrange
const string categoryName = "餐饮";
const string aiResponse = "[\"food\"]";
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.FromResult<string?>(aiResponse));
// Act
await _service.GenerateKeywordsAsync(categoryName);
// Assert
await _openAiService.Received(1).ChatAsync(
Arg.Is<string>(p => p.Contains(categoryName) && p.Contains("英文搜索关键字")),
Arg.Any<int>()
);
}
[Theory]
[InlineData("餐饮", "[\"food\", \"restaurant\"]")]
[InlineData("交通", "[\"transport\", \"traffic\"]")]
[InlineData("购物", "[\"shopping\", \"buy\"]")]
public async Task GenerateKeywordsAsync_应该正确解析不同分类的关键字(string categoryName, string aiResponse)
{
// Arrange
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.FromResult<string?>(aiResponse));
// Act
var keywords = await _service.GenerateKeywordsAsync(categoryName);
// Assert
keywords.Should().NotBeNull();
keywords.Count.Should().BeGreaterThan(0);
keywords.All(k => !string.IsNullOrWhiteSpace(k)).Should().BeTrue();
}
#endregion
#region Settings Tests
[Fact]
public void SearchKeywordSettings_应该使用默认Prompt模板()
{
// Arrange & Act
var settings = new SearchKeywordSettings();
// Assert
settings.KeywordPromptTemplate.Should().NotBeNullOrEmpty();
settings.KeywordPromptTemplate.Should().Contain("{categoryName}");
settings.KeywordPromptTemplate.Should().Contain("JSON数组");
}
[Fact]
public void SearchKeywordSettings_应该接受自定义Prompt模板()
{
// Arrange & Act
var settings = new SearchKeywordSettings
{
KeywordPromptTemplate = "自定义模板:{categoryName}"
};
// Assert
settings.KeywordPromptTemplate.Should().Be("自定义模板:{categoryName}");
}
#endregion
#region Edge Cases
[Fact]
public async Task GenerateKeywordsAsync_应该处理超长分类名称()
{
// Arrange
var longCategoryName = new string('测', 100); // 100个"测"字符
const string aiResponse = "[\"test\"]";
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.FromResult<string?>(aiResponse));
// Act
var keywords = await _service.GenerateKeywordsAsync(longCategoryName);
// Assert
keywords.Should().NotBeNull();
keywords.Should().NotBeEmpty();
}
[Fact]
public async Task GenerateKeywordsAsync_应该处理特殊字符分类名称()
{
// Arrange
const string categoryName = "餐饮&购物";
const string aiResponse = "[\"food\", \"shopping\"]";
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.FromResult<string?>(aiResponse));
// Act
var keywords = await _service.GenerateKeywordsAsync(categoryName);
// Assert
keywords.Should().NotBeNull();
keywords.Count.Should().Be(2);
}
[Fact]
public async Task GenerateKeywordsAsync_应该处理混合语言分类名称()
{
// Arrange
const string categoryName = "Food餐饮";
const string aiResponse = "[\"food\", \"restaurant\"]";
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.FromResult<string?>(aiResponse));
// Act
var keywords = await _service.GenerateKeywordsAsync(categoryName);
// Assert
keywords.Should().NotBeNull();
keywords.Count.Should().Be(2);
}
[Fact]
public async Task GenerateKeywordsAsync_应该处理空关键字数组()
{
// Arrange
const string categoryName = "测试";
const string aiResponse = "[]";
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<int>())
.Returns(Task.FromResult<string?>(aiResponse));
// Act
var keywords = await _service.GenerateKeywordsAsync(categoryName);
// Assert
keywords.Should().NotBeNull();
keywords.Should().BeEmpty();
}
#endregion
}

View File

@@ -19,5 +19,6 @@
<ItemGroup>
<ProjectReference Include="..\Application\Application.csproj" />
<ProjectReference Include="..\Service\Service.csproj" />
<ProjectReference Include="..\WebApi\WebApi.csproj" />
</ItemGroup>
</Project>