This commit is contained in:
SunCheng
2026-02-15 10:10:28 +08:00
parent e51a3edd50
commit a88556c784
92 changed files with 6751 additions and 776 deletions

View File

@@ -316,4 +316,105 @@ public class BudgetApplicationTest : BaseApplicationTest
}
#endregion
#region GetCategoryStatsAsync Tests
[Fact]
public async Task GetCategoryStatsAsync_Should_Include_Trend_And_Description_In_Month_Stats()
{
// Arrange
var referenceDate = new DateTime(2026, 2, 14);
var category = BudgetCategory.Expense;
var serviceResponse = new BudgetCategoryStats
{
Month = new BudgetStatsDto
{
Limit = 3000,
Current = 1200,
Rate = 40,
Trend = new List<decimal?> { 100, 200, 300, 400, 500, null, null },
Description = "<table><tr><th>日期</th><th>金额</th></tr></table>"
},
Year = new BudgetStatsDto
{
Limit = 36000,
Current = 5000,
Rate = 13.89m,
Trend = new List<decimal?> { 1000, 2000, 3000, null },
Description = "<table><tr><th>月份</th><th>金额</th></tr></table>"
}
};
_budgetService.GetCategoryStatsAsync(category, referenceDate).Returns(serviceResponse);
// Act
var result = await _application.GetCategoryStatsAsync(category, referenceDate);
// Assert
result.Should().NotBeNull();
// 验证 Month 数据
result.Month.Limit.Should().Be(3000);
result.Month.Current.Should().Be(1200);
result.Month.Remaining.Should().Be(1800);
result.Month.UsagePercentage.Should().Be(40);
result.Month.Trend.Should().NotBeNull();
result.Month.Trend.Should().HaveCount(7);
result.Month.Trend[0].Should().Be(100);
result.Month.Trend[5].Should().BeNull();
result.Month.Description.Should().NotBeEmpty();
result.Month.Description.Should().Contain("<table>");
}
[Fact]
public async Task GetCategoryStatsAsync_Should_Include_Trend_And_Description_In_Year_Stats()
{
// Arrange
var referenceDate = new DateTime(2026, 2, 14);
var category = BudgetCategory.Income;
var serviceResponse = new BudgetCategoryStats
{
Month = new BudgetStatsDto
{
Limit = 5000,
Current = 3000,
Rate = 60,
Trend = new List<decimal?> { 500, 1000, 1500, 2000, 2500, 3000 },
Description = "<p>月度收入明细</p>"
},
Year = new BudgetStatsDto
{
Limit = 60000,
Current = 10000,
Rate = 16.67m,
Trend = new List<decimal?> { 5000, 10000, null, null, null, null, null, null, null, null, null, null },
Description = "<p>年度收入明细</p>"
}
};
_budgetService.GetCategoryStatsAsync(category, referenceDate).Returns(serviceResponse);
// Act
var result = await _application.GetCategoryStatsAsync(category, referenceDate);
// Assert
result.Should().NotBeNull();
// 验证 Year 数据
result.Year.Limit.Should().Be(60000);
result.Year.Current.Should().Be(10000);
result.Year.Remaining.Should().Be(50000);
result.Year.UsagePercentage.Should().Be(16.67m);
result.Year.Trend.Should().NotBeNull();
result.Year.Trend.Should().HaveCount(12);
result.Year.Trend[0].Should().Be(5000);
result.Year.Trend[1].Should().Be(10000);
result.Year.Trend[2].Should().BeNull();
result.Year.Description.Should().NotBeEmpty();
result.Year.Description.Should().Contain("年度收入明细");
}
#endregion
}

View File

