using FsCheck; using FsCheck.Xunit; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging; using Moq; using VendingMachine.Application.DTOs.Points; using VendingMachine.Application.Services; using VendingMachine.Domain.Entities; using VendingMachine.Infrastructure.Data; using VendingMachine.Infrastructure.Services; namespace VendingMachine.Tests; /// /// 积分模块属性测试 /// public class PointsPropertyTests { // Feature: vending-machine-app, Property 10: 积分转换计算 // 对于任意支付金额和转换比,计算出的积分应等于 Math.Floor(金额 * 转换比)。 // **Validates: Requirements 5.1** [Property(MaxTest = 100)] public bool PointsConversion_ShouldMatchFloorFormula(PositiveInt amountRaw, PositiveInt rateRaw) { // 生成合理范围的金额(0.01 ~ 10000.00)和转换比(0.1 ~ 10.0) var amount = (decimal)(amountRaw.Get % 1000000 + 1) / 100m; var rate = (decimal)(rateRaw.Get % 100 + 1) / 10m; var points = IPointsService.CalculatePoints(amount, rate); var expected = (int)Math.Floor(amount * rate); return points == expected; } [Property(MaxTest = 100)] public bool PointsConversion_ShouldAlwaysBeNonNegative(PositiveInt amountRaw, PositiveInt rateRaw) { var amount = (decimal)(amountRaw.Get % 1000000 + 1) / 100m; var rate = (decimal)(rateRaw.Get % 100 + 1) / 10m; var points = IPointsService.CalculatePoints(amount, rate); return points >= 0; } // Feature: vending-machine-app, Property 11: 积分记录渲染完整性 // 对于任意积分记录,获取记录应包含来源、时间和增加数量;使用记录应包含使用方式、时间和减少数量。 // **Validates: Requirements 5.4, 5.5** [Property(MaxTest = 100)] public bool EarnRecord_ShouldContainAllRequiredFields( NonEmptyString source, PositiveInt amount) { var record = new PointRecordDto { Id = Guid.NewGuid().ToString(), Type = "earn", Amount = amount.Get, Source = source.Get, CreatedAt = DateTime.UtcNow }; // 获取记录应包含来源、时间和增加数量 return !string.IsNullOrEmpty(record.Source) && record.CreatedAt != default && record.Amount > 0 && record.Type == "earn"; } [Property(MaxTest = 100)] public bool SpendRecord_ShouldContainAllRequiredFields( NonEmptyString source, PositiveInt amount) { var record = new PointRecordDto { Id = Guid.NewGuid().ToString(), Type = "spend", Amount = amount.Get, Source = source.Get, CreatedAt = DateTime.UtcNow }; // 使用记录应包含使用方式、时间和减少数量 return !string.IsNullOrEmpty(record.Source) && record.CreatedAt != default && record.Amount > 0 && record.Type == "spend"; } // Feature: vending-machine-app, Property 12: 赠送积分守恒 // 对于任意积分赠送操作,如果发送方积分充足,则发送方减少的积分等于接收方增加的积分(总积分守恒); // 如果发送方积分不足,则双方积分均不变。 // **Validates: Requirements 5.6, 5.7** [Property(MaxTest = 100)] public bool GiftPoints_WhenSufficientBalance_TotalPointsConserved(PositiveInt balanceRaw, PositiveInt giftRaw) { // 确保发送方积分充足 var balance = balanceRaw.Get % 10000 + 1; var giftAmount = (giftRaw.Get % balance) + 1; var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: $"GiftSufficient_{Guid.NewGuid()}") .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .Options; using var db = new AppDbContext(options); var logger = Mock.Of>(); var sender = new User { Uid = "sender000001", Phone = "13800000001", AreaCode = "+86", Nickname = "发送方", PointsBalance = balance }; var receiver = new User { Uid = "receiver0001", Phone = "13800000002", AreaCode = "+86", Nickname = "接收方", PointsBalance = 0 }; db.Users.AddRange(sender, receiver); db.SaveChanges(); var totalBefore = sender.PointsBalance + receiver.PointsBalance; var service = new PointsService(db, logger); var result = service.GiftPointsAsync("sender000001", new GiftPointsRequest { TargetUid = "receiver0001", Amount = giftAmount }).GetAwaiter().GetResult(); db.Entry(sender).Reload(); db.Entry(receiver).Reload(); var totalAfter = sender.PointsBalance + receiver.PointsBalance; // 总积分守恒 return result.Success && totalBefore == totalAfter && sender.PointsBalance == balance - giftAmount && receiver.PointsBalance == giftAmount; } [Property(MaxTest = 100)] public bool GiftPoints_WhenInsufficientBalance_BothUnchanged(PositiveInt balanceRaw, PositiveInt extraRaw) { // 确保发送方积分不足 var balance = balanceRaw.Get % 100 + 1; var giftAmount = balance + (extraRaw.Get % 1000) + 1; var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: $"GiftInsufficient_{Guid.NewGuid()}") .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .Options; using var db = new AppDbContext(options); var logger = Mock.Of>(); var sender = new User { Uid = "sender000001", Phone = "13800000001", AreaCode = "+86", Nickname = "发送方", PointsBalance = balance }; var receiver = new User { Uid = "receiver0001", Phone = "13800000002", AreaCode = "+86", Nickname = "接收方", PointsBalance = 50 }; db.Users.AddRange(sender, receiver); db.SaveChanges(); var senderBefore = sender.PointsBalance; var receiverBefore = receiver.PointsBalance; var service = new PointsService(db, logger); var result = service.GiftPointsAsync("sender000001", new GiftPointsRequest { TargetUid = "receiver0001", Amount = giftAmount }).GetAwaiter().GetResult(); db.Entry(sender).Reload(); db.Entry(receiver).Reload(); // 积分不足时双方积分均不变 return !result.Success && sender.PointsBalance == senderBefore && receiver.PointsBalance == receiverBefore; } }