feat(tests): add QA test suites for onboarding and departmental logic validation

- **Onboarding (B-2):** Extracted `OnboardingValidator` and added `OnboardingValidatorTest` for edge-case validations (17 new unit tests: field guards, double-click prevention, cancel/reset behavior, `rememberSaveable` regression fix).
- **Departmental Logic (B-3):** Extended `AbteilungsRegelServiceTest` with 14 new tests covering CSN-C-NEU splitting logic (≤95 cm: license-free/licensed, ≥100 cm: R1/R2+), Caprilli regressions, and organizational/separate award scenarios.
- Updated `AbteilungsRegelService.kt` to implement CSN-C-NEU logic and added `ORGANISATORISCH` + `SEPARATE_SIEGEREHRUNG` enums for new rules.
- Updated Changelog and QA roadmap with completed tasks.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-03 11:46:00 +02:00
parent 7ff48ed3d7
commit 59f7f8d4ad
8 changed files with 432 additions and 22 deletions
@@ -5,6 +5,7 @@ package at.mocode.entries.domain.service
import at.mocode.core.domain.model.AbteilungsTeilungsTypE
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.PruefungsTypE
import at.mocode.core.domain.model.SparteE
import at.mocode.entries.domain.model.DomAbteilung
import at.mocode.entries.domain.model.DomBewerb
import at.mocode.masterdata.domain.model.DomReiter
@@ -59,16 +60,51 @@ class AbteilungsRegelService {
AbteilungsTeilungsTypE.STRUKTURELL -> {
// Bei strukturellen Teilungen (z.B. Caprilli oder CSN-C-NEU)
// Hier müsste eine detailliertere Prüfung der bewerb.pruefungsTyp erfolgen.
if (bewerb.pruefungsTyp == PruefungsTypE.CAPRILLI) {
val istLizenzfrei = reiter.lizenzKlasse == LizenzKlasseE.LIZENZFREI
if (istLizenzfrei) abteilungen.find { it.abteilungsNummer == 1 }
else abteilungen.find { it.abteilungsNummer == 2 }
} else if (bewerb.sparte == SparteE.SPRINGEN && bewerb.hoeheCm != null) {
// CSN-C-NEU Pflicht-Teilung gemäß ÖTO § 231:
// ≤ 95 cm: Abt. 1 = „ohne Lizenz" (LIZENZFREI), Abt. 2 = „mit Lizenz" (R1+)
// ≥ 100 cm: nur R1+ erlaubt → alle in Abt. 1 (R1/R2+)
val hoehe = bewerb.hoeheCm!!
if (hoehe <= 95) {
val istLizenzfrei = reiter.lizenzKlasse == LizenzKlasseE.LIZENZFREI
if (istLizenzfrei) {
abteilungen.find { it.bezeichnung?.contains("ohne", ignoreCase = true) == true }
?: abteilungen.minByOrNull { it.abteilungsNummer }
} else {
abteilungen.find { it.bezeichnung?.contains("mit", ignoreCase = true) == true }
?: abteilungen.maxByOrNull { it.abteilungsNummer }
}
} else {
// ≥ 100 cm: Pflicht-Teilung R1 / R2+
val istR1 = reiter.lizenzKlasse == LizenzKlasseE.R1
if (istR1) {
abteilungen.find { it.bezeichnung?.contains("R1", ignoreCase = false) == true }
?: abteilungen.minByOrNull { it.abteilungsNummer }
} else {
abteilungen.find { abt ->
val bez = abt.bezeichnung?.lowercase() ?: ""
bez.contains("r2") || bez.contains("r2+")
} ?: abteilungen.maxByOrNull { it.abteilungsNummer }
}
}
} else {
abteilungen.firstOrNull()
}
}
// Organisatorische Teilung: Reiter werden nach Startplatznummer auf Abteilungen verteilt,
// die Rangliste wird am Ende zusammengeführt (§ 39 Abs. 3).
// Zuweisung erfolgt durch den Caller (z.B. nach Startnummer-Modulo) hier Fallback auf erste Abt.
AbteilungsTeilungsTypE.ORGANISATORISCH -> abteilungen.minByOrNull { it.abteilungsNummer }
// Separate Siegerehrung: Jede Abteilung hat eine eigene Platzierung, keine Zusammenführung.
// Zuweisung erfolgt durch den Caller hier Fallback auf erste Abt.
AbteilungsTeilungsTypE.SEPARATE_SIEGEREHRUNG -> abteilungen.minByOrNull { it.abteilungsNummer }
else -> abteilungen.firstOrNull()
}
}
@@ -61,9 +61,158 @@ class AbteilungsRegelServiceTest {
assertTrue(warnings[0].contains("WARN_BEWERB_PFLICHT_TEILUNG_ERFORDERLICH"))
}
// ─── B-3: CSN-C-NEU ≤ 95 cm ────────────────────────────────────────────────
@Test
fun `B3 CSN-C-NEU 95cm LIZENZFREI wird Abteilung ohne Lizenz zugewiesen`() {
val bewerb = createBewerb(teilungsTyp = AbteilungsTeilungsTypE.STRUKTURELL, hoeheCm = 95)
val abtOhne = createAbteilung(bewerb.bewerbId, 1, "ohne Lizenz")
val abtMit = createAbteilung(bewerb.bewerbId, 2, "mit Lizenz")
val reiter = createReiter(LizenzKlasseE.LIZENZFREI)
val result = service.bestimmeAbteilung(bewerb, listOf(abtOhne, abtMit), reiter)
assertEquals(abtOhne.abteilungId, result?.abteilungId)
}
@Test
fun `B3 CSN-C-NEU 95cm R1-Reiter wird Abteilung mit Lizenz zugewiesen`() {
val bewerb = createBewerb(teilungsTyp = AbteilungsTeilungsTypE.STRUKTURELL, hoeheCm = 95)
val abtOhne = createAbteilung(bewerb.bewerbId, 1, "ohne Lizenz")
val abtMit = createAbteilung(bewerb.bewerbId, 2, "mit Lizenz")
val reiter = createReiter(LizenzKlasseE.R1)
val result = service.bestimmeAbteilung(bewerb, listOf(abtOhne, abtMit), reiter)
assertEquals(abtMit.abteilungId, result?.abteilungId)
}
@Test
fun `B3 CSN-C-NEU Grenzfall 90cm LIZENZFREI bleibt in ohne-Lizenz-Abteilung`() {
val bewerb = createBewerb(teilungsTyp = AbteilungsTeilungsTypE.STRUKTURELL, hoeheCm = 90)
val abtOhne = createAbteilung(bewerb.bewerbId, 1, "ohne Lizenz")
val abtMit = createAbteilung(bewerb.bewerbId, 2, "mit Lizenz")
val result = service.bestimmeAbteilung(bewerb, listOf(abtOhne, abtMit), createReiter(LizenzKlasseE.LIZENZFREI))
assertEquals(abtOhne.abteilungId, result?.abteilungId)
}
// ─── B-3: CSN-C-NEU ≥ 100 cm ───────────────────────────────────────────────
@Test
fun `B3 CSN-C-NEU 100cm R1-Reiter wird R1-Abteilung zugewiesen`() {
val bewerb = createBewerb(teilungsTyp = AbteilungsTeilungsTypE.STRUKTURELL, hoeheCm = 100)
val abtR1 = createAbteilung(bewerb.bewerbId, 1, "R1")
val abtR2 = createAbteilung(bewerb.bewerbId, 2, "R2+")
val reiter = createReiter(LizenzKlasseE.R1)
val result = service.bestimmeAbteilung(bewerb, listOf(abtR1, abtR2), reiter)
assertEquals(abtR1.abteilungId, result?.abteilungId)
}
@Test
fun `B3 CSN-C-NEU 100cm R2-Reiter wird R2plus-Abteilung zugewiesen`() {
val bewerb = createBewerb(teilungsTyp = AbteilungsTeilungsTypE.STRUKTURELL, hoeheCm = 100)
val abtR1 = createAbteilung(bewerb.bewerbId, 1, "R1")
val abtR2 = createAbteilung(bewerb.bewerbId, 2, "R2+")
val reiter = createReiter(LizenzKlasseE.R2)
val result = service.bestimmeAbteilung(bewerb, listOf(abtR1, abtR2), reiter)
assertEquals(abtR2.abteilungId, result?.abteilungId)
}
@Test
fun `B3 CSN-C-NEU Grenzfall 110cm R1-Reiter bleibt in R1-Abteilung`() {
val bewerb = createBewerb(teilungsTyp = AbteilungsTeilungsTypE.STRUKTURELL, hoeheCm = 110)
val abtR1 = createAbteilung(bewerb.bewerbId, 1, "R1")
val abtR2 = createAbteilung(bewerb.bewerbId, 2, "R2+")
val result = service.bestimmeAbteilung(bewerb, listOf(abtR1, abtR2), createReiter(LizenzKlasseE.R1))
assertEquals(abtR1.abteilungId, result?.abteilungId)
}
// ─── B-3: ORGANISATORISCH ───────────────────────────────────────────────────
@Test
fun `B3 ORGANISATORISCH liefert immer die erste Abteilung als Fallback`() {
val bewerb = createBewerb(teilungsTyp = AbteilungsTeilungsTypE.ORGANISATORISCH)
val abt1 = createAbteilung(bewerb.bewerbId, 1, "Abteilung 1")
val abt2 = createAbteilung(bewerb.bewerbId, 2, "Abteilung 2")
val result = service.bestimmeAbteilung(bewerb, listOf(abt1, abt2), createReiter())
assertEquals(abt1.abteilungId, result?.abteilungId)
}
@Test
fun `B3 ORGANISATORISCH Gesamtrangliste - validateStrukturelleVollstaendigkeit gibt keine Warnung`() {
val bewerb = createBewerb(teilungsTyp = AbteilungsTeilungsTypE.ORGANISATORISCH)
val abt1 = createAbteilung(bewerb.bewerbId, 1, starterAnzahl = 20)
val abt2 = createAbteilung(bewerb.bewerbId, 2, starterAnzahl = 20)
val warnings = service.validateStrukturelleVollstaendigkeit(bewerb, listOf(abt1, abt2))
assertTrue(warnings.isEmpty())
}
// ─── B-3: SEPARATE_SIEGEREHRUNG ─────────────────────────────────────────────
@Test
fun `B3 SEPARATE_SIEGEREHRUNG liefert immer die erste Abteilung als Fallback`() {
val bewerb = createBewerb(teilungsTyp = AbteilungsTeilungsTypE.SEPARATE_SIEGEREHRUNG)
val abt1 = createAbteilung(bewerb.bewerbId, 1, "Klasse A")
val abt2 = createAbteilung(bewerb.bewerbId, 2, "Klasse B")
val result = service.bestimmeAbteilung(bewerb, listOf(abt1, abt2), createReiter())
assertEquals(abt1.abteilungId, result?.abteilungId)
}
@Test
fun `B3 SEPARATE_SIEGEREHRUNG - validateStrukturelleVollstaendigkeit gibt keine Warnung bei 2 Abteilungen`() {
val bewerb = createBewerb(teilungsTyp = AbteilungsTeilungsTypE.SEPARATE_SIEGEREHRUNG)
val abt1 = createAbteilung(bewerb.bewerbId, 1, starterAnzahl = 15)
val abt2 = createAbteilung(bewerb.bewerbId, 2, starterAnzahl = 15)
val warnings = service.validateStrukturelleVollstaendigkeit(bewerb, listOf(abt1, abt2))
assertTrue(warnings.isEmpty())
}
// ─── B-3: Caprilli (Regressions-Absicherung) ────────────────────────────────
@Test
fun `B3 Caprilli LIZENZFREI wird Abteilung 1 zugewiesen`() {
val bewerb = createBewerb(pruefungsTyp = PruefungsTypE.CAPRILLI, teilungsTyp = AbteilungsTeilungsTypE.STRUKTURELL)
val abt1 = createAbteilung(bewerb.bewerbId, 1, "ohne Lizenz")
val abt2 = createAbteilung(bewerb.bewerbId, 2, "mit Lizenz")
val result = service.bestimmeAbteilung(bewerb, listOf(abt1, abt2), createReiter(LizenzKlasseE.LIZENZFREI))
assertEquals(abt1.abteilungId, result?.abteilungId)
}
@Test
fun `B3 Caprilli R1-Reiter wird Abteilung 2 zugewiesen`() {
val bewerb = createBewerb(pruefungsTyp = PruefungsTypE.CAPRILLI, teilungsTyp = AbteilungsTeilungsTypE.STRUKTURELL)
val abt1 = createAbteilung(bewerb.bewerbId, 1, "ohne Lizenz")
val abt2 = createAbteilung(bewerb.bewerbId, 2, "mit Lizenz")
val result = service.bestimmeAbteilung(bewerb, listOf(abt1, abt2), createReiter(LizenzKlasseE.R1))
assertEquals(abt2.abteilungId, result?.abteilungId)
}
// ─── Hilfsmethoden ──────────────────────────────────────────────────────────
private fun createBewerb(
pruefungsTyp: PruefungsTypE = PruefungsTypE.SPRINGEN_UEBRIG,
teilungsTyp: AbteilungsTeilungsTypE = AbteilungsTeilungsTypE.KEINE
teilungsTyp: AbteilungsTeilungsTypE = AbteilungsTeilungsTypE.KEINE,
hoeheCm: Int? = null
) = DomBewerb(
turnierId = Uuid.random(),
bewerbNummer = 1,
@@ -71,7 +220,8 @@ class AbteilungsRegelServiceTest {
sparte = SparteE.SPRINGEN,
turnierkategorie = TurnierkategorieE.B,
pruefungsTyp = pruefungsTyp,
teilungsTyp = teilungsTyp
teilungsTyp = teilungsTyp,
hoeheCm = hoeheCm
)
private fun createAbteilung(