@@ -441,4 +441,82 @@ public class TransactionCategoryApplicationTest : BaseApplicationTest
}
#endregion
#region DeleteIconAsync Tests
[Fact]
public async Task DeleteIconAsync_存在的分类_应成功删除图标()
{
// Arrange
var category = new TransactionCategory
{
Id = 1,
Name = "测试",
Type = TransactionType.Expense,
Icon = """["<svg>icon1</svg>","<svg>icon2</svg>"]"""
};
_categoryRepository.GetByIdAsync(1).Returns(category);
_categoryRepository.UpdateAsync(Arg.Any<TransactionCategory>()).Returns(true);
// Act
await _application.DeleteIconAsync(1);
// Assert
await _categoryRepository.Received(1).UpdateAsync(Arg.Is<TransactionCategory>(
c => c.Id == 1 && c.Icon == null
));
}
[Fact]
public async Task DeleteIconAsync_分类不存在_应抛出NotFoundException()
{
// Arrange
_categoryRepository.GetByIdAsync(999).Returns((TransactionCategory?)null);
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(() => _application.DeleteIconAsync(999));
}
[Fact]
public async Task DeleteIconAsync_更新失败_应抛出BusinessException()
{
// Arrange
var category = new TransactionCategory
{
Id = 1,
Name = "测试",
Type = TransactionType.Expense,
Icon = """["<svg>icon1</svg>"]"""
};
_categoryRepository.GetByIdAsync(1).Returns(category);
_categoryRepository.UpdateAsync(Arg.Any<TransactionCategory>()).Returns(false);
// Act & Assert
await Assert.ThrowsAsync<BusinessException>(() => _application.DeleteIconAsync(1));
}
[Fact]
public async Task DeleteIconAsync_无图标的分类_应成功()
{
// Arrange
var category = new TransactionCategory
{
Id = 1,
Name = "测试",
Type = TransactionType.Expense,
Icon = null
};
_categoryRepository.GetByIdAsync(1).Returns(category);
_categoryRepository.UpdateAsync(Arg.Any<TransactionCategory>()).Returns(true);
// Act
await _application.DeleteIconAsync(1);
// Assert
await _categoryRepository.Received(1).UpdateAsync(Arg.Is<TransactionCategory>(
c => c.Id == 1 && c.Icon == null
));
}
#endregion
}

View File

