Implement tournament category validation: Add Turnier.validateKategorieLimits with a policy interface and descriptor for decoupled validation against ÖTO limits. Introduce TurnierkategoriePolicy and implement OeToTurnierkategoriePolicy for CSN and CDN max limits. Add comprehensive unit tests and update roadmap with completed A-3 sub-tasks.

This commit is contained in:
Stefan Mogeritsch 2026-04-02 22:51:25 +02:00
parent dc68a6b749
commit 6595ec674f
5 changed files with 294 additions and 3 deletions

View File

@ -5,6 +5,8 @@ package at.mocode.events.domain.model
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.model.TurnierkategorieE
import at.mocode.core.domain.model.TurnierStatusE
import at.mocode.events.domain.validation.TurnierBewerbDescriptor
import at.mocode.events.domain.validation.TurnierkategoriePolicy
import at.mocode.core.domain.serialization.KotlinxInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
@ -108,4 +110,18 @@ data class DomTurnier(
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomTurnier = this.copy(updatedAt = Clock.System.now())
/**
* Validiert die im Turnier geplanten Bewerbe gegen die Limits der Turnierkategorie.
*
* Hinweis: Die konkreten Regeln stammen aus der ÖTO-Spezifikation und werden vom 📜 Rulebook Expert (A-5)
* bereitgestellt. Diese Methode delegiert daher an eine Policy-Schnittstelle, um Kopplung zu vermeiden und
* die Regeln austauschbar zu halten.
*
* Rückgabe: Liste von Meldungen (Fehler/Warnungen) Formulierung/Schweregrad ist Teil der Policy.
*/
fun validateKategorieLimits(
bewerbe: List<TurnierBewerbDescriptor>,
policy: TurnierkategoriePolicy
): List<String> = policy.validate(kategorie, bewerbe)
}

View File

@ -0,0 +1,130 @@
package at.mocode.events.domain.validation
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.model.TurnierkategorieE
/**
* Minimaler Descriptor für Bewerbe zur Kategorielimit-Validierung ohne Kopplung an andere Module.
*/
data class TurnierBewerbDescriptor(
val sparte: SparteE,
val beschreibung: String,
val hoeheCm: Int? = null,
val klasseCode: String? = null
)
/**
* Policy-Schnittstelle für die Validierung der Turnierkategorie-Limits (ÖTO-Regelwerk).
* Implementierung wird durch den 📜 Rulebook Expert (A-5) spezifiziert.
*/
fun interface TurnierkategoriePolicy {
/**
* Liefert Meldungen (Fehler/Warnungen), wenn [bewerbe] die Limits der [kategorie] verletzen.
*/
fun validate(kategorie: TurnierkategorieE, bewerbe: List<TurnierBewerbDescriptor>): List<String>
}
/**
* Default-Policy bis die Spezifikation vorliegt meldet nichts.
*/
object NoopTurnierkategoriePolicy : TurnierkategoriePolicy {
override fun validate(
kategorie: TurnierkategorieE,
bewerbe: List<TurnierBewerbDescriptor>
): List<String> = emptyList()
}
/**
* Konkrete Policy gemäß ÖTO 2026 (Auszug):
* - Implementiert harte Maximal-Limits je Turnierkategorie für CSN (Springen, per Höhe in cm)
* und CDN (Dressur, per Klassen-Level).
* - Komplexere Sonderregeln (Pflichtbewerbe, Tageslimits, Genehmigungen) sind bewusst ausgeklammert
* und können in einer Folgeiteration ergänzt werden.
*/
object OeToTurnierkategoriePolicy : TurnierkategoriePolicy {
override fun validate(
kategorie: TurnierkategorieE,
bewerbe: List<TurnierBewerbDescriptor>
): List<String> {
val msgs = mutableListOf<String>()
bewerbe.forEach { b ->
when (b.sparte) {
SparteE.SPRINGEN -> validateCsN(kategorie, b)?.let { msgs.add(it) }
SparteE.DRESSUR -> validateCdN(kategorie, b)?.let { msgs.add(it) }
else -> {
// Für andere Sparten aktuell keine Limits hinterlegt
}
}
}
return msgs
}
// --- CSN (Springen) ---
private fun validateCsN(kategorie: TurnierkategorieE, b: TurnierBewerbDescriptor): String? {
val maxCm = when (kategorie) {
TurnierkategorieE.C_NEU -> 115
TurnierkategorieE.C -> 130
TurnierkategorieE.B -> 135
TurnierkategorieE.B_STERN -> 140
TurnierkategorieE.A -> 145
TurnierkategorieE.A_STERN -> 160
}
val hoehe = b.hoeheCm ?: springClassToHeight(b.klasseCode)
if (hoehe != null && hoehe > maxCm) {
return "ERROR_KATEGORIE_LIMIT_UEBERSCHRITTEN: ${b.beschreibung} Höhe ${hoehe}cm > ${maxCm}cm für ${kategorie}."
}
return null
}
private fun springClassToHeight(code: String?): Int? = when (code?.trim()?.uppercase()) {
// konservative Repräsentanten aus der Höhentabelle (Unter- und Obergrenzen werden vereinfacht abgebildet)
"E0" -> 90
"E", "A0" -> 100
"A" -> 110
"L" -> 120
"LM" -> 130
"M" -> 135
"S1", "S*" -> 140
"S2" -> 145
"S3", "S***" -> 160
else -> null
}
// --- CDN (Dressur) ---
private enum class DressurLevel { LF, A, L, LM, M, S, GP }
private fun parseDressurLevel(code: String?): DressurLevel? = when (code?.trim()?.uppercase()) {
"LF", "LIZENZFREI" -> DressurLevel.LF
"A" -> DressurLevel.A
"L" -> DressurLevel.L
"LM" -> DressurLevel.LM
"M" -> DressurLevel.M
"S" -> DressurLevel.S
"GP", "GRANDPRIX", "GRAND PRIX", "GRAND-PRIX" -> DressurLevel.GP
else -> null
}
private fun maxDressurLevel(kategorie: TurnierkategorieE): DressurLevel = when (kategorie) {
TurnierkategorieE.C_NEU -> DressurLevel.L // CDN-C-NEU: bis L
TurnierkategorieE.C -> DressurLevel.LM // CDN-C: bis LM
TurnierkategorieE.B -> DressurLevel.M // CDN-B: bis M
TurnierkategorieE.B_STERN -> DressurLevel.S // CDN-B*: bis S (Sonderauflagen ignoriert)
TurnierkategorieE.A -> DressurLevel.S // CDN-A: bis S
TurnierkategorieE.A_STERN -> DressurLevel.GP// CDN-A*: bis GP
}
private fun validateCdN(kategorie: TurnierkategorieE, b: TurnierBewerbDescriptor): String? {
val level = parseDressurLevel(b.klasseCode ?: b.beschreibung)
if (level == null) return null // ohne Klassen-Code keine Dressur-Level-Prüfung möglich
val max = maxDressurLevel(kategorie)
if (level.ordinal > max.ordinal) {
return "ERROR_KATEGORIE_LEVEL_UEBERSCHRITTEN: ${b.beschreibung} Klasse ${level.name} > ${max.name} für ${kategorie}."
}
return null
}
}

