using Microsoft.Extensions.Logging; using Service.AI; using Service.Message; using Service.Transaction; namespace WebApi.Test.Budget; public class BudgetStatsTest : BaseTest { private readonly IBudgetRepository _budgetRepository = Substitute.For(); private readonly IBudgetArchiveRepository _budgetArchiveRepository = Substitute.For(); private readonly ITransactionRecordRepository _transactionsRepository = Substitute.For(); private readonly ITransactionStatisticsService _transactionStatisticsService = Substitute.For(); private readonly IOpenAiService _openAiService = Substitute.For(); private readonly IMessageService _messageService = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private readonly IBudgetSavingsService _budgetSavingsService = Substitute.For(); private readonly IDateTimeProvider _dateTimeProvider = Substitute.For(); private readonly BudgetService _service; public BudgetStatsTest() { _dateTimeProvider.Now.Returns(new DateTime(2024, 1, 15)); IBudgetStatsService budgetStatsService = new BudgetStatsService( _budgetRepository, _budgetArchiveRepository, _transactionStatisticsService, _dateTimeProvider, Substitute.For>() ); _service = new BudgetService( _budgetRepository, _budgetArchiveRepository, _transactionsRepository, _transactionStatisticsService, _openAiService, _messageService, _logger, _budgetSavingsService, _dateTimeProvider, budgetStatsService ); } [Fact] public async Task GetCategoryStats_月度_Test() { // Arrange var referenceDate = new DateTime(2024, 1, 15); var budgets = new List { new() { Id = 1, Name = "吃喝", Limit = 2000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮,零食" }, new() { Id = 2, Name = "交通", Limit = 500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "交通" } }; _budgetRepository.GetAllAsync().Returns(budgets); _budgetRepository.GetCurrentAmountAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(args => { var b = (BudgetRecord)args[0]; return b.Name == "吃喝" ? 1200m : 300m; }); _transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new Dictionary { { new DateTime(2024, 1, 15), 1500m } // 1月15日累计1500(吃喝1200+交通300) }); // Act var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Assert result.Month.Limit.Should().Be(2500); // 吃喝2000 + 交通500 result.Month.Current.Should().Be(1500); // 吃喝1200 + 交通300 } [Fact] public async Task GetCategoryStats_月度_硬性收支_Test() { // Arrange var referenceDate = new DateTime(2024, 1, 15); var budgets = new List { new() { Id = 1, Name = "房租", Limit = 3100, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, IsMandatoryExpense = true } }; _budgetRepository.GetAllAsync().Returns(budgets); _budgetRepository.GetCurrentAmountAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(0m); // 实际支出的金额为0 _dateTimeProvider.Now.Returns(referenceDate); _transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new Dictionary()); // Act var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Assert // 硬性预算的限额保持不变,不根据时间计算 result.Month.Limit.Should().Be(3100); // 实际使用值根据时间计算:1月有31天,15号经过了15天 // 3100 * 15 / 31 ≈ 1500 result.Month.Current.Should().BeApproximately(1500, 1); } [Fact] public async Task GetCategoryStats_年度_1月_Test() { // Arrange var referenceDate = new DateTime(2024, 1, 15); var budgets = new List { new() { Id = 2, Name = "月度吃饭", Limit = 3000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮" }, new() { Id = 1, Name = "年度旅游", Limit = 12000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, SelectedCategories = "旅游" } }; _budgetRepository.GetAllAsync().Returns(budgets); // 月度统计使用趋势统计数据(只包含月度预算的分类) _transactionStatisticsService.GetFilteredTrendStatisticsAsync( Arg.Is(d => d.Year == 2024 && d.Month == 1 && d.Day == 1), Arg.Is(d => d.Year == 2024 && d.Month == 1 && d.Day == 31), TransactionType.Expense, Arg.Is>(list => list.Count == 1 && list.Contains("餐饮"))) .Returns(new Dictionary { { new DateTime(2024, 1, 15), 800m } // 1月15日月度吃饭累计800 }); // 年度统计使用GetCurrentAmountAsync _budgetRepository.GetCurrentAmountAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(args => { var b = (BudgetRecord)args[0]; var startDate = (DateTime)args[1]; var endDate = (DateTime)args[2]; // 月度范围查询 - 月度吃饭(1月) if (startDate.Month == 1 && startDate.Day == 1 && endDate.Month == 1) { return b.Name == "月度吃饭" ? 800m : 0m; } // 年度范围查询 - 年度旅游 if (startDate.Month == 1 && startDate.Day == 1 && endDate.Month == 12) { return b.Name == "年度旅游" ? 2000m : 0m; } return 0m; }); // 年度趋势统计(包含所有分类) _transactionStatisticsService.GetFilteredTrendStatisticsAsync( Arg.Is(d => d.Year == 2024 && d.Month == 1 && d.Day == 1), Arg.Is(d => d.Year == 2024 && d.Month == 12 && d.Day == 31), TransactionType.Expense, Arg.Is>(list => list.Count == 2), // 包含所有分类:餐饮、旅游 true) .Returns(new Dictionary { { new DateTime(2024, 1, 1), 2800m } // 1月累计:月度吃饭800 + 年度旅游2000 = 2800 }); // Act var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Assert // 月度统计中:只包含月度预算 result.Month.Limit.Should().Be(3000); // 月度吃饭3000 result.Month.Current.Should().Be(800); // 月度吃饭已用800(从GetCurrentAmountAsync获取) result.Month.Count.Should().Be(1); // 只包含1个月度预算 // 年度统计中:包含所有预算(月度预算按剩余月份折算) // 1月时,月度预算分为:当前月(1月) + 剩余月份(2-12月共11个月) result.Year.Limit.Should().Be(12000 + (3000 * 12)); // 年度旅游12000 + 月度吃饭折算年度(3000*12=36000) = 48000 result.Year.Current.Should().Be(2000 + 800); // 年度旅游2000 + 月度吃饭800 = 2800 result.Year.Count.Should().Be(3); // 包含3个预算项:年度旅游、月度吃饭(当前月)、月度吃饭(剩余11个月) } [Fact] public async Task GetCategoryStats_年度_1月_硬性收支_Test() { // Arrange var referenceDate = new DateTime(2024, 1, 1); // 元旦 var budgets = new List { new() { Id = 1, Name = "年度固定支出", Limit = 3660, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, IsMandatoryExpense = true } }; _budgetRepository.GetAllAsync().Returns(budgets); _budgetRepository.GetCurrentAmountAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(0m); _dateTimeProvider.Now.Returns(referenceDate); _transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new Dictionary()); // Act var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Assert // 硬性预算的限额保持不变 result.Year.Limit.Should().Be(3660); // 实际使用值根据时间计算:2024是闰年,366天。1月1号是第1天。 // 3660 * 1 / 366 ≈ 10 result.Year.Current.Should().BeApproximately(10, 0.1m); } [Fact] public async Task GetCategoryStats_年度_3月_硬性收支_Test() { // Arrange var referenceDate = new DateTime(2024, 3, 31); // 3月最后一天 var budgets = new List { new() { Id = 1, Name = "年度固定支出", Limit = 3660, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, IsMandatoryExpense = true } }; _budgetRepository.GetAllAsync().Returns(budgets); _budgetRepository.GetCurrentAmountAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(0m); _dateTimeProvider.Now.Returns(referenceDate); _transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new Dictionary()); // Act var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Assert // 硬性预算的限额保持不变 result.Year.Limit.Should().Be(3660); // 实际使用值根据时间计算:2024是闰年。1月(31) + 2月(29) + 3月(31) = 91天 // 3660 * 91 / 366 ≈ 910 result.Year.Current.Should().BeApproximately(910, 1); } [Fact] public async Task GetCategoryStats_月度_发生年度收支_Test() { // Arrange var referenceDate = new DateTime(2024, 1, 15); // 设置预算:包含月度预算和年度预算 var budgets = new List { // 月度预算:吃喝 new() { Id = 1, Name = "吃喝", Limit = 2000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮,零食" }, // 月度预算:交通 new() { Id = 2, Name = "交通", Limit = 500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "交通" }, // 年度预算:年度旅游(当前月度发生了相关支出) new() { Id = 3, Name = "年度旅游", Limit = 12000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, SelectedCategories = "旅游,度假" }, // 年度预算:年度奖金(当前月度发生了相关收入) new() { Id = 4, Name = "年度奖金", Limit = 50000, Category = BudgetCategory.Income, Type = BudgetPeriodType.Year, SelectedCategories = "奖金,年终奖" } }; _budgetRepository.GetAllAsync().Returns(budgets); // 设置月度预算的当前金额 _budgetRepository.GetCurrentAmountAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(args => { var b = (BudgetRecord)args[0]; return b.Name switch { "吃喝" => 1200m, // 月度预算:已用1200元 "交通" => 300m, // 月度预算:已用300元 "年度旅游" => 2000m, // 年度预算:1月份已用2000元 "年度奖金" => 10000m, // 年度预算:1月份已收10000元 _ => 0m }; }); // 设置月度趋势统计数据:只包含月度预算相关的分类(餐饮、零食、交通) // 注意:不应包含年度预算的分类(旅游、度假、奖金、年终奖) _transactionStatisticsService.GetFilteredTrendStatisticsAsync( Arg.Is(d => d.Year == 2024 && d.Month == 1), Arg.Is(d => d.Year == 2024 && d.Month == 1), TransactionType.Expense, Arg.Is>(list => list.Count == 3 && list.Contains("餐饮") && list.Contains("零食") && list.Contains("交通"))) .Returns(new Dictionary { { new DateTime(2024, 1, 15), 1500m } // 1月15日累计1500(吃喝1200+交通300,不包含年度旅游2000) }); // 设置年度趋势统计数据:包含所有预算相关的分类 _transactionStatisticsService.GetFilteredTrendStatisticsAsync( Arg.Is(d => d.Year == 2024 && d.Month == 1 && d.Day == 1), Arg.Is(d => d.Year == 2024 && d.Month == 12 && d.Day == 31), TransactionType.Expense, Arg.Is>(list => list.Count == 5), // 餐饮、零食、交通、旅游、度假 true) .Returns(new Dictionary { { new DateTime(2024, 1, 1), 3500m } // 1月累计3500(吃喝1200+交通300+年度旅游2000) }); // 设置收入相关的趋势统计数据 _transactionStatisticsService.GetFilteredTrendStatisticsAsync( Arg.Is(d => d.Year == 2024 && d.Month == 1), Arg.Is(d => d.Year == 2024 && d.Month == 1), TransactionType.Income, Arg.Any>()) .Returns(new Dictionary()); // 月度收入为空 _transactionStatisticsService.GetFilteredTrendStatisticsAsync( Arg.Is(d => d.Year == 2024 && d.Month == 1 && d.Day == 1), Arg.Is(d => d.Year == 2024 && d.Month == 12 && d.Day == 31), TransactionType.Income, Arg.Any>(), true) .Returns(new Dictionary { { new DateTime(2024, 1, 1), 10000m } // 年度奖金10000 }); // Act - 测试支出统计 var expenseResult = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Act - 测试收入统计 var incomeResult = await _service.GetCategoryStatsAsync(BudgetCategory.Income, referenceDate); // Assert - 支出月度统计:只包含月度预算,不包含年度预算 expenseResult.Month.Limit.Should().Be(2500); // 吃喝2000 + 交通500 expenseResult.Month.Current.Should().Be(1500); // 吃喝1200 + 交通300(不包含年度旅游2000) expenseResult.Month.Count.Should().Be(2); // 只包含2个月度预算 expenseResult.Month.Rate.Should().Be(1500m / 2500m * 100); // 60% // Assert - 支出年度统计:包含所有预算(月度+年度) // 1月时,月度预算分为:当前月(1月) + 剩余月份(2-12月共11个月) expenseResult.Year.Limit.Should().Be(12000 + (2500 * 12)); // 年度旅游12000 + 月度预算折算为年度(2500*12) expenseResult.Year.Current.Should().Be(3500); // 吃喝1200 + 交通300 + 年度旅游2000 expenseResult.Year.Count.Should().Be(5); // 包含5个预算项:年度旅游、吃喝(当前月)、交通(当前月)、吃喝(剩余11个月)、交通(剩余11个月) // Assert - 收入月度统计:只包含月度预算(这里没有月度收入预算,所以应该为0) incomeResult.Month.Limit.Should().Be(0); // 没有月度收入预算 incomeResult.Month.Current.Should().Be(0); // 没有月度收入预算(不包含年度奖金10000) incomeResult.Month.Count.Should().Be(0); // 没有月度收入预算 // Assert - 收入年度统计:包含所有预算(只有年度收入预算) incomeResult.Year.Limit.Should().Be(50000); // 年度奖金50000 incomeResult.Year.Current.Should().Be(10000); // 年度奖金已收10000 incomeResult.Year.Count.Should().Be(1); // 包含1个年度收入预算 } [Fact] public async Task GetCategoryStats_年度_3月_2月预算变更_Test() { // Arrange // 测试场景:2024年3月查看年度预算统计,其中2月份发生了预算变更(吃喝预算从2000增加到2500) var referenceDate = new DateTime(2024, 3, 15); // 设置当前时间,确保3月被认为是当前月份 _dateTimeProvider.Now.Returns(new DateTime(2024, 3, 15)); // 当前3月份有效的预算 var currentBudgets = new List { new() { Id = 1, Name = "吃喝", Limit = 2500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮,零食" }, // 2月预算变更后 new() { Id = 2, Name = "交通", Limit = 500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "交通" }, new() { Id = 3, Name = "年度旅游", Limit = 12000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, SelectedCategories = "旅游,度假" } }; // 2月份的归档数据(预算变更前) var febArchive = new BudgetArchive { Year = 2024, Month = 2, Content = new[] { new BudgetArchiveContent { Id = 1, Name = "吃喝", Limit = 2000, // 2月份时预算还是2000 Actual = 1800, // 2月份实际花费1800 Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = new[] { "餐饮", "零食" } }, new BudgetArchiveContent { Id = 2, Name = "交通", Limit = 500, Actual = 300, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = new[] { "交通" } } } }; // 1月份的归档数据 var janArchive = new BudgetArchive { Year = 2024, Month = 1, Content = new[] { new BudgetArchiveContent { Id = 1, Name = "吃喝", Limit = 2000, // 1月份预算也是2000 Actual = 1500, // 1月份实际花费1500 Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = new[] { "餐饮", "零食" } }, new BudgetArchiveContent { Id = 2, Name = "交通", Limit = 500, Actual = 250, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = new[] { "交通" } } } }; // 设置仓储响应 _budgetRepository.GetAllAsync().Returns(currentBudgets); // 设置归档仓储响应 _budgetArchiveRepository.GetArchiveAsync(2024, 2).Returns(febArchive); _budgetArchiveRepository.GetArchiveAsync(2024, 1).Returns(janArchive); // 设置月度预算的当前金额查询(仅用于3月份) _budgetRepository.GetCurrentAmountAsync(Arg.Any(), Arg.Is(d => d.Month == 3), Arg.Is(d => d.Month == 3)) .Returns(args => { var b = (BudgetRecord)args[0]; return b.Name switch { "吃喝" => 800m, // 3月份已花费800 "交通" => 200m, // 3月份已花费200 _ => 0m }; }); // 年度旅游的年度金额查询 _budgetRepository.GetCurrentAmountAsync( Arg.Is(b => b.Id == 3), Arg.Is(d => d.Month == 1), Arg.Is(d => d.Month == 12)) .Returns(2500m); // 年度旅游1-3月已花费2500 // 设置趋势统计数据查询(用于月度统计) _transactionStatisticsService.GetFilteredTrendStatisticsAsync( Arg.Is(d => d.Month == 3), Arg.Is(d => d.Month == 3), Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new Dictionary { { new DateTime(2024, 3, 15), 1000m } // 3月15日累计1000(吃喝800+交通200) }); // 年度趋势统计数据查询 // 注意:年度统计使用GetFilteredTrendStatisticsAsync获取趋势数据 // 需要返回所有分类的累计金额,包括年度旅游的2500 _transactionStatisticsService.GetFilteredTrendStatisticsAsync( Arg.Is(d => d.Month == 1), Arg.Is(d => d.Month == 12), Arg.Any(), Arg.Any>(), Arg.Is(b => b == true)) .Returns(new Dictionary { // 3月累计:月度预算1000 + 年度旅游2500 = 3500 { new DateTime(2024, 3, 1), 3500m } }); // 补充:年度旅游的GetCurrentAmountAsync调用(用于计算Current) _budgetRepository.GetCurrentAmountAsync( Arg.Is(b => b.Id == 3), Arg.Is(d => d.Month == 1), Arg.Is(d => d.Month == 12)) .Returns(2500m); // 年度旅游1-3月已花费2500 // Act // 直接测试BudgetStatsService,而不是通过BudgetService var budgetStatsService = new BudgetStatsService( _budgetRepository, _budgetArchiveRepository, _transactionStatisticsService, _dateTimeProvider, Substitute.For>() ); var result = await budgetStatsService.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Assert - 月度统计(3月份) // 月度统计应该只包含月度预算,使用当前预算限额 result.Month.Limit.Should().Be(3000); // 吃喝2500 + 交通500(使用变更后的预算) result.Month.Current.Should().Be(1000); // 吃喝800 + 交通200 result.Month.Count.Should().Be(2); // 包含2个月度预算 // Assert - 年度统计(需要考虑预算变更和剩余月份) // 新逻辑: // 1. 对于归档数据,直接使用归档的限额,不折算 // 2. 对于当前及未来月份,使用当前预算 × 剩余月份 // // 预期年度限额计算: // 1月归档:吃喝2000 + 交通500 = 2500 // 2月归档:吃喝2000 + 交通500 = 2500 // 3-12月剩余(12 - 3 + 1 = 10个月):吃喝2500×10 + 交通500×10 = 30000 // 年度旅游:12000 // 总计:2500 + 2500 + 30000 + 12000 = 47000 result.Year.Limit.Should().Be(47000); // 预期年度实际金额: // 根据趋势统计数据,3月累计: 月度预算1000 + 年度旅游2500 = 3500 // 但业务代码会累加所有预算项的Current值: // - 1月归档吃喝:1500 // - 1月归档交通:250 // - 2月归档吃喝:1800 // - 2月归档交通:300 // - 3月吃喝:800 // - 3月交通:200 // - 年度旅游:2500 // 总计:1500+250+1800+300+800+200+2500 = 7350 result.Year.Current.Should().Be(7350); // 应该包含: // - 1月归档的月度预算:吃喝、1个 // - 1月归档的月度预算:交通、1个 // - 2月归档的月度预算:吃喝、1个 // - 2月归档的月度预算:交通、1个 // - 3月当前月的月度预算:吃喝、1个 // - 3月当前月的月度预算:交通、1个 // - 4-12月未来月的月度预算:吃喝、1个(RemainingMonths=9) // - 4-12月未来月的月度预算:交通、1个(RemainingMonths=9) // - 年度旅游:1个 // 总计:9个 result.Year.Count.Should().Be(9); // 验证使用率计算正确 result.Month.Rate.Should().BeApproximately(1000m / 3000m * 100, 0.01m); // 年度使用率:7350 / 47000 * 100 = 15.64% result.Year.Rate.Should().BeApproximately(7350m / 47000m * 100, 0.01m); } }