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:
parent
dc68a6b749
commit
6595ec674f
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user