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;
}
}