using FsCheck;
using FsCheck.Xunit;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MiAssessment.Admin.Business.Data;
using MiAssessment.Admin.Business.Entities;
using MiAssessment.Admin.Business.Models;
using MiAssessment.Admin.Business.Models.Assessment;
using MiAssessment.Admin.Business.Models.Common;
using MiAssessment.Admin.Business.Services;
using Moq;
using Xunit;
namespace MiAssessment.Tests.Admin;
///
/// Mapping 属性测试
/// 验证题目分类映射服务的正确性属性
///
public class MappingPropertyTests
{
private readonly Mock> _mockLogger = new();
#region Property 10: Mapping Relationship Bidirectionality
///
/// Property 10: 通过题目ID查询映射应包含关联的分类ID
/// *For any* QuestionCategoryMapping, querying mappings by QuestionId SHALL include the CategoryId.
///
/// **Validates: Requirements 7.1**
///
[Property(MaxTest = 100)]
public bool Bidirectionality_QueryByQuestion_ShouldIncludeCategoryId(PositiveInt seed)
{
// Arrange: 创建题目、分类和映射关系
using var dbContext = CreateDbContext();
var (assessmentTypeId, questionId, categoryId) = CreateTestData(dbContext, seed.Get);
// 创建映射关系
var mapping = new QuestionCategoryMapping
{
QuestionId = questionId,
CategoryId = categoryId,
CreateTime = DateTime.Now
};
dbContext.QuestionCategoryMappings.Add(mapping);
dbContext.SaveChanges();
var service = new AssessmentService(dbContext, _mockLogger.Object);
// Act: 通过题目ID查询映射
var categories = service.GetMappingsByQuestionAsync(questionId).GetAwaiter().GetResult();
// Assert: 应该包含关联的分类ID
return categories.Any(c => c.Id == categoryId);
}
///
/// Property 10: 通过分类ID查询映射应包含关联的题目ID
/// *For any* QuestionCategoryMapping, querying mappings by CategoryId SHALL include the QuestionId.
///
/// **Validates: Requirements 7.2**
///
[Property(MaxTest = 100)]
public bool Bidirectionality_QueryByCategory_ShouldIncludeQuestionId(PositiveInt seed)
{
// Arrange: 创建题目、分类和映射关系
using var dbContext = CreateDbContext();
var (assessmentTypeId, questionId, categoryId) = CreateTestData(dbContext, seed.Get);
// 创建映射关系
var mapping = new QuestionCategoryMapping
{
QuestionId = questionId,
CategoryId = categoryId,
CreateTime = DateTime.Now
};
dbContext.QuestionCategoryMappings.Add(mapping);
dbContext.SaveChanges();
var service = new AssessmentService(dbContext, _mockLogger.Object);
// Act: 通过分类ID查询映射
var questions = service.GetMappingsByCategoryAsync(categoryId).GetAwaiter().GetResult();
// Assert: 应该包含关联的题目ID
return questions.Any(q => q.Id == questionId);
}
///
/// Property 10: 双向查询应返回一致的映射关系
/// 如果通过题目查询到分类A,则通过分类A查询应能找到该题目
///
/// **Validates: Requirements 7.1, 7.2**
///
[Property(MaxTest = 100)]
public bool Bidirectionality_ConsistentBothDirections(PositiveInt seed, PositiveInt categoryCount)
{
// 限制分类数量为1-5
var actualCategoryCount = (categoryCount.Get % 5) + 1;
// Arrange: 创建题目和多个分类
using var dbContext = CreateDbContext();
var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get);
var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get);
var categoryIds = new List();
for (int i = 0; i < actualCategoryCount; i++)
{
var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get + i);
categoryIds.Add(categoryId);
// 创建映射关系
var mapping = new QuestionCategoryMapping
{
QuestionId = questionId,
CategoryId = categoryId,
CreateTime = DateTime.Now
};
dbContext.QuestionCategoryMappings.Add(mapping);
}
dbContext.SaveChanges();
var service = new AssessmentService(dbContext, _mockLogger.Object);
// Act: 通过题目查询分类
var categoriesFromQuestion = service.GetMappingsByQuestionAsync(questionId).GetAwaiter().GetResult();
// Assert: 对于每个查询到的分类,反向查询应能找到原题目
foreach (var category in categoriesFromQuestion)
{
var questionsFromCategory = service.GetMappingsByCategoryAsync(category.Id).GetAwaiter().GetResult();
if (!questionsFromCategory.Any(q => q.Id == questionId))
{
return false;
}
}
return true;
}
///
/// Property 10: 多对多映射关系的双向一致性
/// 多个题目关联同一分类时,通过分类查询应返回所有关联的题目
///
/// **Validates: Requirements 7.1, 7.2**
///
[Property(MaxTest = 50)]
public bool Bidirectionality_ManyToMany_Consistency(PositiveInt seed, PositiveInt questionCount)
{
// 限制题目数量为2-5
var actualQuestionCount = (questionCount.Get % 4) + 2;
// Arrange: 创建多个题目和一个分类
using var dbContext = CreateDbContext();
var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get);
var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get);
var questionIds = new List();
for (int i = 0; i < actualQuestionCount; i++)
{
var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get + i);
questionIds.Add(questionId);
// 创建映射关系
var mapping = new QuestionCategoryMapping
{
QuestionId = questionId,
CategoryId = categoryId,
CreateTime = DateTime.Now
};
dbContext.QuestionCategoryMappings.Add(mapping);
}
dbContext.SaveChanges();
var service = new AssessmentService(dbContext, _mockLogger.Object);
// Act: 通过分类查询题目
var questionsFromCategory = service.GetMappingsByCategoryAsync(categoryId).GetAwaiter().GetResult();
// Assert: 应该返回所有关联的题目
if (questionsFromCategory.Count != actualQuestionCount) return false;
foreach (var questionId in questionIds)
{
if (!questionsFromCategory.Any(q => q.Id == questionId))
{
return false;
}
}
return true;
}
///
/// Property 10: 软删除的题目不应出现在分类的映射查询结果中
///
/// **Validates: Requirements 7.2**
///
[Property(MaxTest = 100)]
public bool Bidirectionality_DeletedQuestionNotInCategoryQuery(PositiveInt seed)
{
// Arrange: 创建题目、分类和映射关系
using var dbContext = CreateDbContext();
var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get);
var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get);
// 创建正常题目
var normalQuestionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get);
dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping
{
QuestionId = normalQuestionId,
CategoryId = categoryId,
CreateTime = DateTime.Now
});
// 创建已删除的题目
var deletedQuestion = new Question
{
AssessmentTypeId = assessmentTypeId,
QuestionNo = seed.Get + 1000,
Content = $"Deleted Question {seed.Get}",
Sort = 1,
Status = 1,
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now,
IsDeleted = true // 已软删除
};
dbContext.Questions.Add(deletedQuestion);
dbContext.SaveChanges();
dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping
{
QuestionId = deletedQuestion.Id,
CategoryId = categoryId,
CreateTime = DateTime.Now
});
dbContext.SaveChanges();
var service = new AssessmentService(dbContext, _mockLogger.Object);
// Act: 通过分类查询题目
var questions = service.GetMappingsByCategoryAsync(categoryId).GetAwaiter().GetResult();
// Assert: 只应该返回正常的题目,不应包含已删除的题目
return questions.Count == 1 && questions[0].Id == normalQuestionId;
}
///
/// Property 10: 软删除的分类不应出现在题目的映射查询结果中
///
/// **Validates: Requirements 7.1**
///
[Property(MaxTest = 100)]
public bool Bidirectionality_DeletedCategoryNotInQuestionQuery(PositiveInt seed)
{
// Arrange: 创建题目和分类
using var dbContext = CreateDbContext();
var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get);
var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get);
// 创建正常分类
var normalCategoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get);
dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping
{
QuestionId = questionId,
CategoryId = normalCategoryId,
CreateTime = DateTime.Now
});
// 创建已删除的分类
var deletedCategory = new ReportCategory
{
AssessmentTypeId = assessmentTypeId,
ParentId = 0,
Name = $"Deleted Category {seed.Get}",
Code = $"DELETED_{seed.Get}_{Guid.NewGuid():N}".Substring(0, 20),
CategoryType = 1,
ScoreRule = 1,
Sort = 1,
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now,
IsDeleted = true // 已软删除
};
dbContext.ReportCategories.Add(deletedCategory);
dbContext.SaveChanges();
dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping
{
QuestionId = questionId,
CategoryId = deletedCategory.Id,
CreateTime = DateTime.Now
});
dbContext.SaveChanges();
var service = new AssessmentService(dbContext, _mockLogger.Object);
// Act: 通过题目查询分类
var categories = service.GetMappingsByQuestionAsync(questionId).GetAwaiter().GetResult();
// Assert: 只应该返回正常的分类,不应包含已删除的分类
return categories.Count == 1 && categories[0].Id == normalCategoryId;
}
#endregion
#region Property 11: Batch Operation Atomicity
///
/// Property 11: 批量更新成功时所有映射都应被更新
/// *For any* batch update of question-category mappings, all mappings SHALL be updated successfully.
///
/// **Validates: Requirements 7.6**
///
[Property(MaxTest = 100)]
public bool Atomicity_SuccessfulUpdate_AllMappingsUpdated(PositiveInt seed, PositiveInt categoryCount)
{
// 限制分类数量为1-5
var actualCategoryCount = (categoryCount.Get % 5) + 1;
// Arrange: 创建题目和多个分类
using var dbContext = CreateDbContext();
var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get);
var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get);
var categoryIds = new List();
for (int i = 0; i < actualCategoryCount; i++)
{
var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get + i);
categoryIds.Add(categoryId);
}
var service = new AssessmentService(dbContext, _mockLogger.Object);
// Act: 批量更新映射
var request = new BatchUpdateMappingsRequest
{
QuestionId = questionId,
CategoryIds = categoryIds
};
try
{
var result = service.BatchUpdateMappingsAsync(request).GetAwaiter().GetResult();
if (!result) return false;
// Assert: 验证所有映射都已创建
var mappings = dbContext.QuestionCategoryMappings
.Where(m => m.QuestionId == questionId)
.ToList();
if (mappings.Count != actualCategoryCount) return false;
foreach (var categoryId in categoryIds)
{
if (!mappings.Any(m => m.CategoryId == categoryId))
{
return false;
}
}
return true;
}
catch (BusinessException)
{
return false;
}
}
///
/// Property 11: 批量更新应替换所有现有映射
///
/// **Validates: Requirements 7.6**
///
[Property(MaxTest = 100)]
public bool Atomicity_UpdateReplacesExistingMappings(PositiveInt seed)
{
// Arrange: 创建题目和分类,并创建初始映射
using var dbContext = CreateDbContext();
var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get);
var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get);
// 创建初始分类和映射
var oldCategoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get);
dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping
{
QuestionId = questionId,
CategoryId = oldCategoryId,
CreateTime = DateTime.Now
});
dbContext.SaveChanges();
// 创建新分类
var newCategoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get + 100);
var service = new AssessmentService(dbContext, _mockLogger.Object);
// Act: 批量更新映射(用新分类替换旧分类)
var request = new BatchUpdateMappingsRequest
{
QuestionId = questionId,
CategoryIds = new List { newCategoryId }
};
try
{
var result = service.BatchUpdateMappingsAsync(request).GetAwaiter().GetResult();
if (!result) return false;
// Assert: 验证旧映射已删除,新映射已创建
var mappings = dbContext.QuestionCategoryMappings
.Where(m => m.QuestionId == questionId)
.ToList();
// 应该只有一个映射(新的)
if (mappings.Count != 1) return false;
if (mappings[0].CategoryId != newCategoryId) return false;
// 旧映射应该不存在
var oldMappingExists = dbContext.QuestionCategoryMappings
.Any(m => m.QuestionId == questionId && m.CategoryId == oldCategoryId);
return !oldMappingExists;
}
catch (BusinessException)
{
return false;
}
}
///
/// Property 11: 批量更新失败时不应有任何映射被更新(回滚)
/// 当提供无效的分类ID时,所有操作应回滚
///
/// **Validates: Requirements 7.6**
///
[Property(MaxTest = 100)]
public bool Atomicity_FailedUpdate_NoMappingsChanged(PositiveInt seed)
{
// Arrange: 创建题目和分类,并创建初始映射
using var dbContext = CreateDbContext();
var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get);
var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get);
// 创建初始分类和映射
var existingCategoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get);
dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping
{
QuestionId = questionId,
CategoryId = existingCategoryId,
CreateTime = DateTime.Now
});
dbContext.SaveChanges();
var service = new AssessmentService(dbContext, _mockLogger.Object);
// Act: 尝试批量更新映射(包含无效的分类ID)
var request = new BatchUpdateMappingsRequest
{
QuestionId = questionId,
CategoryIds = new List { existingCategoryId, 999999 } // 999999 是无效的分类ID
};
try
{
service.BatchUpdateMappingsAsync(request).GetAwaiter().GetResult();
return false; // 应该抛出异常
}
catch (BusinessException ex)
{
// Assert: 验证原有映射未被修改
if (ex.Code != ErrorCodes.CategoryNotFound) return false;
var mappings = dbContext.QuestionCategoryMappings
.Where(m => m.QuestionId == questionId)
.ToList();
// 原有映射应该保持不变
return mappings.Count == 1 && mappings[0].CategoryId == existingCategoryId;
}
}
///
/// Property 11: 空分类列表应清除所有映射
///
/// **Validates: Requirements 7.5, 7.6**
///
[Property(MaxTest = 100)]
public bool Atomicity_EmptyCategoryList_ClearsAllMappings(PositiveInt seed)
{
// Arrange: 创建题目和分类,并创建初始映射
using var dbContext = CreateDbContext();
var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get);
var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get);
// 创建多个初始映射
for (int i = 0; i < 3; i++)
{
var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get + i);
dbContext.QuestionCategoryMappings.Add(new QuestionCategoryMapping
{
QuestionId = questionId,
CategoryId = categoryId,
CreateTime = DateTime.Now
});
}
dbContext.SaveChanges();
var service = new AssessmentService(dbContext, _mockLogger.Object);
// Act: 批量更新映射(空分类列表)
var request = new BatchUpdateMappingsRequest
{
QuestionId = questionId,
CategoryIds = new List() // 空列表
};
try
{
var result = service.BatchUpdateMappingsAsync(request).GetAwaiter().GetResult();
if (!result) return false;
// Assert: 验证所有映射都已删除
var mappings = dbContext.QuestionCategoryMappings
.Where(m => m.QuestionId == questionId)
.ToList();
return mappings.Count == 0;
}
catch (BusinessException)
{
return false;
}
}
///
/// Property 11: 批量更新应去重分类ID
///
/// **Validates: Requirements 7.4, 7.6**
///
[Property(MaxTest = 100)]
public bool Atomicity_DuplicateCategoryIds_ShouldBeDeduplicated(PositiveInt seed)
{
// Arrange: 创建题目和分类
using var dbContext = CreateDbContext();
var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get);
var questionId = CreateQuestion(dbContext, assessmentTypeId, seed.Get);
var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get);
var service = new AssessmentService(dbContext, _mockLogger.Object);
// Act: 批量更新映射(包含重复的分类ID)
var request = new BatchUpdateMappingsRequest
{
QuestionId = questionId,
CategoryIds = new List { categoryId, categoryId, categoryId } // 重复的分类ID
};
try
{
var result = service.BatchUpdateMappingsAsync(request).GetAwaiter().GetResult();
if (!result) return false;
// Assert: 验证只创建了一个映射(去重后)
var mappings = dbContext.QuestionCategoryMappings
.Where(m => m.QuestionId == questionId)
.ToList();
return mappings.Count == 1 && mappings[0].CategoryId == categoryId;
}
catch (BusinessException)
{
return false;
}
}
///
/// Property 11: 对不存在的题目进行批量更新应失败
///
/// **Validates: Requirements 7.3, 7.6**
///
[Property(MaxTest = 100)]
public bool Atomicity_InvalidQuestionId_ShouldFail(PositiveInt seed)
{
// Arrange
using var dbContext = CreateDbContext();
var assessmentTypeId = CreateAssessmentType(dbContext, seed.Get);
var categoryId = CreateCategory(dbContext, assessmentTypeId, seed.Get);
var service = new AssessmentService(dbContext, _mockLogger.Object);
// Act: 尝试对不存在的题目进行批量更新
var request = new BatchUpdateMappingsRequest
{
QuestionId = 999999, // 不存在的题目ID
CategoryIds = new List { categoryId }
};
try
{
service.BatchUpdateMappingsAsync(request).GetAwaiter().GetResult();
return false; // 应该抛出异常
}
catch (BusinessException ex)
{
return ex.Code == ErrorCodes.QuestionNotFound;
}
}
#endregion
#region 辅助方法
///
/// 创建内存数据库上下文
///
private AdminBusinessDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning))
.Options;
return new AdminBusinessDbContext(options);
}
///
/// 创建测评类型并返回ID
///
private long CreateAssessmentType(AdminBusinessDbContext dbContext, int seed)
{
var assessmentType = new AssessmentType
{
Name = $"Test Assessment {seed}",
Code = $"TEST_{seed}_{Guid.NewGuid():N}".Substring(0, 20),
Price = 99.99m,
QuestionCount = 80,
Sort = 1,
Status = 1,
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now,
IsDeleted = false
};
dbContext.AssessmentTypes.Add(assessmentType);
dbContext.SaveChanges();
return assessmentType.Id;
}
///
/// 创建题目并返回ID
///
private long CreateQuestion(AdminBusinessDbContext dbContext, long assessmentTypeId, int seed)
{
var question = new Question
{
AssessmentTypeId = assessmentTypeId,
QuestionNo = seed,
Content = $"Test Question {seed}",
Sort = 1,
Status = 1,
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now,
IsDeleted = false
};
dbContext.Questions.Add(question);
dbContext.SaveChanges();
return question.Id;
}
///
/// 创建分类并返回ID
///
private long CreateCategory(AdminBusinessDbContext dbContext, long assessmentTypeId, int seed)
{
var category = new ReportCategory
{
AssessmentTypeId = assessmentTypeId,
ParentId = 0,
Name = $"Test Category {seed}",
Code = $"CAT_{seed}_{Guid.NewGuid():N}".Substring(0, 20),
CategoryType = (seed % 8) + 1,
ScoreRule = (seed % 2) + 1,
Sort = 1,
CreateTime = DateTime.Now,
UpdateTime = DateTime.Now,
IsDeleted = false
};
dbContext.ReportCategories.Add(category);
dbContext.SaveChanges();
return category.Id;
}
///
/// 创建测试数据(测评类型、题目、分类)
///
private (long assessmentTypeId, long questionId, long categoryId) CreateTestData(
AdminBusinessDbContext dbContext, int seed)
{
var assessmentTypeId = CreateAssessmentType(dbContext, seed);
var questionId = CreateQuestion(dbContext, assessmentTypeId, seed);
var categoryId = CreateCategory(dbContext, assessmentTypeId, seed);
return (assessmentTypeId, questionId, categoryId);
}
#endregion
}