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:
443
WebApi.Test/Controllers/IconControllerTest.cs
Normal file
443
WebApi.Test/Controllers/IconControllerTest.cs
Normal 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
|
||||
}
|
||||
181
WebApi.Test/Entity/TransactionCategoryTest.cs
Normal file
181
WebApi.Test/Entity/TransactionCategoryTest.cs
Normal 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(":");
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
96
WebApi.Test/Service/IconSearch/IconifyApiIntegrationTest.cs
Normal file
96
WebApi.Test/Service/IconSearch/IconifyApiIntegrationTest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
389
WebApi.Test/Service/IconSearch/IconifyApiServiceTest.cs
Normal file
389
WebApi.Test/Service/IconSearch/IconifyApiServiceTest.cs
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -19,5 +19,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Application\Application.csproj" />
|
||||
<ProjectReference Include="..\Service\Service.csproj" />
|
||||
<ProjectReference Include="..\WebApi\WebApi.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user