using Common; namespace WebApi.Test; public class BudgetSavingsTest : BaseTest { private readonly IBudgetRepository _budgetRepository = Substitute.For(); private readonly IBudgetArchiveRepository _budgetArchiveRepository = Substitute.For(); private readonly ITransactionRecordRepository _transactionsRepository = Substitute.For(); private readonly IConfigService _configService = Substitute.For(); private readonly IDateTimeProvider _dateTimeProvider = Substitute.For(); private readonly BudgetSavingsService _service; public BudgetSavingsTest() { _dateTimeProvider.Now.Returns(DateTime.Now); _service = new BudgetSavingsService( _budgetRepository, _budgetArchiveRepository, _transactionsRepository, _configService, _dateTimeProvider ); } [Fact] public async Task GetSavings_月度_Test() { // Arrange var referenceDate = new DateTime(2024, 1, 1); var budgets = new List { new() { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 10000, Category = BudgetCategory.Income, SelectedCategories = "工资" }, new() { Id = 2, Name = "餐饮", Type = BudgetPeriodType.Month, Limit = 2000, Category = BudgetCategory.Expense, SelectedCategories = "餐饮" } }; var transactions = new Dictionary<(string, TransactionType), decimal> { { ("工资", TransactionType.Income), 10000m }, { ("餐饮", TransactionType.Expense), 1500m } }; _budgetRepository.GetAllAsync().Returns(budgets); _transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any(), Arg.Any()) .Returns(transactions); _configService.GetConfigByKeyAsync("SavingsCategories").Returns(Task.FromResult("存款")); // Act var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Month, referenceDate); // Assert result.Should().NotBeNull(); result.Limit.Should().Be(10000 - 2000); // 计划收入 - 计划支出 = 10000 - 2000 = 8000 result.Current.Should().Be(1500 - 10000); // 实际支出 - 实际收入 = 1500 - 10000 = -8500 result.Name.Should().Be("月度存款计划"); } [Fact] public async Task GetSavings_月度_年度收支_Test() { // Arrange var referenceDate = new DateTime(2024, 1, 1); var budgets = new List { new() { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 10000, Category = BudgetCategory.Income, SelectedCategories = "工资" }, new() { Id = 2, Name = "餐饮", Type = BudgetPeriodType.Month, Limit = 2000, Category = BudgetCategory.Expense, SelectedCategories = "餐饮" }, new() { Id = 3, Name = "年终奖", Type = BudgetPeriodType.Year, Limit = 50000, Category = BudgetCategory.Income, SelectedCategories = "奖金" }, new() { Id = 4, Name = "保险", Type = BudgetPeriodType.Year, Limit = 6000, Category = BudgetCategory.Expense, SelectedCategories = "保险" } }; var transactions = new Dictionary<(string, TransactionType), decimal> { { ("工资", TransactionType.Income), 10000m }, { ("餐饮", TransactionType.Expense), 1500m }, { ("奖金", TransactionType.Income), 50000m }, { ("保险", TransactionType.Expense), 6000m } }; _budgetRepository.GetAllAsync().Returns(budgets); _transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any(), Arg.Any()) .Returns(transactions); _configService.GetConfigByKeyAsync("SavingsCategories").Returns(Task.FromResult("存款")); // Act var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Month, referenceDate); // Assert result.Should().NotBeNull(); // 计划收入 = 月度计划收入(10000) + 本月发生的年度实际收入(50000) = 60000 // 计划支出 = 月度计划支出(2000) + 本月发生的年度实际支出(6000) = 8000 // 计划存款 = 60000 - 8000 = 52000 result.Limit.Should().Be(60000 - 8000); // 实际 = 实际支出(1500 + 6000) - 实际收入(10000 + 50000) = 7500 - 60000 = -52500 result.Current.Should().Be((1500 + 6000) - (10000 + 50000)); } [Fact] public async Task GetSavings_月度_年度收支_硬性收支_Test() { // Arrange // 模拟当前日期为 2026-01-20 var now = new DateTime(2026, 1, 20); _dateTimeProvider.Now.Returns(now); var referenceDate = new DateTime(2026, 1, 1); var budgets = new List { // 房租 3100,硬性支出。假设目前还没付(实际为0),系统应按 20/31 天估算为 2000 new() { Id = 1, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3100, Category = BudgetCategory.Expense, SelectedCategories = "房租", IsMandatoryExpense = true }, // 理财收益 6200,硬性收入。假设目前还没到账(实际为0),系统应按 20/31 天估算为 4000 new() { Id = 2, Name = "理财收益", Type = BudgetPeriodType.Month, Limit = 6200, Category = BudgetCategory.Income, SelectedCategories = "理财", IsMandatoryExpense = true } }; // 模拟实际交易为 0 var transactions = new Dictionary<(string, TransactionType), decimal>(); _budgetRepository.GetAllAsync().Returns(budgets); _transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any(), Arg.Any()) .Returns(transactions); _configService.GetConfigByKeyAsync("SavingsCategories").Returns(Task.FromResult("存款")); // Act var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Month, referenceDate); // Assert // 2026年1月有31天,当前是20号 // 预期的估算值: // 支出 = 3100 / 31 * 20 = 2000 // 收入 = 6200 / 31 * 20 = 4000 result.Should().NotBeNull(); // 计划存款 = 计划收入(6200) - 计划支出(3100) = 3100 result.Limit.Should().Be(6200 - 3100); // 实际 = 估算支出(2000) - 估算收入(4000) = -2000 result.Current.Should().Be(2000 - 4000); } [Fact] public async Task GetSavings_年度_预算_实际_Test() { // Arrange var year = 2024; var referenceDate = new DateTime(year, 1, 1); _dateTimeProvider.Now.Returns(new DateTime(year, 1, 20)); var budgets = new List { new() { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 10000, Category = BudgetCategory.Income, SelectedCategories = "工资" }, new() { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Category = BudgetCategory.Expense, SelectedCategories = "房租" }, new() { Id = 3, Name = "年终奖", Type = BudgetPeriodType.Year, Limit = 50000, Category = BudgetCategory.Income, SelectedCategories = "奖金" }, new() { Id = 4, Name = "旅游", Type = BudgetPeriodType.Year, Limit = 20000, Category = BudgetCategory.Expense, SelectedCategories = "旅游" } }; var transactions = new Dictionary<(string, TransactionType), decimal> { { ("工资", TransactionType.Income), 10000m }, { ("房租", TransactionType.Expense), 3000m }, { ("存款", TransactionType.None), 2000m } }; _budgetRepository.GetAllAsync().Returns(budgets); _transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any(), Arg.Any()) .Returns(transactions); _budgetArchiveRepository.GetArchivesByYearAsync(year).Returns(new List()); _configService.GetConfigByKeyAsync("SavingsCategories").Returns(Task.FromResult("存款")); // Act var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Year, referenceDate); // Assert result.Should().NotBeNull(); // MonthlyIncome: 10000 * 12 = 170000 // MonthlyExpense: 3000 * 12 = 56000 // YearlyIncome: 50000 * 1 = 50000 // YearlyExpense: 20000 * 1 = 20000 // Savings: (170000 + 50000) - (56000 + 20000) = 114000 result.Limit.Should().Be(114000); result.Current.Should().Be(2000); result.Name.Should().Be("年度存款计划"); } [Fact] public async Task GetSavings_年度_归档盈亏_Test() { // Arrange var year = 2024; // 当前是3月15号 _dateTimeProvider.Now.Returns(new DateTime(year, 3, 15)); var budgets = new List { // Monthly Budget changed from 10000 (Jan) to 11000 (Current/Feb) new() { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 11000, Category = BudgetCategory.Income, SelectedCategories = "工资" }, new() { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Category = BudgetCategory.Expense, SelectedCategories = "房租" }, }; var currentTransactions = new Dictionary<(string, TransactionType), decimal> { { ("工资", TransactionType.Income), 11000m } }; var archives = new List { new() { Year = year, Month = 1, Content = [ new BudgetArchiveContent { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 10000, Actual = 12000, Category = BudgetCategory.Income }, new BudgetArchiveContent { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Actual = 3600, Category = BudgetCategory.Expense } ] }, new() { Year = year, Month = 2, Content = [ new BudgetArchiveContent { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 11000, Actual = 3000, Category = BudgetCategory.Income }, new BudgetArchiveContent { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Actual = 5000, Category = BudgetCategory.Expense } ] } }; _budgetRepository.GetAllAsync().Returns(budgets); _transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any(), Arg.Any()) .Returns(currentTransactions); _budgetArchiveRepository.GetArchivesByYearAsync(year).Returns(archives); _configService.GetConfigByKeyAsync("SavingsCategories").Returns(Task.FromResult("存款")); // Act var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Year, new DateTime(year, 1, 1)); // Assert result.Should().NotBeNull(); // 归档实际收入1月 = 12000 - 3600 = 8400 // 归档实际收入2月 = 3000 - 5000 = -2000 // 预计收入 = 8400 + -2000 + 11000 * 10 = 116400 // 预计支出 = 3000 * 10 = 30000 // 预计存款 = 116400 - 30000 = 86400 result.Limit.Should().Be(86400); } [Fact] public async Task GetSavings_年度_硬性收支_Test() { // Arrange var year = 2024; // 当前是3月15号 _dateTimeProvider.Now.Returns(new DateTime(year, 3, 15)); var budgets = new List { // Monthly Budget changed from 10000 (Jan) to 11000 (Current/Feb) new() { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 11000, Category = BudgetCategory.Income, SelectedCategories = "工资" }, new() { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Category = BudgetCategory.Expense, SelectedCategories = "房租" }, new() { Id = 3, Name = "硬性支出", Type = BudgetPeriodType.Year, Limit = 10000, Category = BudgetCategory.Expense, SelectedCategories = "房租", IsMandatoryExpense = true }, }; var currentTransactions = new Dictionary<(string, TransactionType), decimal> { { ("工资", TransactionType.Income), 11000m } }; var archives = new List { new() { Year = year, Month = 1, Content = [ new BudgetArchiveContent { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 10000, Actual = 12000, Category = BudgetCategory.Income }, new BudgetArchiveContent { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Actual = 3600, Category = BudgetCategory.Expense } ] }, new() { Year = year, Month = 2, Content = [ new BudgetArchiveContent { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 11000, Actual = 3000, Category = BudgetCategory.Income }, new BudgetArchiveContent { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Actual = 5000, Category = BudgetCategory.Expense } ] } }; _budgetRepository.GetAllAsync().Returns(budgets); _transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any(), Arg.Any()) .Returns(currentTransactions); _budgetArchiveRepository.GetArchivesByYearAsync(year).Returns(archives); _configService.GetConfigByKeyAsync("SavingsCategories").Returns(Task.FromResult("存款")); // Act var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Year, new DateTime(year, 1, 1)); // Assert result.Should().NotBeNull(); // 归档实际收入1月 = 12000 - 3600 = 8400 // 归档实际收入2月 = 3000 - 5000 = -2000 // 预计收入 = 8400 + -2000 + 11000 * 10 = 116400 // 硬性支出平均到每天 = 10000 / 366 * 75 = 2049.18 // 预计支出 = 3000 * 10 = 30000 // 预计存款 = 116400 - 30000 - 2049.18 = 84350.82 result.Limit.Should().BeApproximately(84350.82m, 0.01m); } [Fact] public async Task GetSavings_年度_不限额_Test() { // Arrange var year = 2024; // 当前是3月15号 _dateTimeProvider.Now.Returns(new DateTime(year, 3, 15)); var budgets = new List { // Monthly Budget changed from 10000 (Jan) to 11000 (Current/Feb) new() { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 11000, Category = BudgetCategory.Income, SelectedCategories = "工资" }, new() { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Category = BudgetCategory.Expense, SelectedCategories = "房租" }, new() { Id = 3, Name = "硬性支出", Type = BudgetPeriodType.Year, Limit = 10000, Category = BudgetCategory.Expense, SelectedCategories = "房租", IsMandatoryExpense = true }, new() { Id = 4, Name = "意外支出", Type = BudgetPeriodType.Year, Limit = 0, Category = BudgetCategory.Expense, SelectedCategories = "意外支出", NoLimit = true }, new() { Id = 5, Name = "意外收入", Type = BudgetPeriodType.Month, Limit = 0, Category = BudgetCategory.Income, SelectedCategories = "意外收入", NoLimit = true } }; var currentTransactions = new Dictionary<(string, TransactionType), decimal> { { ("工资", TransactionType.Income), 11000m }, { ("意外支出", TransactionType.Expense), 300m }, { ("意外收入", TransactionType.Income), 2000m }, }; var archives = new List { new() { Year = year, Month = 1, Content = [ new BudgetArchiveContent { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 10000, Actual = 12000, Category = BudgetCategory.Income }, new BudgetArchiveContent { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Actual = 3600, Category = BudgetCategory.Expense } ] }, new() { Year = year, Month = 2, Content = [ new BudgetArchiveContent { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 11000, Actual = 3000, Category = BudgetCategory.Income }, new BudgetArchiveContent { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Actual = 5000, Category = BudgetCategory.Expense } ] } }; _budgetRepository.GetAllAsync().Returns(budgets); _transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any(), Arg.Any()) .Returns(currentTransactions); _budgetArchiveRepository.GetArchivesByYearAsync(year).Returns(archives); _configService.GetConfigByKeyAsync("SavingsCategories").Returns(Task.FromResult("存款")); // Act var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Year, new DateTime(year, 1, 1)); // Assert result.Should().NotBeNull(); // 归档实际收入1月 = 12000 - 3600 = 8400 // 归档实际收入2月 = 3000 - 5000 = -2000 // 预计收入 = 8400 + -2000 + 11000 * 10 = 116400 // 硬性支出平均到每天 = 10000 / 366 * 75 = 2049.18 // 预计支出 = 3000 * 10 = 30000 // 预计意外支出 = 300 // 预计意外收入 = 2000 // 预计存款 = 116400 - 30000 - 2049.18 - 300 + 2000 = 86050.82 result.Limit.Should().BeApproximately(86050.82m, 0.1m); } }