feat(ldd): property-based, mutation и браузерный E2E
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user