using Microsoft.Extensions.Logging; 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 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 IBudgetStatsService _budgetStatsService; private readonly BudgetService _service; public BudgetStatsTest() { _dateTimeProvider.Now.Returns(new DateTime(2024, 1, 15)); _budgetStatsService = new BudgetStatsService( _budgetRepository, _budgetArchiveRepository, _transactionsRepository, _dateTimeProvider, Substitute.For>() ); _service = new BudgetService( _budgetRepository, _budgetArchiveRepository, _transactionsRepository, _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; }); _transactionsRepository.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); _transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new Dictionary()); // Act var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Assert // 1月有31天,15号经过了15天 // 3100 * 15 / 31 = 1500 result.Month.Limit.Should().Be(3100); result.Month.Current.Should().Be(1500); } [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); // 月度统计使用趋势统计数据(只包含月度预算的分类) _transactionsRepository.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("餐饮")), // 只包含月度预算的分类 false) .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]; // 年度范围查询 - 年度旅游 if (startDate.Month == 1 && startDate.Day == 1) { return b.Name == "年度旅游" ? 2000m : 0m; } return 0m; }); // 年度趋势统计(包含所有分类) _transactionsRepository.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 result.Month.Count.Should().Be(1); // 只包含1个月度预算 // 年度统计中:包含所有预算(月度预算按剩余月份折算) // 1月时剩余月份 = 12 - 1 + 1 = 12个月 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(2); // 包含2个预算(1个月度+1个年度) } [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); _transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new Dictionary()); // Act var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Assert // 2024是闰年,366天。1月1号是第1天。 // 3660 * 1 / 366 = 10 result.Year.Limit.Should().Be(3660); result.Year.Current.Should().Be(10); } [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); _transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) .Returns(new Dictionary()); // Act var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Assert // 2024是闰年。1月(31) + 2月(29) + 3月(31) = 91天 // 3660 * 91 / 366 = 910 result.Year.Limit.Should().Be(3660); result.Year.Current.Should().Be(910); } [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 }; }); // 设置月度趋势统计数据:只包含月度预算相关的分类(餐饮、零食、交通) // 注意:不应包含年度预算的分类(旅游、度假、奖金、年终奖) _transactionsRepository.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("交通")), false) .Returns(new Dictionary { { new DateTime(2024, 1, 15), 1500m } // 1月15日累计1500(吃喝1200+交通300,不包含年度旅游2000) }); // 设置年度趋势统计数据:包含所有预算相关的分类 _transactionsRepository.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) }); // 设置收入相关的趋势统计数据 _transactionsRepository.GetFilteredTrendStatisticsAsync( Arg.Is(d => d.Year == 2024 && d.Month == 1), Arg.Is(d => d.Year == 2024 && d.Month == 1), TransactionType.Income, Arg.Any>(), false) .Returns(new Dictionary()); // 月度收入为空 _transactionsRepository.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月时剩余月份 = 12 - 1 + 1 = 12个月 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(3); // 包含3个预算(2个月度+1个年度) // 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 // 设置趋势统计数据查询(用于月度统计) _transactionsRepository.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 _transactionsRepository.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 } }); // Act // 直接测试BudgetStatsService,而不是通过BudgetService var budgetStatsService = new BudgetStatsService( _budgetRepository, _budgetArchiveRepository, _transactionsRepository, _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 result.Year.Current.Should().Be(3500); // 应该包含: // - 1月归档的月度预算:吃喝、1个 // - 1月归档的月度预算:交通、1个 // - 2月归档的月度预算:吃喝、1个 // - 2月归档的月度预算:交通、1个 // - 3-12月的月度预算:吃喝、1个 // - 3-12月的月度预算:交通、1个 // - 年度旅游:1个 // 总计:7个 result.Year.Count.Should().Be(7); // 验证使用率计算正确 result.Month.Rate.Should().BeApproximately(1000m / 3000m * 100, 0.01m); result.Year.Rate.Should().BeApproximately(3500m / 47000m * 100, 0.01m); } }