View File

@ -0,0 +1,69 @@
package at.mocode.events.domain.model
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.model.TurnierkategorieE
import at.mocode.events.domain.validation.TurnierBewerbDescriptor
import at.mocode.events.domain.validation.TurnierkategoriePolicy
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.uuid.Uuid
import kotlinx.datetime.LocalDate
class DomTurnierKategorieValidationTest {
private val fakePolicy = TurnierkategoriePolicy { kategorie, bewerbe ->
val msgs = mutableListOf<String>()
bewerbe.forEach { b ->
val h = b.hoeheCm ?: return@forEach
val max = when (kategorie) {
TurnierkategorieE.C_NEU -> 115 // laut Roadmap grob L
TurnierkategorieE.C -> 130 // LM
TurnierkategorieE.B, TurnierkategorieE.B_STERN -> 140
TurnierkategorieE.A -> 145
TurnierkategorieE.A_STERN -> 160
}
if (h > max) {
msgs.add("ERROR_KATEGORIE_LIMIT_UEBERSCHRITTEN: ${b.beschreibung} Höhe ${h}cm > ${max}cm für ${kategorie}.")
}
}
msgs
}
@Test
fun `C Turnier verbietet 135cm Springen`() {
val turnier = DomTurnier(
veranstaltungId = Uuid.random(),
name = "CSN-C Samstag",
sparte = SparteE.SPRINGEN,
kategorie = TurnierkategorieE.C,
datum = LocalDate(2026, 6, 1)
)
val bewerbe = listOf(
TurnierBewerbDescriptor(SparteE.SPRINGEN, beschreibung = "LM 130 cm", hoeheCm = 130),
TurnierBewerbDescriptor(SparteE.SPRINGEN, beschreibung = "M 135 cm", hoeheCm = 135)
)
val msgs = turnier.validateKategorieLimits(bewerbe, fakePolicy)
assertEquals(1, msgs.size, "Erwartet genau eine Limitverletzung (135cm auf C-Turnier)")
}
@Test
fun `C-NEU Turnier verbietet 120cm`() {
val turnier = DomTurnier(
veranstaltungId = Uuid.random(),
name = "CSN-C-NEU",
sparte = SparteE.SPRINGEN,
kategorie = TurnierkategorieE.C_NEU,
datum = LocalDate(2026, 6, 1)
)
val bewerbe = listOf(
TurnierBewerbDescriptor(SparteE.SPRINGEN, beschreibung = "L 115 cm", hoeheCm = 115),
TurnierBewerbDescriptor(SparteE.SPRINGEN, beschreibung = "LM 125 cm", hoeheCm = 125)
)
val msgs = turnier.validateKategorieLimits(bewerbe, fakePolicy)
assertEquals(1, msgs.size, "Erwartet genau eine Limitverletzung (125cm auf C-NEU)")
}
}