@@ -0,0 +1,467 @@
using Microsoft.Extensions.Options;
using Service.AI;
using Service.AppSettingModel;
namespace WebApi.Test.Service;
/// <summary>
/// ClassificationIconPromptProvider 单元测试
/// </summary>
public class ClassificationIconPromptProviderTest : BaseTest
{
private readonly ClassificationIconPromptProvider _provider;
private readonly ILogger<ClassificationIconPromptProvider> _logger;
private readonly IOptions<IconPromptSettings> _config;
public ClassificationIconPromptProviderTest()
{
_logger = Substitute.For<ILogger<ClassificationIconPromptProvider>>();
_config = Options.Create(new IconPromptSettings
{
EnableNewPrompt = true,
GrayScaleRatio = 1.0,
StyleStrength = 0.7,
ColorScheme = "single-color"
});
_provider = new ClassificationIconPromptProvider(_logger, _config);
}
#region GetPrompt Tests
[Fact]
public void GetPrompt_支出分类_应包含正确的上下文信息()
{
// Arrange
const string categoryName = "餐饮";
const TransactionType categoryType = TransactionType.Expense;
// Act
var prompt = _provider.GetPrompt(categoryName, categoryType);
// Assert
prompt.Should().Contain(categoryName);
prompt.Should().Contain("支出");
prompt.Should().Contain("分类名称");
prompt.Should().Contain("分类类型");
}
[Fact]
public void GetPrompt_收入分类_应包含正确的上下文信息()
{
// Arrange
const string categoryName = "工资";
const TransactionType categoryType = TransactionType.Income;
// Act
var prompt = _provider.GetPrompt(categoryName, categoryType);
// Assert
prompt.Should().Contain(categoryName);
prompt.Should().Contain("收入");
prompt.Should().Contain("分类名称");
prompt.Should().Contain("分类类型");
}
[Fact]
public void GetPrompt_不计入收支分类_应包含正确的上下文信息()
{
// Arrange
const string categoryName = "转账";
const TransactionType categoryType = TransactionType.None;
// Act
var prompt = _provider.GetPrompt(categoryName, categoryType);
// Assert
prompt.Should().Contain(categoryName);
prompt.Should().Contain("不计入收支");
}
[Fact]
public void GetPrompt_应包含设计要求()
{
// Arrange
const string categoryName = "交通";
const TransactionType categoryType = TransactionType.Expense;
// Act
var prompt = _provider.GetPrompt(categoryName, categoryType);
// Assert
prompt.Should().Contain("24x24");
prompt.Should().Contain("viewBox=\"0 0 24 24\"");
prompt.Should().Contain("扁平化、单色、极致简约");
prompt.Should().Contain("避免渐变和阴影");
prompt.Should().Contain("保持线条简洁");
prompt.Should().Contain("高对比度");
}
[Fact]
public void GetPrompt_应包含SVG格式要求()
{
// Arrange
const string categoryName = "购物";
const TransactionType categoryType = TransactionType.Expense;
// Act
var prompt = _provider.GetPrompt(categoryName, categoryType);
// Assert
prompt.Should().Contain("JSON 数组格式");
prompt.Should().Contain("5 个完整的 SVG 字符串");
}
[Fact]
public void GetPrompt_应包含返回格式说明()
{
// Arrange
const string categoryName = "娱乐";
const TransactionType categoryType = TransactionType.Expense;
// Act
var prompt = _provider.GetPrompt(categoryName, categoryType);
// Assert
prompt.Should().Contain("[\"<svg>...</svg>\", \"<svg>...</svg>\", \"<svg>...</svg>\", \"<svg>...</svg>\", \"<svg>...</svg>\"]");
}
[Fact]
public void GetPrompt_多次调用_应生成相同的提示词结构()
{
// Arrange
const string categoryName = "餐饮";
const TransactionType categoryType = TransactionType.Expense;
// Act
var prompt1 = _provider.GetPrompt(categoryName, categoryType);
var prompt2 = _provider.GetPrompt(categoryName, categoryType);
// Assert
prompt1.Should().Be(prompt2);
}
[Fact]
public void GetPrompt_不同分类_应生成不同的提示词()
{
// Arrange
const string categoryName1 = "餐饮";
const string categoryName2 = "交通";
const TransactionType categoryType = TransactionType.Expense;
// Act
var prompt1 = _provider.GetPrompt(categoryName1, categoryType);
var prompt2 = _provider.GetPrompt(categoryName2, categoryType);
// Assert
prompt1.Should().NotBe(prompt2);
prompt1.Should().Contain(categoryName1);
prompt2.Should().Contain(categoryName2);
}
#endregion
#region GetSingleIconPrompt Tests
[Fact]
public void GetSingleIconPrompt_支出分类_应包含正确的上下文信息()
{
// Arrange
const string categoryName = "餐饮";
const TransactionType categoryType = TransactionType.Expense;
// Act
var prompt = _provider.GetSingleIconPrompt(categoryName, categoryType);
// Assert
prompt.Should().Contain(categoryName);
prompt.Should().Contain("支出");
prompt.Should().Contain("分类名称");
prompt.Should().Contain("分类类型");
}
[Fact]
public void GetSingleIconPrompt_应包含单个图标要求()
{
// Arrange
const string categoryName = "交通";
const TransactionType categoryType = TransactionType.Expense;
// Act
var prompt = _provider.GetSingleIconPrompt(categoryName, categoryType);
// Assert
prompt.Should().Contain("生成 1 个");
prompt.Should().Contain("24x24");
prompt.Should().Contain("viewBox=\"0 0 24 24\"");
prompt.Should().Contain("扁平化、单色、极致简约");
}
[Fact]
public void GetSingleIconPrompt_应包含返回格式说明()
{
// Arrange
const string categoryName = "购物";
const TransactionType categoryType = TransactionType.Expense;
// Act
var prompt = _provider.GetSingleIconPrompt(categoryName, categoryType);
// Assert
prompt.Should().Contain("JSON 数组格式");
prompt.Should().Contain("1 个完整的 SVG 字符串");
prompt.Should().Contain("[\"<svg>...</svg>\"]");
}
[Fact]
public void GetSingleIconPrompt_应与GetPrompt生成不同的提示词()
{
// Arrange
const string categoryName = "餐饮";
const TransactionType categoryType = TransactionType.Expense;
// Act
var singlePrompt = _provider.GetSingleIconPrompt(categoryName, categoryType);
var multiPrompt = _provider.GetPrompt(categoryName, categoryType);
// Assert
singlePrompt.Should().NotBe(multiPrompt);
singlePrompt.Should().Contain("生成 1 个");
multiPrompt.Should().Contain("生成 5 个");
}
#endregion
#region PromptTemplateEngine Tests (3.1)
[Fact]
public void PromptTemplateEngine_ReplacePlaceholders_应正确替换单个占位符()
{
// Arrange
var template = "分类名称:{{category_name}},类型:{{category_type}}";
var placeholders = new Dictionary<string, string>
{
["category_name"] = "餐饮",
["category_type"] = "支出"
};
// Act
var result = PromptTemplateEngine.ReplacePlaceholders(template, placeholders);
// Assert
result.Should().Be("分类名称:餐饮,类型:支出");
}
[Fact]
public void PromptTemplateEngine_ReplacePlaceholders_应正确替换多个占位符()
{
// Arrange
var template = "颜色方案:{{color_scheme}},简约度:{{style_strength}}";
var placeholders = new Dictionary<string, string>
{
["color_scheme"] = "single-color",
["style_strength"] = "0.8 - 高度简约"
};
// Act
var result = PromptTemplateEngine.ReplacePlaceholders(template, placeholders);
// Assert
result.Should().Be("颜色方案single-color简约度0.8 - 高度简约");
}
[Fact]
public void PromptTemplateEngine_ReplacePlaceholders_空模板应返回原字符串()
{
// Arrange
var template = string.Empty;
var placeholders = new Dictionary<string, string>();
// Act
var result = PromptTemplateEngine.ReplacePlaceholders(template, placeholders);
// Assert
result.Should().Be(string.Empty);
}
[Fact]
public void PromptTemplateEngine_ReplacePlaceholders_空占位符字典应返回原字符串()
{
// Arrange
var template = "测试模板字符串";
var placeholders = new Dictionary<string, string>();
// Act
var result = PromptTemplateEngine.ReplacePlaceholders(template, placeholders);
// Assert
result.Should().Be("测试模板字符串");
}
#endregion
#region (3.2)
[Fact]
public void GetPrompt_新版提示词_应包含简约风格要求()
{
// Arrange
const string categoryName = "餐饮";
const TransactionType categoryType = TransactionType.Expense;
// Act
var prompt = _provider.GetPrompt(categoryName, categoryType);
// Assert
prompt.Should().Contain("扁平化、单色、极致简约");
prompt.Should().Contain("避免渐变和阴影");
prompt.Should().Contain("保持线条简洁");
prompt.Should().Contain("高对比度");
}
[Fact]
public void GetPrompt_新版提示词_应包含颜色方案占位符替换()
{
// Arrange
const string categoryName = "交通";
const TransactionType categoryType = TransactionType.Expense;
// Act
var prompt = _provider.GetPrompt(categoryName, categoryType);
// Assert
prompt.Should().Contain("颜色方案single-color");
}
[Fact]
public void GetPrompt_新版提示词_应包含风格强度描述()
{
// Arrange
const string categoryName = "娱乐";
const TransactionType categoryType = TransactionType.Expense;
// Act
var prompt = _provider.GetPrompt(categoryName, categoryType);
// Assert
prompt.Should().Contain("简约度0.7");
prompt.Should().Contain("高度简约(去除所有装饰)");
}
#endregion
#region (3.3)
[Fact]
public void IconPromptSettings_初始化_应包含默认抽象分类配置()
{
// Arrange & Act
var config = new IconPromptSettings();
// Assert
config.AbstractCategories.Should().ContainKey("其他");
config.AbstractCategories.Should().ContainKey("通用");
config.AbstractCategories.Should().ContainKey("未知");
config.AbstractCategories["其他"].GeometryShape.Should().Be("circle");
config.AbstractCategories["其他"].ColorCode.Should().Be("#9E9E9E");
}
[Fact]
public void IconPromptSettings_抽象分类配置_可以自定义()
{
// Arrange
var config = new IconPromptSettings();
// Act
config.AbstractCategories["自定义分类"] = new AbstractCategoryConfig
{
GeometryShape = "hexagon",
ColorCode = "#FF5722"
};
// Assert
config.AbstractCategories["自定义分类"].GeometryShape.Should().Be("hexagon");
config.AbstractCategories["自定义分类"].ColorCode.Should().Be("#FF5722");
}
#endregion
#region (3.4)
[Fact]
public void PromptTemplateEngine_ReplaceForIconGeneration_风格强度09_应显示极度简约()
{
// Arrange
var template = "简约度:{{style_strength}}";
// Act
var result = PromptTemplateEngine.ReplaceForIconGeneration(
template,
"餐饮",
"支出",
"single-color",
0.95);
// Assert
result.Should().Contain("0.9");
result.Should().Contain("极度简约(仅保留最核心元素)");
}
[Fact]
public void PromptTemplateEngine_ReplaceForIconGeneration_风格强度07_应显示高度简约()
{
// Arrange
var template = "简约度:{{style_strength}}";
// Act
var result = PromptTemplateEngine.ReplaceForIconGeneration(
template,
"交通",
"支出",
"single-color",
0.7);
// Assert
result.Should().Contain("0.7");
result.Should().Contain("高度简约(去除所有装饰)");
}
[Fact]
public void PromptTemplateEngine_ReplaceForIconGeneration_风格强度05_应显示简约()
{
// Arrange
var template = "简约度:{{style_strength}}";
// Act
var result = PromptTemplateEngine.ReplaceForIconGeneration(
template,
"购物",
"支出",
"single-color",
0.5);
// Assert
result.Should().Contain("0.5");
result.Should().Contain("简约(保留必要细节)");
}
[Fact]
public void PromptTemplateEngine_ReplaceForIconGeneration_风格强度03_应显示适中()
{
// Arrange
var template = "简约度:{{style_strength}}";
// Act
var result = PromptTemplateEngine.ReplaceForIconGeneration(
template,
"娱乐",
"支出",
"single-color",
0.3);
// Assert
result.Should().Contain("0.3");
result.Should().Contain("适中");
}
#endregion
}

