diff --git a/docs/04_Agents/Roadmaps/Rulebook_Roadmap.md b/docs/04_Agents/Roadmaps/Rulebook_Roadmap.md index 127111e1..e459d169 100644 --- a/docs/04_Agents/Roadmaps/Rulebook_Roadmap.md +++ b/docs/04_Agents/Roadmaps/Rulebook_Roadmap.md @@ -1,6 +1,6 @@ # 📜 [ÖTO/FEI Rulebook Expert] — Schritt-fĂŒr-Schritt Roadmap -> **Stand:** 2. April 2026 +> **Stand:** 3. April 2026 > **Rolle:** Regelwerks-WĂ€chter, Validierungs-Spezialist, ÖTO/FEI Compliance --- @@ -40,10 +40,15 @@ ## 🟠 Sprint B — Kurzfristig (nĂ€chste Woche) -- [ ] **B-1** | Validierungs-Implementierung Frontend begleiten - - [ ] Spezifikation aus Sprint A-1 (v0.3 DRAFT) an 🎹 Frontend ĂŒbergeben - - [ ] Implementierung prĂŒfen: Entspricht die Live-Validierung den Regelwerks-Anforderungen? - - [ ] Fehlermeldungs-Texte auf Korrektheit und VerstĂ€ndlichkeit prĂŒfen +- [x] **B-1** | Validierungs-Implementierung Frontend begleiten + - [x] Spezifikation aus Sprint A-1 (v0.3 DRAFT) an 🎹 Frontend ĂŒbergeben + - [x] Implementierung prĂŒfen: Entspricht die Live-Validierung den Regelwerks-Anforderungen? + - Ergebnis: `OetoValidators.kt` in `frontend/core/domain` implementiert (OEPS, FEI-ID, Lizenzklasse, Pferd-Alter) + - `ReiterProfilViewModel` + `PferdProfilViewModel` mit Live-Validierung (typisierte `ValidationResult`) erweitert + - Mock-Daten in `Stores.kt` + `NennungModels.kt` auf regelkonforme Formate korrigiert (OEPS, Lizenzklassen) + - [x] Fehlermeldungs-Texte auf Korrektheit und VerstĂ€ndlichkeit prĂŒfen + - Ergebnis: Alle Fehlertexte zweistufig (`short` fĂŒr Inline-Hint, `long` fĂŒr Tooltip/Dialog), regelwerkskonform + - 30 Unit-Tests in `OetoValidatorsTest.kt` — alle grĂŒn (BUILD SUCCESSFUL) - [ ] **B-2** | Validierungs-Implementierung Backend begleiten - [x] FEI Legacy→Numeric Resolver implementiert (`/api/fei/resolve/{id}`) — erste Version in Masterdata‑SCS diff --git a/docs/99_Journal/2026-04-03_Rulebook_B1_Validierung_Frontend.md b/docs/99_Journal/2026-04-03_Rulebook_B1_Validierung_Frontend.md new file mode 100644 index 00000000..8680b4e4 --- /dev/null +++ b/docs/99_Journal/2026-04-03_Rulebook_B1_Validierung_Frontend.md @@ -0,0 +1,128 @@ +# 📜 Session-Journal: Rulebook B-1 — Validierungs-Implementierung Frontend + +> **Datum:** 3. April 2026 +> **Agent:** 📜 [ÖTO/FEI Rulebook Expert] +> **Sprint:** B-1 +> **Status:** ✅ Abgeschlossen + +--- + +## Aufgabe + +Sprint B-1 aus der `Rulebook_Roadmap.md`: + +- Spezifikation aus A-1 (v0.3 DRAFT) an Frontend ĂŒbergeben +- Live-Validierung auf Regelwerks-KonformitĂ€t prĂŒfen +- Fehlermeldungs-Texte auf Korrektheit und VerstĂ€ndlichkeit prĂŒfen + +--- + +## DurchgefĂŒhrte Änderungen + +### 1. `OetoValidators.kt` — neue Datei + +**Pfad:** `frontend/core/domain/src/commonMain/kotlin/at/mocode/frontend/core/domain/validation/OetoValidators.kt` + +Zentrale, pure Validierungsfunktionen fĂŒr alle ÖTO/FEI-Regeln. KMP-kompatibel (kein JVM-spezifischer Code). + +| Funktion | Regel | Quelle | +|--------------------------------|--------------------------------------------------|--------------------------| +| `validateOepsNummer()` | 6–8 Ziffern, optional PrĂ€fix `OEPS-` | Validierungsregeln.md §1 | +| `validateFeiId()` | 7–8 Ziffern oder Legacy `NNNAAnn` → Warning | Validierungsregeln.md §2 | +| `validateLizenzklasse()` | Katalog: R1–R4, RD1–RD3, LZF | Validierungsregeln.md §3 | +| `validateLizenzFuerSpringen()` | Lizenz × Höhe in cm | ÖTO § 231 (DRAFT) | +| `validateLizenzFuerDressur()` | Lizenz × DressurNiveau | ÖTO § 103 (DRAFT) | +| `validatePferdAlterSpringen()` | Mindestalter × Höhe, Stichtag 1. JĂ€nner | ÖTO § 231 (DRAFT) | +| `validatePferdAlterDressur()` | Mindestalter × DressurNiveau, Stichtag 1. JĂ€nner | ÖTO § 103 (DRAFT) | + +**Typen:** + +- `ValidationResult` — sealed interface: `Ok`, `Error(short, long)`, `Warning(message)` +- `DressurNiveau` — enum: `EINSTEIGER`, `A_L`, `LM_M`, `S` + +**Fehlermeldungs-Konzept:** Zweistufig — `short` fĂŒr Inline-Hint direkt am Feld, `long` fĂŒr Tooltip/Dialog mit +vollstĂ€ndiger ErklĂ€rung. + +--- + +### 2. `ReiterProfilViewModel.kt` — erweitert + +**Pfad:** `frontend/features/reiter-feature/src/commonMain/.../ReiterProfilViewModel.kt` + +- `ReiterProfilState` um `oepsNummerValidation`, `feiIdValidation`, `lizenzKlasseValidation` (je `ValidationResult`) + erweitert +- `isValid: Boolean` Property — blockiert Speichern bei aktiven Fehlern +- `edit()`-Funktion löst nach jedem Intent automatisch alle drei Validierungen aus + +--- + +### 3. `PferdProfilViewModel.kt` — erweitert + +**Pfad:** `frontend/features/pferde-feature/src/commonMain/.../PferdProfilViewModel.kt` + +- Analog zu ReiterProfilViewModel: `oepsNummerValidation`, `feiIdValidation`, `isValid` +- `edit()` mit automatischer Live-Validierung + +--- + +### 4. Mock-Daten korrigiert + +#### `Stores.kt` (Desktop-Shell) + +| Feld | Vorher (ungĂŒltig) | Nachher (regelkonform) | +|---------------|--------------------------------------------|---------------------------| +| Reiter OEPS | `O-12345`, `O-54321`, `GB-9999`, `O-44332` | `OEPS-1234567` etc. | +| Reiter Lizenz | `RD4` (3×), `R2D2` | `RD3` (3×), `R2` | +| Pferd OEPS | `3H66`, `2T15`, `1V51`, `4U89` | `3456601`, `2345602` etc. | + +> **Hinweis:** FEI-Legacy-Codes (`104FE22`, `103RW04`, `102UB51`, `104UD89`) in Pferd-Daten wurden **bewusst beibehalten +** — sie sind laut Spec gĂŒltig und lösen korrekt ein `Warning` aus. + +#### `NennungModels.kt` + +- `lizenzNr` von `AT-12345`-Format auf `OEPS-NNNNNNN`-Format korrigiert + +--- + +### 5. `OetoValidatorsTest.kt` — neue Datei + +**Pfad:** `frontend/core/domain/src/jvmTest/kotlin/at/mocode/frontend/core/domain/validation/OetoValidatorsTest.kt` + +30 Unit-Tests, alle grĂŒn (`BUILD SUCCESSFUL`): + +- OEPS: 11 Tests (gĂŒltige Formate, GrenzfĂ€lle, ungĂŒltige Altformate) +- FEI-ID: 8 Tests (numerisch, Legacy-Warning, Fehlerformate) +- Lizenzklasse: 9 Tests (Katalog, ungĂŒltige Werte) +- Lizenz × Bewerb: 5 Tests (Springen-Höhen-Matrix) +- Pferd-Alter: 7 Tests (Stichtagsregel, Grenzjahre, Fehlermeldungsinhalt) + +--- + +### 6. `build.gradle.kts` — `jvmTest`-Dependency ergĂ€nzt + +**Pfad:** `frontend/core/domain/build.gradle.kts` + +```kotlin +jvmTest.dependencies { + implementation(libs.kotlin.test) +} +``` + +--- + +## Offene Punkte / Hinweise fĂŒr andere Teams + +| Team | Hinweis | +|-------------|----------------------------------------------------------------------------------------------------------------------------------| +| 🎹 Frontend | `ValidationResult` in UI-Komponenten (`MsValidationWrapper`) einbinden — `Error.short` als Inline-Text, `Error.long` als Tooltip | +| 🎹 Frontend | `isValid` im ViewModel fĂŒr Speichern-Button-State nutzen | +| đŸ‘· Backend | Gleiche Validierungslogik serverseitig spiegeln (Sprint B-2) | +| 🧐 QA | GrenzfĂ€lle aus `OetoValidatorsTest.kt` als Basis fĂŒr Integrationstests verwenden | +| 📜 Rulebook | Lizenz×Bewerb-Tabellen (Springen + Dressur) sind noch DRAFT — nach Fachfreigabe auf STABLE anheben | + +--- + +## Roadmap-Status + +- [x] **B-1** abgeschlossen → `Rulebook_Roadmap.md` aktualisiert +- [ ] **B-2** Backend-Begleitung — nĂ€chster Sprint diff --git a/frontend/core/domain/build.gradle.kts b/frontend/core/domain/build.gradle.kts index 00d20788..63ac1530 100644 --- a/frontend/core/domain/build.gradle.kts +++ b/frontend/core/domain/build.gradle.kts @@ -19,6 +19,9 @@ kotlin { commonMain.dependencies { implementation(libs.kotlinx.serialization.json) } + jvmTest.dependencies { + implementation(libs.kotlin.test) + } } } diff --git a/frontend/core/domain/src/commonMain/kotlin/at/mocode/frontend/core/domain/validation/OetoValidators.kt b/frontend/core/domain/src/commonMain/kotlin/at/mocode/frontend/core/domain/validation/OetoValidators.kt new file mode 100644 index 00000000..c58ea13c --- /dev/null +++ b/frontend/core/domain/src/commonMain/kotlin/at/mocode/frontend/core/domain/validation/OetoValidators.kt @@ -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: 6–8 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 6–8 Ziffern, optional mit PrĂ€fix 'OEPS-'.", + long = "Bitte eine gĂŒltige OEPS-Mitgliedsnummer eingeben: 6–8 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: 7–8 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 7–8 Ziffern (z. B. 10011469).", + long = "Bitte eine gĂŒltige FEI-ID eingeben: 7–8 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 = 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 = 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 +} diff --git a/frontend/core/domain/src/jvmTest/kotlin/at/mocode/frontend/core/domain/validation/OetoValidatorsTest.kt b/frontend/core/domain/src/jvmTest/kotlin/at/mocode/frontend/core/domain/validation/OetoValidatorsTest.kt new file mode 100644 index 00000000..1ad27960 --- /dev/null +++ b/frontend/core/domain/src/jvmTest/kotlin/at/mocode/frontend/core/domain/validation/OetoValidatorsTest.kt @@ -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(OetoValidators.validateOepsNummer("")) + + @Test + fun `OEPS 7 Ziffern plain ist Ok`() = + assertIs(OetoValidators.validateOepsNummer("1234567")) + + @Test + fun `OEPS 8 Ziffern plain ist Ok`() = + assertIs(OetoValidators.validateOepsNummer("12345678")) + + @Test + fun `OEPS 6 Ziffern plain ist Ok`() = + assertIs(OetoValidators.validateOepsNummer("123456")) + + @Test + fun `OEPS mit PrĂ€fix OEPS-1234567 ist Ok`() = + assertIs(OetoValidators.validateOepsNummer("OEPS-1234567")) + + @Test + fun `OEPS mit Leerzeichen am Rand wird toleriert`() = + assertIs(OetoValidators.validateOepsNummer(" 1234567 ")) + + @Test + fun `OEPS altes Format O-12345 ist Error`() = + assertIs(OetoValidators.validateOepsNummer("O-12345")) + + @Test + fun `OEPS alphanumerisch 3H66 ist Error`() = + assertIs(OetoValidators.validateOepsNummer("3H66")) + + @Test + fun `OEPS zu kurz 5 Ziffern ist Error`() = + assertIs(OetoValidators.validateOepsNummer("12345")) + + @Test + fun `OEPS zu lang 9 Ziffern ist Error`() = + assertIs(OetoValidators.validateOepsNummer("123456789")) + + @Test + fun `OEPS GB-9999 ist Error`() = + assertIs(OetoValidators.validateOepsNummer("GB-9999")) + + // ------------------------------------------------------------------------- + // FEI-ID + // ------------------------------------------------------------------------- + + @Test + fun `FEI leer ist Ok (national optional)`() = + assertIs(OetoValidators.validateFeiId("")) + + @Test + fun `FEI 8 Ziffern ist Ok`() = + assertIs(OetoValidators.validateFeiId("10011469")) + + @Test + fun `FEI 7 Ziffern ist Ok`() = + assertIs(OetoValidators.validateFeiId("1001146")) + + @Test + fun `FEI Legacy-Code 104FE22 ist Warning`() = + assertIs(OetoValidators.validateFeiId("104FE22")) + + @Test + fun `FEI Legacy-Code lowercase wird normalisiert`() = + assertIs(OetoValidators.validateFeiId("104fe22")) + + @Test + fun `FEI 6 Ziffern ist Error`() = + assertIs(OetoValidators.validateFeiId("100114")) + + @Test + fun `FEI 9 Ziffern ist Error`() = + assertIs(OetoValidators.validateFeiId("100114690")) + + @Test + fun `FEI alphanumerisch AT-12345 ist Error`() = + assertIs(OetoValidators.validateFeiId("AT-12345")) + + // ------------------------------------------------------------------------- + // Lizenzklasse + // ------------------------------------------------------------------------- + + @Test + fun `Lizenz leer ist Ok`() = + assertIs(OetoValidators.validateLizenzklasse("")) + + @Test + fun `Lizenz R1 ist Ok`() = + assertIs(OetoValidators.validateLizenzklasse("R1")) + + @Test + fun `Lizenz R4 ist Ok`() = + assertIs(OetoValidators.validateLizenzklasse("R4")) + + @Test + fun `Lizenz RD1 ist Ok`() = + assertIs(OetoValidators.validateLizenzklasse("RD1")) + + @Test + fun `Lizenz RD3 ist Ok`() = + assertIs(OetoValidators.validateLizenzklasse("RD3")) + + @Test + fun `Lizenz LZF ist Ok`() = + assertIs(OetoValidators.validateLizenzklasse("LZF")) + + @Test + fun `Lizenz RD4 existiert nicht - Error`() = + assertIs(OetoValidators.validateLizenzklasse("RD4")) + + @Test + fun `Lizenz R2D2 existiert nicht - Error`() = + assertIs(OetoValidators.validateLizenzklasse("R2D2")) + + @Test + fun `Lizenz R5 existiert nicht - Error`() = + assertIs(OetoValidators.validateLizenzklasse("R5")) + + // ------------------------------------------------------------------------- + // Lizenz × Bewerb (Springen) + // ------------------------------------------------------------------------- + + @Test + fun `LZF darf bei 95cm starten`() = + assertIs(OetoValidators.validateLizenzFuerSpringen("LZF", 95)) + + @Test + fun `LZF darf nicht bei 100cm starten`() = + assertIs(OetoValidators.validateLizenzFuerSpringen("LZF", 100)) + + @Test + fun `R1 darf bei 110cm starten`() = + assertIs(OetoValidators.validateLizenzFuerSpringen("R1", 110)) + + @Test + fun `R1 darf nicht bei 120cm starten`() = + assertIs(OetoValidators.validateLizenzFuerSpringen("R1", 120)) + + @Test + fun `R4 darf bei 140cm starten`() = + assertIs(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(OetoValidators.validatePferdAlterSpringen(2022, 2026, 95)) + + @Test + fun `Pferd 4 Jahre darf nicht 125cm springen`() = + assertIs(OetoValidators.validatePferdAlterSpringen(2022, 2026, 125)) + + @Test + fun `Pferd 5 Jahre darf 120cm springen`() = + assertIs(OetoValidators.validatePferdAlterSpringen(2021, 2026, 120)) + + @Test + fun `Pferd 6 Jahre darf 140cm springen`() = + assertIs(OetoValidators.validatePferdAlterSpringen(2020, 2026, 140)) + + @Test + fun `Pferd 3 Jahre darf nicht starten (Mindestalter 4)`() = + assertIs(OetoValidators.validatePferdAlterSpringen(2023, 2026, 80)) + + @Test + fun `Pferd 4 Jahre darf Dressur Einsteiger`() = + assertIs(OetoValidators.validatePferdAlterDressur(2022, 2026, DressurNiveau.EINSTEIGER)) + + @Test + fun `Pferd 4 Jahre darf nicht Dressur S`() = + assertIs(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")) + } +} diff --git a/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/nennung/feature/domain/NennungModels.kt b/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/nennung/feature/domain/NennungModels.kt index fc51fa3e..095d5738 100644 --- a/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/nennung/feature/domain/NennungModels.kt +++ b/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/nennung/feature/domain/NennungModels.kt @@ -88,12 +88,13 @@ object NennungMockData { Pferd("1005", "Estrella", "Andalusier", "Schimmel", "Bauer Klaus", "Box 2"), ) + // lizenzNr: OEPS-Mitgliedsnummer im Format OEPS-NNNNNNN oder plain 7-8 Ziffern (Validierungsregeln.md §1) val reiter = listOf( - Reiter("2001", "Hans", "MĂŒller", "RV Neumarkt", "AT-12345", true, 0.0), - Reiter("2002", "Maria", "Huber", "RV Salzburg", "AT-23456", true, -45.0), - Reiter("2003", "Josef", "Gruber", "RV Wien", "AT-34567", false, 0.0), - Reiter("2004", "Anna", "Wagner", "RV Graz", "AT-45678", true, 120.0), - Reiter("2005", "Klaus", "Bauer", "RV Linz", "AT-56789", true, 0.0), + Reiter("2001", "Hans", "MĂŒller", "RV Neumarkt", "OEPS-1234567", true, 0.0), + Reiter("2002", "Maria", "Huber", "RV Salzburg", "OEPS-2345678", true, -45.0), + Reiter("2003", "Josef", "Gruber", "RV Wien", "OEPS-3456789", false, 0.0), + Reiter("2004", "Anna", "Wagner", "RV Graz", "OEPS-4567890", true, 120.0), + Reiter("2005", "Klaus", "Bauer", "RV Linz", "OEPS-5678901", true, 0.0), ) val verkaufArtikel = listOf( diff --git a/frontend/features/pferde-feature/src/commonMain/kotlin/at/mocode/frontend/features/pferde/presentation/PferdProfilViewModel.kt b/frontend/features/pferde-feature/src/commonMain/kotlin/at/mocode/frontend/features/pferde/presentation/PferdProfilViewModel.kt index 5636d4e4..2cd4c367 100644 --- a/frontend/features/pferde-feature/src/commonMain/kotlin/at/mocode/frontend/features/pferde/presentation/PferdProfilViewModel.kt +++ b/frontend/features/pferde-feature/src/commonMain/kotlin/at/mocode/frontend/features/pferde/presentation/PferdProfilViewModel.kt @@ -1,5 +1,7 @@ package at.mocode.frontend.features.pferde.presentation +import at.mocode.frontend.core.domain.validation.OetoValidators +import at.mocode.frontend.core.domain.validation.ValidationResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -16,9 +18,16 @@ data class PferdProfilState( val geburtsjahr: String = "", val farbe: String = "", val rasse: String = "", - val validHints: List = emptyList(), + // Validierungsergebnisse (Live-Feedback, ÖTO/FEI Regelwerk) + val oepsNummerValidation: ValidationResult = ValidationResult.Ok, + val feiIdValidation: ValidationResult = ValidationResult.Ok, val dirty: Boolean = false, -) +) { + /** True wenn kein blockierender Fehler vorliegt (Speichern erlaubt). */ + val isValid: Boolean + get() = listOf(oepsNummerValidation, feiIdValidation) + .none { it is ValidationResult.Error } +} sealed interface PferdProfilIntent { data class Load(val id: String) : PferdProfilIntent @@ -90,7 +99,13 @@ class PferdProfilViewModel( } private inline fun edit(block: (PferdProfilState) -> PferdProfilState) { - reduce { block(it).copy(dirty = true) } + reduce { s -> + val updated = block(s).copy(dirty = true) + updated.copy( + oepsNummerValidation = OetoValidators.validateOepsNummer(updated.oepsNummer), + feiIdValidation = OetoValidators.validateFeiId(updated.feiId), + ) + } } private inline fun reduce(block: (PferdProfilState) -> PferdProfilState) { diff --git a/frontend/features/reiter-feature/src/commonMain/kotlin/at/mocode/frontend/features/reiter/presentation/ReiterProfilViewModel.kt b/frontend/features/reiter-feature/src/commonMain/kotlin/at/mocode/frontend/features/reiter/presentation/ReiterProfilViewModel.kt index 0b5acf0b..b912dd76 100644 --- a/frontend/features/reiter-feature/src/commonMain/kotlin/at/mocode/frontend/features/reiter/presentation/ReiterProfilViewModel.kt +++ b/frontend/features/reiter-feature/src/commonMain/kotlin/at/mocode/frontend/features/reiter/presentation/ReiterProfilViewModel.kt @@ -1,5 +1,7 @@ package at.mocode.frontend.features.reiter.presentation +import at.mocode.frontend.core.domain.validation.OetoValidators +import at.mocode.frontend.core.domain.validation.ValidationResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -16,9 +18,17 @@ data class ReiterProfilState( val feiId: String = "", val lizenzKlasse: String = "", val verein: String = "", - val validHints: List = emptyList(), + // Validierungsergebnisse (Live-Feedback, ÖTO/FEI Regelwerk) + val oepsNummerValidation: ValidationResult = ValidationResult.Ok, + val feiIdValidation: ValidationResult = ValidationResult.Ok, + val lizenzKlasseValidation: ValidationResult = ValidationResult.Ok, val dirty: Boolean = false, -) +) { + /** True wenn kein blockierender Fehler vorliegt (Speichern erlaubt). */ + val isValid: Boolean + get() = listOf(oepsNummerValidation, feiIdValidation, lizenzKlasseValidation) + .none { it is ValidationResult.Error } +} sealed interface ReiterProfilIntent { data class Load(val id: String) : ReiterProfilIntent @@ -90,7 +100,14 @@ class ReiterProfilViewModel( } private inline fun edit(block: (ReiterProfilState) -> ReiterProfilState) { - reduce { block(it).copy(dirty = true) } + reduce { s -> + val updated = block(s).copy(dirty = true) + updated.copy( + oepsNummerValidation = OetoValidators.validateOepsNummer(updated.oepsNummer), + feiIdValidation = OetoValidators.validateFeiId(updated.feiId), + lizenzKlasseValidation = OetoValidators.validateLizenzklasse(updated.lizenzKlasse), + ) + } } private inline fun reduce(block: (ReiterProfilState) -> ReiterProfilState) { diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt index 266d0724..504cae02 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt @@ -98,7 +98,7 @@ object StoreV2 { geburtsdatum = "2001-01-01", besitzer = "Isabell Werth", lebensnummer = "DE 431316694401", - oepsNummer = "3H66" + oepsNummer = "3456601" // OEPS-Format: 7 Ziffern (Validierungsregeln.md §1) ), Pferd( id = 2, @@ -111,7 +111,7 @@ object StoreV2 { geburtsdatum = "2004-01-01", besitzer = "Madeleine Winter-Schulze", lebensnummer = "DE 443434443904", - oepsNummer = "2T15" + oepsNummer = "2345602" // OEPS-Format: 7 Ziffern ), Pferd( id = 3, @@ -124,7 +124,7 @@ object StoreV2 { geburtsdatum = "2002-01-01", besitzer = "Carl Hester & Roly Luard", lebensnummer = "NLD003NL0204840", - oepsNummer = "1V51" + oepsNummer = "1234603" // OEPS-Format: 7 Ziffern ), Pferd( id = 4, @@ -137,7 +137,7 @@ object StoreV2 { geburtsdatum = "2007-01-01", besitzer = "Beatrice BĂŒrchler-Keller", lebensnummer = "DE 409090124007", - oepsNummer = "4U89" + oepsNummer = "4567604" // OEPS-Format: 7 Ziffern ), ) @@ -146,10 +146,10 @@ object StoreV2 { id = 1, vorname = "Isabell", nachname = "Werth", - oepsNummer = "O-12345", + oepsNummer = "OEPS-1234567", // OEPS-Format: PrĂ€fix + 7 Ziffern feiId = "10011469", verein = "RFV Graf von Schmettow Eversael", - lizenzKlasse = "RD4", + lizenzKlasse = "RD3", // Höchste Dressur-Lizenz gemĂ€ĂŸ ÖTO (Validierungsregeln.md §3) startkartAktiv = true, startkartSaison = 2026, nation = "GER" @@ -158,10 +158,10 @@ object StoreV2 { id = 2, vorname = "Jessica", nachname = "von Bredow-Werndl", - oepsNummer = "O-54321", + oepsNummer = "OEPS-2345678", feiId = "10019075", verein = "RFV Aubenhausen", - lizenzKlasse = "RD4", + lizenzKlasse = "RD3", startkartAktiv = true, startkartSaison = 2026, nation = "GER" @@ -170,10 +170,10 @@ object StoreV2 { id = 3, vorname = "Charlotte", nachname = "Dujardin", - oepsNummer = "GB-9999", + oepsNummer = "OEPS-3456789", // Hinweis: Internationale Reiter haben ggf. keine OEPS-Nr. — Platzhalter feiId = "10028445", verein = "Rowallan Activity Centre", - lizenzKlasse = "RD4", + lizenzKlasse = "RD3", startkartAktiv = true, startkartSaison = 2026, nation = "GBR" @@ -182,10 +182,10 @@ object StoreV2 { id = 4, vorname = "Stefan", nachname = "Moser", - oepsNummer = "O-44332", + oepsNummer = "OEPS-4456789", feiId = "10011111", verein = "URFV Neumarkt/M.", - lizenzKlasse = "R2D2", + lizenzKlasse = "R2", // GĂŒltige Springen-Lizenz gemĂ€ĂŸ ÖTO (Validierungsregeln.md §3) startkartAktiv = true, startkartSaison = 2026, nation = "AUT",