View File

@ -0,0 +1,71 @@
package at.mocode.events.domain.validation
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.model.TurnierkategorieE
import kotlin.test.Test
import kotlin.test.assertEquals
class OeToTurnierkategoriePolicyTest {
private val policy = OeToTurnierkategoriePolicy
@Test
fun `CSN C verbietet 135cm, erlaubt 130cm`() {
val msgs = policy.validate(
TurnierkategorieE.C,
listOf(
TurnierBewerbDescriptor(SparteE.SPRINGEN, beschreibung = "LM 130 cm", hoeheCm = 130),
TurnierBewerbDescriptor(SparteE.SPRINGEN, beschreibung = "M 135 cm", hoeheCm = 135)
)
)
assertEquals(1, msgs.size)
}
@Test
fun `CSN C-NEU verbietet 120cm (ueber 115cm)`() {
val msgs = policy.validate(
TurnierkategorieE.C_NEU,
listOf(
TurnierBewerbDescriptor(SparteE.SPRINGEN, beschreibung = "L 115 cm", hoeheCm = 115),
TurnierBewerbDescriptor(SparteE.SPRINGEN, beschreibung = "LM 120 cm", hoeheCm = 120)
)
)
assertEquals(1, msgs.size)
}
@Test
fun `CSN B erlaubt 135cm, verbietet 140cm`() {
val msgs = policy.validate(
TurnierkategorieE.B,
listOf(
TurnierBewerbDescriptor(SparteE.SPRINGEN, beschreibung = "M 135 cm", hoeheCm = 135),
TurnierBewerbDescriptor(SparteE.SPRINGEN, beschreibung = "S1 140 cm", hoeheCm = 140)
)
)
assertEquals(1, msgs.size)
}
@Test
fun `CDN C verbietet M, erlaubt LM`() {
val msgs = policy.validate(
TurnierkategorieE.C,
listOf(
TurnierBewerbDescriptor(SparteE.DRESSUR, beschreibung = "LM Dressur", klasseCode = "LM"),
TurnierBewerbDescriptor(SparteE.DRESSUR, beschreibung = "M Dressur", klasseCode = "M")
)
)
assertEquals(1, msgs.size)
}
@Test
fun `CDN C-NEU verbietet LM, erlaubt L`() {
val msgs = policy.validate(
TurnierkategorieE.C_NEU,
listOf(
TurnierBewerbDescriptor(SparteE.DRESSUR, beschreibung = "L Dressur", klasseCode = "L"),
TurnierBewerbDescriptor(SparteE.DRESSUR, beschreibung = "LM Dressur", klasseCode = "LM")
)
)
assertEquals(1, msgs.size)
}
}

View File

@ -44,9 +44,14 @@
- [x] Migrations-Skript schreiben und testen (`db/tenant/V2__domain_hierarchy.sql`, Test: `DomainHierarchyMigrationTest`)
- [ ] **A-3** | Validierungs-Grundlage: Turnierkategorie-Limits
- [ ] `Turnier.validate()`: Bewerbs-Klassen gegen Limits der Turnierkategorie prüfen (z.B. kein S-Springen auf
C-Turnier)
- [ ] Voraussetzung: Spezifikation von 📜 Rulebook Expert (A-5) abwarten
- [x] Grundlage implementiert: Entkoppelte Policy-Schnittstelle + Bewerb-Descriptor
- Events-Domain: `DomTurnier.validateKategorieLimits(bewerbe, policy)` delegiert an `TurnierkategoriePolicy`
- Neu: `TurnierBewerbDescriptor`, `TurnierkategoriePolicy`, `NoopTurnierkategoriePolicy`
- Test: `DomTurnierKategorieValidationTest` mit Fake-Policy (Verkabelung + Beispielverletzungen)
- [x] Konkrete Regeln/Limits gemäß ÖTO umsetzen (eigene Policy-Implementierung)
- `OeToTurnierkategoriePolicy`: Harte Max-Limits umgesetzt (CSN: Höhe in cm; CDN: Klassen-Level). Sonderregeln (Pflichtbewerbe, Tageslimits, Genehmigungen) offen.
- Tests: `OeToTurnierkategoriePolicyTest` (CSN/C und C-NEU; B vs. 140 cm; CDN/C und C-NEU; B vs. S)
- [ ] Voraussetzung: Spezifikation von 📜 Rulebook Expert (A-5) abwarten (zur Ergänzung der Sonderregeln)
---