using Service.Transaction; namespace WebApi.Test.Transaction; public class TransactionStatisticsServiceTest : BaseTest { private readonly ITransactionRecordRepository _transactionRepository = Substitute.For(); private readonly ITransactionStatisticsService _service; public TransactionStatisticsServiceTest() { // 默认配置 QueryAsync 返回空列表 _transactionRepository.QueryAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(new List()); _service = new TransactionStatisticsService( _transactionRepository ); } private void ConfigureQueryAsync(List data) { _transactionRepository.QueryAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(data); } [Fact] public async Task GetDailyStatisticsAsync_基本测试() { // Arrange var year = 2024; var month = 1; var testData = new List { new() { Id = 1, OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0), Amount = -100m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "午餐" }, new() { Id = 2, OccurredAt = new DateTime(2024, 1, 1, 15, 0, 0), Amount = -50m, Type = TransactionType.Expense, Classify = "交通", Reason = "地铁" }, new() { Id = 3, OccurredAt = new DateTime(2024, 1, 2, 9, 0, 0), Amount = 5000m, Type = TransactionType.Income, Classify = "工资", Reason = "工资收入" } }; ConfigureQueryAsync(testData); // Act var result = await _service.GetDailyStatisticsAsync(year, month); // Assert result.Should().HaveCount(2); result.Should().ContainKey("2024-01-01"); result.Should().ContainKey("2024-01-02"); result["2024-01-01"].count.Should().Be(2); result["2024-01-01"].expense.Should().Be(150m); result["2024-01-01"].income.Should().Be(0m); result["2024-01-02"].count.Should().Be(1); result["2024-01-02"].expense.Should().Be(0m); result["2024-01-02"].income.Should().Be(5000m); } [Fact] public async Task GetDailyStatisticsAsync_带储蓄分类() { // Arrange var year = 2024; var month = 1; var savingClassify = "投资,存款"; var testData = new List { new() { Id = 1, OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0), Amount = -100m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "午餐" }, new() { Id = 2, OccurredAt = new DateTime(2024, 1, 1, 15, 0, 0), Amount = -1000m, Type = TransactionType.Expense, Classify = "投资", Reason = "基金定投" }, new() { Id = 3, OccurredAt = new DateTime(2024, 1, 2, 9, 0, 0), Amount = -500m, Type = TransactionType.Expense, Classify = "存款", Reason = "银行存款" } }; _transactionRepository.QueryAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(testData); // Act var result = await _service.GetDailyStatisticsAsync(year, month, savingClassify); // Assert result.Should().HaveCount(2); result["2024-01-01"].count.Should().Be(2); result["2024-01-01"].expense.Should().Be(1100m); result["2024-01-01"].income.Should().Be(0m); result["2024-01-01"].saving.Should().Be(1000m); result["2024-01-02"].count.Should().Be(1); result["2024-01-02"].expense.Should().Be(500m); result["2024-01-02"].income.Should().Be(0m); result["2024-01-02"].saving.Should().Be(500m); } [Fact] public async Task GetDailyStatisticsByRangeAsync_基本测试() { // Arrange var startDate = new DateTime(2024, 1, 1); var endDate = new DateTime(2024, 1, 5); var testData = new List { new() { Id = 1, OccurredAt = new DateTime(2024, 1, 3, 10, 0, 0), Amount = -100m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "午餐" } }; _transactionRepository.QueryAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(testData); // Act var result = await _service.GetDailyStatisticsByRangeAsync(startDate, endDate); // Assert result.Should().HaveCount(1); result.Should().ContainKey("2024-01-03"); result["2024-01-03"].count.Should().Be(1); result["2024-01-03"].expense.Should().Be(100m); } [Fact] public async Task GetMonthlyStatisticsAsync_基本测试() { // Arrange var year = 2024; var month = 1; var testData = new List { new() { Id = 1, OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0), Amount = -100m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "午餐" }, new() { Id = 2, OccurredAt = new DateTime(2024, 1, 2, 15, 0, 0), Amount = -50m, Type = TransactionType.Expense, Classify = "交通", Reason = "地铁" }, new() { Id = 3, OccurredAt = new DateTime(2024, 1, 5, 9, 0, 0), Amount = 5000m, Type = TransactionType.Income, Classify = "工资", Reason = "工资收入" }, new() { Id = 4, OccurredAt = new DateTime(2024, 1, 10, 9, 0, 0), Amount = 2000m, Type = TransactionType.Income, Classify = "奖金", Reason = "奖金收入" } }; _transactionRepository.QueryAsync( year, month, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(testData); // Act var result = await _service.GetMonthlyStatisticsAsync(year, month); // Assert result.Year.Should().Be(year); result.Month.Should().Be(month); result.TotalExpense.Should().Be(150m); result.TotalIncome.Should().Be(7000m); result.Balance.Should().Be(6850m); result.ExpenseCount.Should().Be(2); result.IncomeCount.Should().Be(2); result.TotalCount.Should().Be(4); } [Fact] public async Task GetMonthlyStatisticsAsync_无数据() { // Arrange var year = 2024; var month = 2; _transactionRepository.QueryAsync( year, month, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new List()); // Act var result = await _service.GetMonthlyStatisticsAsync(year, month); // Assert result.Year.Should().Be(year); result.Month.Should().Be(month); result.TotalExpense.Should().Be(0m); result.TotalIncome.Should().Be(0m); result.Balance.Should().Be(0m); result.ExpenseCount.Should().Be(0); result.IncomeCount.Should().Be(0); result.TotalCount.Should().Be(0); } [Fact] public async Task GetCategoryStatisticsAsync_支出分类() { // Arrange var year = 2024; var month = 1; var type = TransactionType.Expense; var testData = new List { new() { Id = 1, OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0), Amount = -100m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "午餐" }, new() { Id = 2, OccurredAt = new DateTime(2024, 1, 2, 15, 0, 0), Amount = -50m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "晚餐" }, new() { Id = 3, OccurredAt = new DateTime(2024, 1, 3, 9, 0, 0), Amount = -200m, Type = TransactionType.Expense, Classify = "交通", Reason = "打车" }, new() { Id = 4, OccurredAt = new DateTime(2024, 1, 5, 9, 0, 0), Amount = 5000m, Type = TransactionType.Income, Classify = "工资", Reason = "工资收入" } }; _transactionRepository.QueryAsync( year, month, Arg.Any(), Arg.Any(), type, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(testData); // Act var result = await _service.GetCategoryStatisticsAsync(year, month, type); // Assert result.Should().HaveCount(2); var dining = result.First(c => c.Classify == "餐饮"); dining.Amount.Should().Be(150m); dining.Count.Should().Be(2); dining.Percent.Should().Be(42.9m); var transport = result.First(c => c.Classify == "交通"); transport.Amount.Should().Be(200m); transport.Count.Should().Be(1); transport.Percent.Should().Be(57.1m); } [Fact] public async Task GetCategoryStatisticsAsync_收入分类() { // Arrange var year = 2024; var month = 1; var type = TransactionType.Income; var testData = new List { new() { Id = 1, OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0), Amount = 5000m, Type = TransactionType.Income, Classify = "工资", Reason = "工资收入" }, new() { Id = 2, OccurredAt = new DateTime(2024, 1, 2, 15, 0, 0), Amount = 1000m, Type = TransactionType.Income, Classify = "奖金", Reason = "绩效奖金" }, new() { Id = 3, OccurredAt = new DateTime(2024, 1, 3, 9, 0, 0), Amount = -100m, Type = TransactionType.Expense, Classify = "餐饮", Reason = "午餐" } }; _transactionRepository.QueryAsync( year, month, Arg.Any(), Arg.Any(), type, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(testData); // Act var result = await _service.GetCategoryStatisticsAsync(year, month, type); // Assert result.Should().HaveCount(2); var salary = result.First(c => c.Classify == "工资"); salary.Amount.Should().Be(5000m); salary.Count.Should().Be(1); salary.Percent.Should().Be(83.3m); var bonus = result.First(c => c.Classify == "奖金"); bonus.Amount.Should().Be(1000m); bonus.Count.Should().Be(1); bonus.Percent.Should().Be(16.7m); } [Fact] public async Task GetTrendStatisticsAsync_多个月份() { // Arrange var startYear = 2024; var startMonth = 1; var monthCount = 3; var mockData = new Dictionary> { [1] = new List { new() { Id = 1, OccurredAt = new DateTime(2024, 1, 1), Amount = -1000m, Type = TransactionType.Expense }, new() { Id = 2, OccurredAt = new DateTime(2024, 1, 5), Amount = 5000m, Type = TransactionType.Income } }, [2] = new List { new() { Id = 3, OccurredAt = new DateTime(2024, 2, 1), Amount = -1500m, Type = TransactionType.Expense }, new() { Id = 4, OccurredAt = new DateTime(2024, 2, 5), Amount = 5000m, Type = TransactionType.Income } }, [3] = new List { new() { Id = 5, OccurredAt = new DateTime(2024, 3, 1), Amount = -2000m, Type = TransactionType.Expense }, new() { Id = 6, OccurredAt = new DateTime(2024, 3, 5), Amount = 5000m, Type = TransactionType.Income } } }; _transactionRepository.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(args => { var month = (int)args[1]; if (mockData.ContainsKey(month)) { return mockData[month]; } return new List(); }); // Act var result = await _service.GetTrendStatisticsAsync(startYear, startMonth, monthCount); // Assert result.Should().HaveCount(3); result[0].Year.Should().Be(2024); result[0].Month.Should().Be(1); result[0].Expense.Should().Be(1000m); result[0].Income.Should().Be(5000m); result[0].Balance.Should().Be(4000m); result[1].Year.Should().Be(2024); result[1].Month.Should().Be(2); result[1].Expense.Should().Be(1500m); result[1].Income.Should().Be(5000m); result[1].Balance.Should().Be(3500m); result[2].Year.Should().Be(2024); result[2].Month.Should().Be(3); result[2].Expense.Should().Be(2000m); result[2].Income.Should().Be(5000m); result[2].Balance.Should().Be(3000m); } [Fact] public async Task GetTrendStatisticsAsync_跨年() { // Arrange var startYear = 2024; var startMonth = 11; var monthCount = 4; _transactionRepository.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new List()); // Act var result = await _service.GetTrendStatisticsAsync(startYear, startMonth, monthCount); // Assert result.Should().HaveCount(4); result[0].Year.Should().Be(2024); result[0].Month.Should().Be(11); result[1].Year.Should().Be(2024); result[1].Month.Should().Be(12); result[2].Year.Should().Be(2025); result[2].Month.Should().Be(1); result[3].Year.Should().Be(2025); result[3].Month.Should().Be(2); } [Fact] public async Task GetReasonGroupsAsync_基本测试() { // Arrange var testData = new List { new() { Id = 1, Reason = "麦当劳", Classify = "", Amount = -50m, Type = TransactionType.Expense, OccurredAt = new DateTime(2024, 1, 1) }, new() { Id = 2, Reason = "麦当劳", Classify = "", Amount = -80m, Type = TransactionType.Expense, OccurredAt = new DateTime(2024, 1, 2) }, new() { Id = 3, Reason = "肯德基", Classify = "", Amount = -60m, Type = TransactionType.Expense, OccurredAt = new DateTime(2024, 1, 3) }, new() { Id = 4, Reason = "麦当劳", Classify = "快餐", Amount = -45m, Type = TransactionType.Expense, OccurredAt = new DateTime(2024, 1, 4) } }; _transactionRepository.QueryAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(testData); // Act var (list, total) = await _service.GetReasonGroupsAsync(); // Assert total.Should().Be(2); list.Should().HaveCount(2); var mcdonalds = list.First(g => g.Reason == "麦当劳"); mcdonalds.Count.Should().Be(2); mcdonalds.TotalAmount.Should().Be(130m); mcdonalds.SampleType.Should().Be(TransactionType.Expense); mcdonalds.SampleClassify.Should().Be(""); mcdonalds.TransactionIds.Should().Contain(1L); mcdonalds.TransactionIds.Should().Contain(2L); var kfc = list.First(g => g.Reason == "肯德基"); kfc.Count.Should().Be(1); kfc.TotalAmount.Should().Be(60m); } [Fact] public async Task GetClassifiedByKeywordsWithScoreAsync_基本匹配() { // Arrange var keywords = new List { "餐饮", "午餐" }; var testData = new List { new() { Id = 1, Reason = "今天午餐吃得很饱", Classify = "餐饮", OccurredAt = new DateTime(2024, 1, 1), Amount = -50m }, new() { Id = 2, Reason = "餐饮支出", Classify = "餐饮", OccurredAt = new DateTime(2024, 1, 2), Amount = -80m }, new() { Id = 3, Reason = "交通费", Classify = "交通", OccurredAt = new DateTime(2024, 1, 3), Amount = -10m } }; _transactionRepository.GetClassifiedByKeywordsAsync(Arg.Any>(), Arg.Any()) .ReturnsForAnyArgs(testData); // Act var result = await _service.GetClassifiedByKeywordsWithScoreAsync(keywords, minMatchRate: 0.3, limit: 10); // Assert result.Should().HaveCount(2); var first = result[0]; // 第一个结果应该是相关性分数最高的,可能是 Id=2("今天午餐吃得很饱"匹配两个关键词) first.record.Id.Should().BeOneOf(1L, 2L); first.relevanceScore.Should().BeGreaterThan(0.5); var second = result[1]; second.record.Id.Should().BeOneOf(1L, 2L); second.record.Id.Should().NotBe(first.record.Id); second.relevanceScore.Should().BeGreaterThan(0.3); } [Fact] public async Task GetClassifiedByKeywordsWithScoreAsync_精确匹配加分() { // Arrange var keywords = new List { "午餐" }; var testData = new List { new() { Id = 1, Reason = "午餐", Classify = "餐饮", OccurredAt = new DateTime(2024, 1, 1), Amount = -50m }, new() { Id = 2, Reason = "今天中午吃了一顿午餐", Classify = "餐饮", OccurredAt = new DateTime(2024, 1, 2), Amount = -80m } }; _transactionRepository.GetClassifiedByKeywordsAsync(Arg.Any>(), Arg.Any()) .ReturnsForAnyArgs(testData); // Act var result = await _service.GetClassifiedByKeywordsWithScoreAsync(keywords, minMatchRate: 0.3, limit: 10); // Assert result.Should().HaveCount(2); // 精确匹配应该得分更高 result[0].record.Id.Should().Be(1); result[0].relevanceScore.Should().BeGreaterThan(result[1].relevanceScore); } [Fact] public async Task GetFilteredTrendStatisticsAsync_按日分组() { // Arrange var startDate = new DateTime(2024, 1, 1); var endDate = new DateTime(2024, 1, 5); var type = TransactionType.Expense; var classifies = new[] { "餐饮", "交通" }; var testData = new List { new() { Id = 1, OccurredAt = new DateTime(2024, 1, 1, 10, 0, 0), Amount = -100m, Type = TransactionType.Expense, Classify = "餐饮" }, new() { Id = 2, OccurredAt = new DateTime(2024, 1, 1, 15, 0, 0), Amount = -50m, Type = TransactionType.Expense, Classify = "交通" }, new() { Id = 3, OccurredAt = new DateTime(2024, 1, 2, 10, 0, 0), Amount = -80m, Type = TransactionType.Expense, Classify = "餐饮" } }; _transactionRepository.QueryAsync( Arg.Any(), Arg.Any(), startDate, endDate, type, classifies, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(testData); // Act var result = await _service.GetFilteredTrendStatisticsAsync(startDate, endDate, type, classifies, groupByMonth: false); // Assert result.Should().HaveCount(2); result.Should().ContainKey(new DateTime(2024, 1, 1)); result.Should().ContainKey(new DateTime(2024, 1, 2)); result[new DateTime(2024, 1, 1)].Should().Be(150m); result[new DateTime(2024, 1, 2)].Should().Be(80m); } [Fact] public async Task GetFilteredTrendStatisticsAsync_按月分组() { // Arrange var startDate = new DateTime(2024, 1, 1); var endDate = new DateTime(2024, 3, 31); var type = TransactionType.Expense; var classifies = new[] { "餐饮" }; var testData = new List { new() { Id = 1, OccurredAt = new DateTime(2024, 1, 15), Amount = -1000m, Type = TransactionType.Expense, Classify = "餐饮" }, new() { Id = 2, OccurredAt = new DateTime(2024, 2, 15), Amount = -1500m, Type = TransactionType.Expense, Classify = "餐饮" }, new() { Id = 3, OccurredAt = new DateTime(2024, 3, 15), Amount = -2000m, Type = TransactionType.Expense, Classify = "餐饮" } }; _transactionRepository.QueryAsync( Arg.Any(), Arg.Any(), startDate, endDate, type, classifies, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(testData); // Act var result = await _service.GetFilteredTrendStatisticsAsync(startDate, endDate, type, classifies, groupByMonth: true); // Assert result.Should().HaveCount(3); result.Should().ContainKey(new DateTime(2024, 1, 1)); result.Should().ContainKey(new DateTime(2024, 2, 1)); result.Should().ContainKey(new DateTime(2024, 3, 1)); result[new DateTime(2024, 1, 1)].Should().Be(1000m); result[new DateTime(2024, 2, 1)].Should().Be(1500m); result[new DateTime(2024, 3, 1)].Should().Be(2000m); } [Fact] public async Task GetAmountGroupByClassifyAsync_基本测试() { // Arrange var startTime = new DateTime(2024, 1, 1); var endTime = new DateTime(2024, 1, 31); var testData = new List { new() { Id = 1, Amount = -100m, Type = TransactionType.Expense, Classify = "餐饮" }, new() { Id = 2, Amount = -50m, Type = TransactionType.Expense, Classify = "餐饮" }, new() { Id = 3, Amount = 5000m, Type = TransactionType.Income, Classify = "工资" }, new() { Id = 4, Amount = -200m, Type = TransactionType.Expense, Classify = "交通" } }; _transactionRepository.QueryAsync( Arg.Any(), Arg.Any(), startTime, endTime, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(testData); // Act var result = await _service.GetAmountGroupByClassifyAsync(startTime, endTime); // Assert result.Should().HaveCount(3); result[("餐饮", TransactionType.Expense)].Should().Be(-150m); result[("工资", TransactionType.Income)].Should().Be(5000m); result[("交通", TransactionType.Expense)].Should().Be(-200m); } [Fact] public async Task GetAmountGroupByClassifyAsync_相同分类不同类型() { // Arrange var startTime = new DateTime(2024, 1, 1); var endTime = new DateTime(2024, 1, 31); var testData = new List { new() { Id = 1, Amount = -100m, Type = TransactionType.Expense, Classify = "兼职" }, new() { Id = 2, Amount = 500m, Type = TransactionType.Income, Classify = "兼职" } }; _transactionRepository.QueryAsync( Arg.Any(), Arg.Any(), startTime, endTime, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(testData); // Act var result = await _service.GetAmountGroupByClassifyAsync(startTime, endTime); // Assert result.Should().HaveCount(2); result[("兼职", TransactionType.Expense)].Should().Be(-100m); result[("兼职", TransactionType.Income)].Should().Be(500m); } }