feat(validation): integrate ÖTO/FEI rule validations and centralized validators

- Added `OetoValidators` with live-validation for OEPS numbers, FEI-IDs, license classes, and horse data to align with ÖTO/FEI 2026 standards.
- Expanded `ReiterProfilViewModel` and `PferdProfilViewModel` to include validation states (`ValidationResult`) for enhanced form feedback and dirty state tracking.
- Standardized mock data in `Stores.kt` and `NennungModels.kt` to comply with updated validation rules.
- Created `OetoValidatorsTest` to ensure validation logic accuracy (30 unit tests, all green).
- Updated `build.gradle.kts` to include `kotlin.test` dependency for JVM testing.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-03 09:04:08 +02:00
parent 2c8d16b27f
commit 14b458860c
9 changed files with 631 additions and 28 deletions
@@ -0,0 +1,229 @@
package at.mocode.frontend.core.domain.validation
/**
* ÖTO/FEI Validierungsregeln — Frontend Live-Validierung
*
* Quelle: docs/03_Domain/02_Reference/Validierungsregeln.md (v0.3 DRAFT)
* Regelwerk: ÖTO 2026, FEI General Regulations 2026
*
* Alle Funktionen sind pure (keine Seiteneffekte), testbar und KMP-kompatibel.
*/
object OetoValidators {
// -------------------------------------------------------------------------
// 1. OEPS-Mitgliedsnummer (§ Validierungsregeln.md Abschnitt 1)
// -------------------------------------------------------------------------
private val OEPS_WITH_PREFIX = Regex("^OEPS-[0-9]{6,8}$")
private val OEPS_PLAIN = Regex("^[0-9]{6,8}$")
/**
* Prüft ob die OEPS-Mitgliedsnummer formal gültig ist.
* Erlaubt: 68 Ziffern, optional mit Präfix "OEPS-".
* Leerzeichen am Rand werden toleriert (trim).
*/
fun validateOepsNummer(input: String): ValidationResult {
if (input.isBlank()) return ValidationResult.Ok // Optionales Feld
val trimmed = input.trim()
return if (OEPS_WITH_PREFIX.matches(trimmed) || OEPS_PLAIN.matches(trimmed)) {
ValidationResult.Ok
} else {
ValidationResult.Error(
short = "Ungültige OEPS-Mitgliedsnummer. Erlaubt sind 68 Ziffern, optional mit Präfix 'OEPS-'.",
long = "Bitte eine gültige OEPS-Mitgliedsnummer eingeben: 68 Ziffern (z. B. 1234567 oder OEPS-1234567). Keine Leerzeichen oder Sonderzeichen."
)
}
}
// -------------------------------------------------------------------------
// 2. FEI-ID (§ Validierungsregeln.md Abschnitt 2)
// -------------------------------------------------------------------------
private val FEI_NUMERIC = Regex("^[0-9]{7,8}$")
private val FEI_LEGACY = Regex("^[0-9]{3}[A-Z]{2}[0-9]{2}$")
/**
* Prüft ob die FEI-ID formal gültig ist.
* Erlaubt: 78 Ziffern (aktuell) oder Legacy-Code NNNAAnn (z. B. 104FE22).
* Eingabe wird vor Prüfung in Großbuchstaben normalisiert.
*/
fun validateFeiId(input: String): ValidationResult {
if (input.isBlank()) return ValidationResult.Ok // Optionales Feld (national)
val s = input.trim().uppercase()
return if (FEI_NUMERIC.matches(s) || FEI_LEGACY.matches(s)) {
if (FEI_LEGACY.matches(s)) {
ValidationResult.Warning(
message = "Legacy FEI-Referenzcode erkannt (z. B. 104FE22). Wird beim Speichern automatisch aufgelöst, sofern ein Mapping vorhanden ist."
)
} else {
ValidationResult.Ok
}
} else {
ValidationResult.Error(
short = "Ungültige FEI-ID. Erlaubt sind 78 Ziffern (z. B. 10011469).",
long = "Bitte eine gültige FEI-ID eingeben: 78 Ziffern (z. B. 10011469). Historische Referenzcodes (z. B. 104FE22) werden akzeptiert und wenn möglich automatisch aufgelöst."
)
}
}
// -------------------------------------------------------------------------
// 3. Lizenzklasse (§ Validierungsregeln.md Abschnitt 3)
// -------------------------------------------------------------------------
/** Alle gültigen Lizenzklassen gemäß ÖTO/ZNS. */
val GUELTIGE_LIZENZKLASSEN = setOf("R1", "R2", "R3", "R4", "RD1", "RD2", "RD3", "LZF")
/**
* Prüft ob die Lizenzklasse im gültigen Katalog liegt.
*/
fun validateLizenzklasse(input: String): ValidationResult {
if (input.isBlank()) return ValidationResult.Ok // Optionales Feld
return if (input.trim().uppercase() in GUELTIGE_LIZENZKLASSEN) {
ValidationResult.Ok
} else {
ValidationResult.Error(
short = "Ungültige Lizenzklasse. Erlaubt: R1, R2, R3, R4, RD1, RD2, RD3, LZF.",
long = "Bitte eine gültige Lizenzklasse auswählen. Gültige Werte: R1, R2, R3, R4 (Springen), RD1, RD2, RD3 (Dressur), LZF (lizenzfrei)."
)
}
}
/**
* Prüft ob die Lizenzklasse für den gewählten Bewerb (Springen, Höhe in cm) zulässig ist.
* Quelle: Validierungsregeln.md Abschnitt 3.6, ÖTO § 231.
* Status: DRAFT — Tabelle wird nach Fachfreigabe auf STABLE angehoben.
*/
fun validateLizenzFuerSpringen(lizenz: String, hoeheInCm: Int): ValidationResult {
val erlaubt = erlaubteLizenzenSpringen(hoeheInCm)
return if (lizenz in erlaubt) {
ValidationResult.Ok
} else {
ValidationResult.Error(
short = "Diese Lizenzklasse ist für den ausgewählten Bewerb nicht zugelassen.",
long = "Bitte eine für diesen Bewerb zugelassene Lizenz auswählen. Die Zulassung richtet sich nach Disziplin und Höhe/Schwierigkeitsgrad (ÖTO § 231)."
)
}
}
private fun erlaubteLizenzenSpringen(hoeheInCm: Int): Set<String> = when {
hoeheInCm <= 95 -> setOf("LZF", "R1", "R2", "R3", "R4")
hoeheInCm <= 100 -> setOf("R1", "R2", "R3", "R4")
hoeheInCm <= 110 -> setOf("R1", "R2", "R3", "R4")
hoeheInCm <= 120 -> setOf("R2", "R3", "R4")
hoeheInCm <= 135 -> setOf("R3", "R4")
else -> setOf("R4")
}
/**
* Prüft ob die Lizenzklasse für den gewählten Dressur-Bewerb zulässig ist.
* Quelle: Validierungsregeln.md Abschnitt 3.6, ÖTO § 103.
* Status: DRAFT — Tabelle wird nach Fachfreigabe auf STABLE angehoben.
*/
fun validateLizenzFuerDressur(lizenz: String, niveau: DressurNiveau): ValidationResult {
val erlaubt = erlaubteLizenzenDressur(niveau)
return if (lizenz in erlaubt) {
ValidationResult.Ok
} else {
ValidationResult.Error(
short = "Diese Lizenzklasse ist für den ausgewählten Bewerb nicht zugelassen.",
long = "Bitte eine für diesen Bewerb zugelassene Lizenz auswählen. Die Zulassung richtet sich nach Disziplin und Schwierigkeitsgrad (ÖTO § 103)."
)
}
}
private fun erlaubteLizenzenDressur(niveau: DressurNiveau): Set<String> = when (niveau) {
DressurNiveau.EINSTEIGER -> setOf("LZF", "RD1", "RD2", "RD3")
DressurNiveau.A_L -> setOf("RD1", "RD2", "RD3")
DressurNiveau.LM_M -> setOf("RD2", "RD3")
DressurNiveau.S -> setOf("RD3")
}
// -------------------------------------------------------------------------
// 4. Altersklasse Pferd (§ Validierungsregeln.md Abschnitt 4)
// -------------------------------------------------------------------------
/**
* Berechnet das Pferdealter nach ÖTO-Stichtagsregel (1. Jänner des Veranstaltungsjahres).
* Quelle: Validierungsregeln.md Abschnitt 4.1.
*/
fun pferdAlterAm1Jan(geburtsjahr: Int, veranstaltungsjahr: Int): Int =
veranstaltungsjahr - geburtsjahr
/**
* Prüft ob das Pferd für einen Springbewerb (Höhe in cm) alt genug ist.
* Quelle: Validierungsregeln.md Abschnitt 4.5, ÖTO § 231.
* Status: DRAFT.
*/
fun validatePferdAlterSpringen(geburtsjahr: Int, veranstaltungsjahr: Int, hoeheInCm: Int): ValidationResult {
val alter = pferdAlterAm1Jan(geburtsjahr, veranstaltungsjahr)
val minAlter = minAlterSpringen(hoeheInCm)
return if (alter >= minAlter) {
ValidationResult.Ok
} else {
ValidationResult.Error(
short = "Pferd ist für diesen Bewerb zu jung.",
long = "Das Mindestalter für diesen Bewerb ist $minAlter Jahre (Stichtag 1. Jänner). Dieses Pferd gilt im Jahr $veranstaltungsjahr als $alter Jahre alt."
)
}
}
private fun minAlterSpringen(hoeheInCm: Int): Int = when {
hoeheInCm <= 100 -> 4
hoeheInCm <= 120 -> 5
else -> 6
}
/**
* Prüft ob das Pferd für einen Dressurbewerb alt genug ist.
* Quelle: Validierungsregeln.md Abschnitt 4.5, ÖTO § 103.
* Status: DRAFT.
*/
fun validatePferdAlterDressur(geburtsjahr: Int, veranstaltungsjahr: Int, niveau: DressurNiveau): ValidationResult {
val alter = pferdAlterAm1Jan(geburtsjahr, veranstaltungsjahr)
val minAlter = minAlterDressur(niveau)
return if (alter >= minAlter) {
ValidationResult.Ok
} else {
ValidationResult.Error(
short = "Pferd ist für diesen Bewerb zu jung.",
long = "Das Mindestalter für diesen Bewerb ist $minAlter Jahre (Stichtag 1. Jänner). Dieses Pferd gilt im Jahr $veranstaltungsjahr als $alter Jahre alt."
)
}
}
private fun minAlterDressur(niveau: DressurNiveau): Int = when (niveau) {
DressurNiveau.EINSTEIGER -> 4
DressurNiveau.A_L -> 4
DressurNiveau.LM_M -> 5
DressurNiveau.S -> 6
}
}
// -------------------------------------------------------------------------
// Hilfstypen
// -------------------------------------------------------------------------
/**
* Ergebnis einer Validierungsprüfung.
*/
sealed interface ValidationResult {
/** Eingabe ist regelkonform. */
data object Ok : ValidationResult
/** Eingabe verletzt eine Pflicht-Regel (blockierend). */
data class Error(val short: String, val long: String = short) : ValidationResult
/** Eingabe ist formal gültig, aber ein Hinweis ist angebracht (nicht blockierend). */
data class Warning(val message: String) : ValidationResult
}
/**
* Dressur-Prüfungsniveaus gemäß ÖTO § 103.
* Status: DRAFT — Mapping auf konkrete Prüfungsbezeichnungen folgt.
*/
enum class DressurNiveau {
EINSTEIGER, // Dressurreiterprüfung, Führzügel, First Ridden
A_L, // Klasse A / Klasse L
LM_M, // Klasse LM / Klasse M
S // Klasse S
}
@@ -0,0 +1,205 @@
package at.mocode.frontend.core.domain.validation
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* Unit-Tests für OetoValidators.
* Quelle: docs/03_Domain/02_Reference/Validierungsregeln.md (v0.3 DRAFT)
*/
class OetoValidatorsTest {
// -------------------------------------------------------------------------
// OEPS-Mitgliedsnummer
// -------------------------------------------------------------------------
@Test
fun `OEPS leer ist Ok (optionales Feld)`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateOepsNummer(""))
@Test
fun `OEPS 7 Ziffern plain ist Ok`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateOepsNummer("1234567"))
@Test
fun `OEPS 8 Ziffern plain ist Ok`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateOepsNummer("12345678"))
@Test
fun `OEPS 6 Ziffern plain ist Ok`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateOepsNummer("123456"))
@Test
fun `OEPS mit Präfix OEPS-1234567 ist Ok`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateOepsNummer("OEPS-1234567"))
@Test
fun `OEPS mit Leerzeichen am Rand wird toleriert`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateOepsNummer(" 1234567 "))
@Test
fun `OEPS altes Format O-12345 ist Error`() =
assertIs<ValidationResult.Error>(OetoValidators.validateOepsNummer("O-12345"))
@Test
fun `OEPS alphanumerisch 3H66 ist Error`() =
assertIs<ValidationResult.Error>(OetoValidators.validateOepsNummer("3H66"))
@Test
fun `OEPS zu kurz 5 Ziffern ist Error`() =
assertIs<ValidationResult.Error>(OetoValidators.validateOepsNummer("12345"))
@Test
fun `OEPS zu lang 9 Ziffern ist Error`() =
assertIs<ValidationResult.Error>(OetoValidators.validateOepsNummer("123456789"))
@Test
fun `OEPS GB-9999 ist Error`() =
assertIs<ValidationResult.Error>(OetoValidators.validateOepsNummer("GB-9999"))
// -------------------------------------------------------------------------
// FEI-ID
// -------------------------------------------------------------------------
@Test
fun `FEI leer ist Ok (national optional)`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateFeiId(""))
@Test
fun `FEI 8 Ziffern ist Ok`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateFeiId("10011469"))
@Test
fun `FEI 7 Ziffern ist Ok`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateFeiId("1001146"))
@Test
fun `FEI Legacy-Code 104FE22 ist Warning`() =
assertIs<ValidationResult.Warning>(OetoValidators.validateFeiId("104FE22"))
@Test
fun `FEI Legacy-Code lowercase wird normalisiert`() =
assertIs<ValidationResult.Warning>(OetoValidators.validateFeiId("104fe22"))
@Test
fun `FEI 6 Ziffern ist Error`() =
assertIs<ValidationResult.Error>(OetoValidators.validateFeiId("100114"))
@Test
fun `FEI 9 Ziffern ist Error`() =
assertIs<ValidationResult.Error>(OetoValidators.validateFeiId("100114690"))
@Test
fun `FEI alphanumerisch AT-12345 ist Error`() =
assertIs<ValidationResult.Error>(OetoValidators.validateFeiId("AT-12345"))
// -------------------------------------------------------------------------
// Lizenzklasse
// -------------------------------------------------------------------------
@Test
fun `Lizenz leer ist Ok`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateLizenzklasse(""))
@Test
fun `Lizenz R1 ist Ok`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateLizenzklasse("R1"))
@Test
fun `Lizenz R4 ist Ok`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateLizenzklasse("R4"))
@Test
fun `Lizenz RD1 ist Ok`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateLizenzklasse("RD1"))
@Test
fun `Lizenz RD3 ist Ok`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateLizenzklasse("RD3"))
@Test
fun `Lizenz LZF ist Ok`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateLizenzklasse("LZF"))
@Test
fun `Lizenz RD4 existiert nicht - Error`() =
assertIs<ValidationResult.Error>(OetoValidators.validateLizenzklasse("RD4"))
@Test
fun `Lizenz R2D2 existiert nicht - Error`() =
assertIs<ValidationResult.Error>(OetoValidators.validateLizenzklasse("R2D2"))
@Test
fun `Lizenz R5 existiert nicht - Error`() =
assertIs<ValidationResult.Error>(OetoValidators.validateLizenzklasse("R5"))
// -------------------------------------------------------------------------
// Lizenz × Bewerb (Springen)
// -------------------------------------------------------------------------
@Test
fun `LZF darf bei 95cm starten`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateLizenzFuerSpringen("LZF", 95))
@Test
fun `LZF darf nicht bei 100cm starten`() =
assertIs<ValidationResult.Error>(OetoValidators.validateLizenzFuerSpringen("LZF", 100))
@Test
fun `R1 darf bei 110cm starten`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateLizenzFuerSpringen("R1", 110))
@Test
fun `R1 darf nicht bei 120cm starten`() =
assertIs<ValidationResult.Error>(OetoValidators.validateLizenzFuerSpringen("R1", 120))
@Test
fun `R4 darf bei 140cm starten`() =
assertIs<ValidationResult.Ok>(OetoValidators.validateLizenzFuerSpringen("R4", 140))
// -------------------------------------------------------------------------
// Pferd-Alter (Stichtagsregel 1. Jänner)
// -------------------------------------------------------------------------
@Test
fun `Pferd 2022 gilt 2026 als 4 Jahre alt`() =
assertEquals(4, OetoValidators.pferdAlterAm1Jan(2022, 2026))
@Test
fun `Pferd 4 Jahre darf 95cm springen`() =
assertIs<ValidationResult.Ok>(OetoValidators.validatePferdAlterSpringen(2022, 2026, 95))
@Test
fun `Pferd 4 Jahre darf nicht 125cm springen`() =
assertIs<ValidationResult.Error>(OetoValidators.validatePferdAlterSpringen(2022, 2026, 125))
@Test
fun `Pferd 5 Jahre darf 120cm springen`() =
assertIs<ValidationResult.Ok>(OetoValidators.validatePferdAlterSpringen(2021, 2026, 120))
@Test
fun `Pferd 6 Jahre darf 140cm springen`() =
assertIs<ValidationResult.Ok>(OetoValidators.validatePferdAlterSpringen(2020, 2026, 140))
@Test
fun `Pferd 3 Jahre darf nicht starten (Mindestalter 4)`() =
assertIs<ValidationResult.Error>(OetoValidators.validatePferdAlterSpringen(2023, 2026, 80))
@Test
fun `Pferd 4 Jahre darf Dressur Einsteiger`() =
assertIs<ValidationResult.Ok>(OetoValidators.validatePferdAlterDressur(2022, 2026, DressurNiveau.EINSTEIGER))
@Test
fun `Pferd 4 Jahre darf nicht Dressur S`() =
assertIs<ValidationResult.Error>(OetoValidators.validatePferdAlterDressur(2022, 2026, DressurNiveau.S))
@Test
fun `Fehlermeldung enthält Mindestalter und Ist-Alter`() {
val result = OetoValidators.validatePferdAlterSpringen(2023, 2026, 125) as ValidationResult.Error
assertTrue(result.long.contains("6")) // Mindestalter für >120cm
assertTrue(result.long.contains("3")) // Ist-Alter (2026-2023)
assertTrue(result.long.contains("2026"))
}
}