feat(ldd): property-based, mutation и браузерный E2E

This commit is contained in:
2026-10-28 00:01:56 +03:00
parent 80ccf26b61
commit 905feb440b
18 changed files with 1432 additions and 2 deletions

View File

@@ -2,6 +2,57 @@ plugins {
id("lms.spring-boot")
}
/** PIT через CLI: Gradle-плагин 1.15 несовместим с Gradle 9.6, JUnit5-плагин — с JUnit Platform 6. */
val pitest by configurations.creating
dependencies {
add("pitest", libs.pitestCommandLine)
}
tasks.register<JavaExec>("pitestPromotionPlanner") {
group = "verification"
description = "Mutation testing PromotionPlanner (P0 promotion rules)"
dependsOn(tasks.testClasses)
classpath = pitest + sourceSets.getByName("test").runtimeClasspath
mainClass.set("org.pitest.mutationtest.commandline.MutationCoverageReport")
val reportDir =
layout.buildDirectory
.dir("reports/pitest-promotion-planner")
.get()
.asFile
.absolutePath
val sourceDirs =
sourceSets.main
.get()
.kotlin.srcDirs
.joinToString(",") { it.absolutePath }
val testClasspath = sourceSets.getByName("test").runtimeClasspath.asPath
args(
"--reportDir",
reportDir,
"--targetClasses",
"ru.ysu.lms.identity.promotion.PromotionPlanner",
"--targetTests",
"ru.ysu.lms.identity.promotion.PromotionPlannerMutationTest",
"--sourceDirs",
sourceDirs,
"--classPath",
testClasspath,
"--mutators",
"STRONGER",
"--threads",
"1",
"--outputFormats",
"XML,HTML",
"--timestampedReports",
"false",
"--timeoutConst",
"15000",
"--failWhenNoMutations",
"true",
)
}
dependencies {
implementation(platform(libs.springBootBom))
@@ -15,6 +66,8 @@ dependencies {
implementation(libs.flywayPostgresql)
runtimeOnly(libs.postgresql)
implementation(libs.jacksonYaml)
implementation(libs.jacksonModuleKotlin)
implementation(libs.jackson3ModuleKotlin)
implementation(libs.springdocStarterWebmvcUi)
implementation(libs.apachePoiOoxml)
@@ -24,6 +77,8 @@ dependencies {
testImplementation(project(":libs:test-support"))
testImplementation(libs.springBootStarterTest)
testImplementation(libs.springSecurityTest)
// PIT JUnit5-плагин несовместим с JUnit Platform 6; mutation harness — JUnit 4.
testImplementation("junit:junit:4.13.2")
}
springBoot {

View File

@@ -0,0 +1,248 @@
package ru.ysu.lms.identity.promotion
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.PromotionAction
import ru.ysu.lms.identity.domain.PromotionEntityType
import ru.ysu.lms.identity.domain.Student
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.StudentRepository
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlin.random.Random
/** JUnit 4 harness для PIT; обычный `test` его не запускает. */
class PromotionPlannerMutationTest {
private val clock = Clock.fixed(Instant.parse("2026-10-27T00:00:00Z"), ZoneOffset.UTC)
@Test
fun `group promotion and graduation follow limits`() {
val random = Random(17)
repeat(25) {
val groups = (1..random.nextInt(1, 6)).map { index -> group(random, index) }
val planner = planner(groups)
val plan = planner.plan(2026)
val items = plan.items.filter { it.entityType == PromotionEntityType.GROUP }.associateBy { it.entityId }
for (group in groups.filter { it.status == GroupStatus.ACTIVE }) {
val item = items.getValue(group.id)
val course = group.course!!.toInt()
val limit = if (group.degree == "БО") 4 else 2
assertThat(item.before["course"]).isEqualTo(course)
if (course < limit) {
assertThat(item.action).isEqualTo(PromotionAction.PROMOTE)
assertThat(item.after["course"]).isEqualTo(course + 1)
assertThat(item.after["name"])
.isEqualTo("${group.stream}-${course + 1}${group.number}${group.degree}")
} else {
assertThat(item.action).isEqualTo(PromotionAction.GRADUATE)
assertThat(item.after["status"]).isEqualTo(GroupStatus.GRADUATED.name)
}
}
}
}
@Test
fun `summary counts graduates academic moves and warnings`() {
val promote = group(index = 1, course = 1)
val graduate = group(index = 2, course = 4)
val inactive = group(index = 3, course = 1, status = GroupStatus.ARCHIVED)
val invalid = group(index = 4, course = 1, degree = "XX")
val activeStudent = student(StudentStatus.ACTIVE)
val academicStudent = student(StudentStatus.ACADEMIC_LEAVE)
val expelledStudent = student(StudentStatus.EXPELLED)
val enrollments =
listOf(
Enrollment(group = graduate, student = activeStudent),
Enrollment(group = promote, student = academicStudent),
Enrollment(group = promote, student = expelledStudent),
)
val planner =
planner(
groups = listOf(promote, graduate, inactive, invalid),
students = listOf(activeStudent, academicStudent, expelledStudent),
enrollments = enrollments,
)
val plan = planner.plan(2026)
@Suppress("UNCHECKED_CAST")
val counts = plan.result["counts"] as Map<String, Any?>
assertThat(counts["groupsPromote"]).isEqualTo(1)
assertThat(counts["groupsGraduate"]).isEqualTo(1)
assertThat(counts["studentsGraduate"]).isEqualTo(1)
assertThat(counts["studentsAcademic"]).isEqualTo(1)
@Suppress("UNCHECKED_CAST")
val warnings = plan.result["warnings"] as List<String>
assertThat(warnings).anyMatch { it.contains("некорректны") }
val graduateItem = plan.items.first { it.entityId == graduate.id }
assertThat(graduateItem.before).containsEntry("status", GroupStatus.ACTIVE.name)
assertThat(graduateItem.after).containsEntry("status", GroupStatus.GRADUATED.name)
val activeItem = plan.items.first { it.entityId == activeStudent.accountId }
assertThat(activeItem.after).containsEntry("status", StudentStatus.GRADUATED.name)
assertThat(plan.items.map { it.entityId }).doesNotContain(inactive.id)
}
@Test
fun `academic leave uses existing target and skips incomplete sources`() {
val source = group(index = 5, course = 2)
val target = group(index = 6, course = 2)
val student = student(StudentStatus.ACADEMIC_LEAVE)
val enrollment = Enrollment(group = source, student = student)
val planner = planner(listOf(source, target), listOf(student), listOf(enrollment), existingTarget = target)
val plan = planner.plan(2026)
val item = plan.items.first { it.entityId == student.accountId }
@Suppress("UNCHECKED_CAST")
val targetState = item.after["targetGroup"] as Map<String, Any?>
assertThat(targetState["id"]).isEqualTo(target.id.toString())
assertThat(targetState["created"]).isEqualTo(false)
val noEnrollment = student(StudentStatus.ACADEMIC_LEAVE)
val noEnrollmentPlan = planner(listOf(source), listOf(noEnrollment)).plan(2026)
assertThat(noEnrollmentPlan.items.map { it.entityId }).doesNotContain(noEnrollment.accountId)
@Suppress("UNCHECKED_CAST")
val warnings = noEnrollmentPlan.result["warnings"] as List<String>
assertThat(warnings).anyMatch { it.contains("нет зачисления") }
val incomplete = group(index = 7, course = 1, stream = null)
val incompleteStudent = student(StudentStatus.ACADEMIC_LEAVE)
val incompletePlan =
planner(
listOf(incomplete),
listOf(incompleteStudent),
listOf(Enrollment(group = incomplete, student = incompleteStudent)),
).plan(2026)
assertThat(incompletePlan.items.map { it.entityId }).doesNotContain(incompleteStudent.accountId)
}
@Test
fun `active student outside graduating group is not moved`() {
val group = group(index = 8, course = 1)
val student = student(StudentStatus.ACTIVE)
val planner = planner(listOf(group), listOf(student), listOf(Enrollment(group = group, student = student)))
val plan = planner.plan(2026)
assertThat(plan.items.map { it.entityId }).doesNotContain(student.accountId)
}
@Test
fun `enrollment without student does not break planning`() {
val group = group(index = 9, course = 1)
val student = student(StudentStatus.ACTIVE)
val orphanEnrollment = Enrollment(group = group, student = null)
val planner = planner(listOf(group), listOf(student), listOf(orphanEnrollment))
val plan = planner.plan(2026)
assertThat(plan.items).isNotEmpty
}
@Test
fun `academic student moves to same course of previous cohort`() {
val random = Random(18)
repeat(20) {
val group = group(random, 1, forceActive = true)
val student =
Student(
accountId = UUID.randomUUID(),
studentNo = "S-${random.nextInt()}",
lastName = "Иванов",
firstName = "Иван",
status = StudentStatus.ACADEMIC_LEAVE,
)
val planner = planner(listOf(group), listOf(student), listOf(Enrollment(group = group, student = student)))
val plan = planner.plan(2026)
val item =
plan.items.first {
it.entityType == PromotionEntityType.STUDENT && it.entityId == student.accountId
}
val course = group.course!!.toInt()
assertThat(item.action).isEqualTo(PromotionAction.ACADEMIC)
assertThat(item.after["enrollmentYear"]).isEqualTo(2026 - (course - 1))
@Suppress("UNCHECKED_CAST")
val target = item.after["targetGroup"] as Map<String, Any?>
assertThat(target["course"]).isEqualTo(course)
assertThat(target["enrollmentYear"]).isEqualTo(2026 - (course - 1))
}
}
private fun group(
random: Random,
index: Int,
forceActive: Boolean = false,
): GroupEntity {
val degree = if (random.nextBoolean()) "БО" else "МО"
val limit = if (degree == "БО") 4 else 2
return GroupEntity(
id = UUID.randomUUID(),
name = "ПИ-${index}$degree",
stream = "ПИ",
course = random.nextInt(1, limit + 1).toShort(),
number = index.toShort(),
degree = degree,
enrollmentYear = 2026,
status = if (forceActive || random.nextBoolean()) GroupStatus.ACTIVE else GroupStatus.ARCHIVED,
)
}
private fun group(
index: Int,
course: Int,
degree: String = "БО",
status: GroupStatus = GroupStatus.ACTIVE,
stream: String? = "ПИ",
): GroupEntity =
GroupEntity(
id = UUID.randomUUID(),
name = "ПИ-${index}$degree",
stream = stream,
course = course.toShort(),
number = index.toShort(),
degree = degree,
enrollmentYear = 2026,
status = status,
)
private fun student(status: StudentStatus): Student =
Student(
accountId = UUID.randomUUID(),
studentNo = "S-${UUID.randomUUID()}",
lastName = "Иванов",
firstName = "Иван",
status = status,
)
private fun planner(
groups: List<GroupEntity>,
students: List<Student> = emptyList(),
enrollments: List<Enrollment> = emptyList(),
existingTarget: GroupEntity? = null,
): PromotionPlanner {
val groupRepository = mockk<GroupRepository>()
val studentRepository = mockk<StudentRepository>()
val enrollmentRepository = mockk<EnrollmentRepository>()
every { groupRepository.findAll() } returns groups
every { studentRepository.findAll() } returns students
every { enrollmentRepository.findAllWithStudentAndGroup() } returns enrollments
every {
groupRepository.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
any(),
any(),
any(),
any(),
any(),
)
} returns existingTarget
return PromotionPlanner(groupRepository, studentRepository, enrollmentRepository, clock)
}
}

View File

@@ -0,0 +1,145 @@
package ru.ysu.lms.identity.promotion
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.PromotionAction
import ru.ysu.lms.identity.domain.PromotionEntityType
import ru.ysu.lms.identity.domain.Student
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.StudentRepository
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlin.random.Random
/**
* Property-style проверки планировщика повышения: не один сценарий, а
* 100 сгенерированных когорт. Проверяются инварианты «курс + 1», выпуск на
* пределе и академический перевод в группу того же курса с годом набора
* `year - (course - 1)`.
*/
class PromotionPlannerPropertyTest {
private val clock = Clock.fixed(Instant.parse("2026-10-27T00:00:00Z"), ZoneOffset.UTC)
@Test
fun `active groups promote or graduate exactly by degree limit`() {
val random = Random(20261027)
repeat(100) { iteration ->
val groups = (1..random.nextInt(1, 8)).map { index -> randomGroup(random, index) }
val planner = planner(groups = groups)
val plan = planner.plan(2026)
val items = plan.items.filter { it.entityType == PromotionEntityType.GROUP }.associateBy { it.entityId }
val active = groups.filter { it.status == GroupStatus.ACTIVE }
assertThat(items).hasSize(active.size)
for (group in active) {
val item = items.getValue(group.id)
val course = group.course!!.toInt()
val limit = if (group.degree == "БО") 4 else 2
assertThat(item.before["course"]).isEqualTo(course)
if (course < limit) {
assertThat(item.action).isEqualTo(PromotionAction.PROMOTE)
assertThat(item.after["course"]).isEqualTo(course + 1)
assertThat(item.after["name"])
.isEqualTo("${group.stream}-${course + 1}${group.number}${group.degree}")
} else {
assertThat(item.action).isEqualTo(PromotionAction.GRADUATE)
assertThat(item.after["status"]).isEqualTo(GroupStatus.GRADUATED.name)
}
}
val inactive = groups.filter { it.status != GroupStatus.ACTIVE }.map { it.id }
if (inactive.isNotEmpty()) {
assertThat(items.keys).doesNotContainAnyElementsOf(inactive)
}
assertThat(plan.result["counts"]).isNotNull
assertThat(plan.items.size).isEqualTo(items.size)
}
}
@Test
fun `academic leave targets the same course with year minus course minus one`() {
val random = Random(20261028)
repeat(100) { iteration ->
val group = randomGroup(random, 1, forceActive = true)
val student =
Student(
accountId = UUID.randomUUID(),
studentNo = "S-$iteration-${random.nextInt()}",
lastName = "Иванов",
firstName = "Иван",
status = StudentStatus.ACADEMIC_LEAVE,
)
val planner =
planner(groups = listOf(group), students = listOf(student), enrollment = Enrollment(group = group, student = student))
val plan = planner.plan(2026)
val item =
plan.items.first {
it.entityType == PromotionEntityType.STUDENT && it.entityId == student.accountId
}
val course = group.course!!.toInt()
assertThat(item.action).isEqualTo(PromotionAction.ACADEMIC)
assertThat(item.after["enrollmentYear"]).isEqualTo(2026 - (course - 1))
@Suppress("UNCHECKED_CAST")
val target = item.after["targetGroup"] as Map<String, Any?>
assertThat(target["course"]).isEqualTo(course)
assertThat(target["stream"]).isEqualTo(group.stream)
assertThat(target["degree"]).isEqualTo(group.degree)
assertThat(target["enrollmentYear"]).isEqualTo(2026 - (course - 1))
}
}
private fun randomGroup(
random: Random,
index: Int,
forceActive: Boolean = false,
): GroupEntity {
val degree = if (random.nextBoolean()) "БО" else "МО"
val limit = if (degree == "БО") 4 else 2
return GroupEntity(
id = UUID.randomUUID(),
name = "ПИ-${random.nextInt(1, limit + 1)}${index}$degree",
stream = "ПИ",
course = random.nextInt(1, limit + 1).toShort(),
number = index.toShort(),
degree = degree,
enrollmentYear = 2026,
status = if (forceActive || random.nextInt(5) > 0) GroupStatus.ACTIVE else GroupStatus.ARCHIVED,
)
}
private fun planner(
groups: List<GroupEntity>,
students: List<Student> = emptyList(),
enrollment: Enrollment? = null,
): PromotionPlanner {
val groupRepository = mockk<GroupRepository>()
val studentRepository = mockk<StudentRepository>()
val enrollmentRepository = mockk<EnrollmentRepository>()
every { groupRepository.findAll() } returns groups
every { studentRepository.findAll() } returns students
every { enrollmentRepository.findAllWithStudentAndGroup() } returns listOfNotNull(enrollment)
every {
groupRepository.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
any(),
any(),
any(),
any(),
any(),
)
} returns null
return PromotionPlanner(groupRepository, studentRepository, enrollmentRepository, clock)
}
}