View File

@@ -0,0 +1,263 @@
using NSubstitute;
using Service.AI;
using Service.Transaction;
using System.Text.Json;
namespace WebApi.Test.Service;
/// <summary>
/// SmartHandleService 单元测试
/// </summary>
public class SmartHandleServiceTest : BaseTest
{
private readonly IOpenAiService _openAiService;
private readonly ITransactionRecordRepository _transactionRecordRepository;
private readonly ITransactionStatisticsService _transactionStatisticsService;
private readonly ITextSegmentService _textSegmentService;
private readonly ILogger<SmartHandleService> _logger;
private readonly ITransactionCategoryRepository _categoryRepository;
private readonly IConfigService _configService;
private readonly IClassificationIconPromptProvider _iconPromptProvider;
private readonly SmartHandleService _service;
public SmartHandleServiceTest()
{
_openAiService = Substitute.For<IOpenAiService>();
_transactionRecordRepository = Substitute.For<ITransactionRecordRepository>();
_transactionStatisticsService = Substitute.For<ITransactionStatisticsService>();
_textSegmentService = Substitute.For<ITextSegmentService>();
_logger = Substitute.For<ILogger<SmartHandleService>>();
_categoryRepository = Substitute.For<ITransactionCategoryRepository>();
_configService = Substitute.For<IConfigService>();
_iconPromptProvider = Substitute.For<IClassificationIconPromptProvider>();
_service = new SmartHandleService(
_transactionRecordRepository,
_transactionStatisticsService,
_textSegmentService,
_logger,
_categoryRepository,
_openAiService,
_configService,
_iconPromptProvider
);
}
#region GenerateSingleCategoryIconAsync Tests
[Fact]
public async Task GenerateSingleCategoryIconAsync_AI返回带Markdown标记的JSON_应成功解析()
{
// Arrange
const string categoryName = "餐饮";
const TransactionType categoryType = TransactionType.Expense;
const string aiResponseWithMarkdown = """
```json
["<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#ff6b6b\"/></svg>"]
```
""";
_iconPromptProvider.GetSingleIconPrompt(categoryName, categoryType).Returns("system prompt");
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>())
.Returns(aiResponseWithMarkdown);
// Act
var result = await _service.GenerateSingleCategoryIconAsync(categoryName, categoryType);
// Assert
result.Should().NotBeNull();
result.Should().Contain("<svg");
result.Should().Contain("viewBox=\"0 0 24 24\"");
}
[Fact]
public async Task GenerateSingleCategoryIconAsync_AI返回纯JSON_应成功解析()
{
// Arrange
const string categoryName = "交通";
const TransactionType categoryType = TransactionType.Expense;
const string aiResponsePureJson = """
["<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" fill=\"#4a90e2\"/></svg>"]
""";
_iconPromptProvider.GetSingleIconPrompt(categoryName, categoryType).Returns("system prompt");
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>())
.Returns(aiResponsePureJson);
// Act
var result = await _service.GenerateSingleCategoryIconAsync(categoryName, categoryType);
// Assert
result.Should().NotBeNull();
result.Should().Contain("<svg");
}
[Fact]
public async Task GenerateSingleCategoryIconAsync_AI返回空响应_应返回null()
{
// Arrange
const string categoryName = "购物";
const TransactionType categoryType = TransactionType.Expense;
_iconPromptProvider.GetSingleIconPrompt(categoryName, categoryType).Returns("system prompt");
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>())
.Returns((string?)null);
// Act
var result = await _service.GenerateSingleCategoryIconAsync(categoryName, categoryType);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GenerateSingleCategoryIconAsync_AI返回无效JSON_应返回null()
{
// Arrange
const string categoryName = "娱乐";
const TransactionType categoryType = TransactionType.Expense;
const string invalidJsonResponse = "这是一个文本响应,不是 JSON";
_iconPromptProvider.GetSingleIconPrompt(categoryName, categoryType).Returns("system prompt");
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>())
.Returns(invalidJsonResponse);
// Act
var result = await _service.GenerateSingleCategoryIconAsync(categoryName, categoryType);
// Assert
result.Should().BeNull();
}
#endregion
#region GenerateCategoryIconsAsync Tests
[Fact]
public async Task GenerateCategoryIconsAsync_AI返回带Markdown标记的JSON_应成功解析()
{
// Arrange
const string categoryName = "餐饮";
const TransactionType categoryType = TransactionType.Expense;
const int iconCount = 5;
const string aiResponseWithMarkdown = """
```json
[
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#ff6b6b\"/></svg>",
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><rect x=\"6\" y=\"6\" width=\"12\" height=\"12\" fill=\"#4ecdc4\"/></svg>",
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path d=\"M12 2L2 22h20L12 2z\" fill=\"#f5a623\"/></svg>",
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><ellipse cx=\"12\" cy=\"12\" rx=\"10\" ry=\"6\" fill=\"#6c5ce7\"/></svg>",
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><polygon points=\"12,2 22,8 22,16 12,22 2,16 2,8\" fill=\"#a29bfe\"/></svg>"
]
```
""";
_iconPromptProvider.GetPrompt(categoryName, categoryType).Returns("system prompt");
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>())
.Returns(aiResponseWithMarkdown);
// Act
var result = await _service.GenerateCategoryIconsAsync(categoryName, categoryType, iconCount);
// Assert
result.Should().NotBeNull();
result.Should().HaveCount(iconCount);
result.Should().AllSatisfy(icon => icon.Should().Contain("<svg"));
}
[Fact]
public async Task GenerateCategoryIconsAsync_AI返回纯JSON_应成功解析()
{
// Arrange
const string categoryName = "交通";
const TransactionType categoryType = TransactionType.Expense;
const int iconCount = 5;
const string aiResponsePureJson = """
[
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#ff6b6b\"/></svg>",
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><rect x=\"6\" y=\"6\" width=\"12\" height=\"12\" fill=\"#4ecdc4\"/></svg>",
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path d=\"M12 2L2 22h20L12 2z\" fill=\"#f5a623\"/></svg>",
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><ellipse cx=\"12\" cy=\"12\" rx=\"10\" ry=\"6\" fill=\"#6c5ce7\"/></svg>",
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><polygon points=\"12,2 22,8 22,16 12,22 2,16 2,8\" fill=\"#a29bfe\"/></svg>"
]
""";
_iconPromptProvider.GetPrompt(categoryName, categoryType).Returns("system prompt");
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>())
.Returns(aiResponsePureJson);
// Act
var result = await _service.GenerateCategoryIconsAsync(categoryName, categoryType, iconCount);
// Assert
result.Should().NotBeNull();
result.Should().HaveCount(iconCount);
}
[Fact]
public async Task GenerateCategoryIconsAsync_AI返回空响应_应返回null()
{
// Arrange
const string categoryName = "购物";
const TransactionType categoryType = TransactionType.Expense;
const int iconCount = 5;
_iconPromptProvider.GetPrompt(categoryName, categoryType).Returns("system prompt");
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>())
.Returns((string?)null);
// Act
var result = await _service.GenerateCategoryIconsAsync(categoryName, categoryType, iconCount);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GenerateCategoryIconsAsync_AI返回图标数量不正确_应返回null()
{
// Arrange
const string categoryName = "娱乐";
const TransactionType categoryType = TransactionType.Expense;
const int iconCount = 5;
const string aiResponseWith3Icons = """
[
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"#ff6b6b\"/></svg>",
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><rect x=\"6\" y=\"6\" width=\"12\" height=\"12\" fill=\"#4ecdc4\"/></svg>",
"<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path d=\"M12 2L2 22h20L12 2z\" fill=\"#f5a623\"/></svg>"
]
""";
_iconPromptProvider.GetPrompt(categoryName, categoryType).Returns("system prompt");
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>())
.Returns(aiResponseWith3Icons);
// Act
var result = await _service.GenerateCategoryIconsAsync(categoryName, categoryType, iconCount);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GenerateCategoryIconsAsync_AI返回无效JSON_应返回null()
{
// Arrange
const string categoryName = "其他";
const TransactionType categoryType = TransactionType.Expense;
const int iconCount = 5;
const string invalidJsonResponse = "这是一个文本响应,不是 JSON";
_iconPromptProvider.GetPrompt(categoryName, categoryType).Returns("system prompt");
_openAiService.ChatAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<int>())
.Returns(invalidJsonResponse);
// Act
var result = await _service.GenerateCategoryIconsAsync(categoryName, categoryType, iconCount);
// Assert
result.Should().BeNull();
}
#endregion
}