### feat: verbessere Wizard-Validierung und UI-Feedback
- Integriere Fortschrittsanzeige während der Veranstalter-Suche (`isCheckingStats`). - Zeige Fehlermeldungen bei Suchfehlern im `EventWizardScreen`. - Füge `hasSelectedVeranstalter`-Guard und zugehörige Tests hinzu. - Präzisiere `DemoEventFlow` mit expliziter Guard-Logik. - Aktualisiere Unit-Tests zur Abdeckung neuer Guard-Szenarien.
This commit is contained in:
@@ -16,7 +16,9 @@ In dieser Session wurde die Brücke zwischen der Wizard-Runtime und dem ZNS-Impo
|
||||
- `EventWizardScreen`: Der eingebettete `StammdatenImportScreen` nutzt nun `reEvaluateCurrentStep()`, um einen nahtlosen Übergang nach dem Import zu ermöglichen.
|
||||
- **Stabilität:**
|
||||
- Erfolgreiche Kompilierung des Desktop-Shell-Moduls (`:frontend:shells:meldestelle-desktop`).
|
||||
- Wizard-Unit-Tests bleiben grün (9/9).
|
||||
- Wizard-Unit-Tests (JVM/Common) sind vollständig grün (10/10).
|
||||
- **Fehlerbehebung (QA):**
|
||||
- Korrektur von `WizardRuntimeJvmTest.selection_goes_to_ansprechperson_when_guard_true_by_default`, der aufgrund unvollständiger Testdaten (fehlendes `ORG-` Präfix) fehlschlug.
|
||||
|
||||
## Verifikation
|
||||
- **Gradle:** `./gradlew :frontend:shells:meldestelle-desktop:compileKotlinJvm` läuft fehlerfrei durch.
|
||||
@@ -28,3 +30,16 @@ In dieser Session wurde die Brücke zwischen der Wizard-Runtime und dem ZNS-Impo
|
||||
3. Optimierung der ZNS-Importer-Performance für große Datensätze.
|
||||
|
||||
🏗️ [Lead Architect] | 👷 [Backend Developer] | 🎨 [Frontend Expert] | 🧹 [Curator]
|
||||
|
||||
## Repository-Strategie & Veranstalter-Validierung
|
||||
Um die Veranstalter-Wahl im Wizard robust zu gestalten, nutzen wir eine zweistufige Repository-Strategie:
|
||||
|
||||
1. **`VereinRepository` (Lokal):** Primäre Quelle für bereits bekannte oder manuell angelegte Vereine. Wenn ein Verein hier gefunden wird, gilt er als "validiert" für den Wizard.
|
||||
2. **`MasterdataRepository` / `ZnsImportProvider` (Global/Remote):** Fallback-Quelle. Falls die OEPS-Nummer lokal unbekannt ist, wird in den (offline verfügbaren) ZNS-Stammdaten gesucht. Ein Fund hier führt zur Übernahme der Daten in den lokalen Kontext.
|
||||
|
||||
### Wizard-Guards
|
||||
- **`hasZns`:** Stellt sicher, dass überhaupt Stammdaten vorhanden sind (Initial-Check).
|
||||
- **`hasSelectedVeranstalter`:** Verhindert das Voranschreiten im Wizard, solange kein gültiger Veranstalter (lokale ID) ausgewählt wurde.
|
||||
- **`needsContactPerson`:** Dynamische Weiche; erzwingt die manuelle Eingabe einer Ansprechperson, wenn der Veranstalter-Datensatz unvollständig ist (z.B. neue ZNS-Importe ohne hinterlegte Kontaktperson).
|
||||
|
||||
Diese Strategie sichert die Datenqualität beim Erstellen neuer Veranstaltungen, während sie dem User maximale Flexibilität (Import vs. Neuanlage) bietet.
|
||||
|
||||
+27
@@ -4,6 +4,7 @@ import at.mocode.frontend.core.navigation.AppScreen
|
||||
import at.mocode.frontend.core.wizard.dsl.flow
|
||||
import at.mocode.frontend.core.wizard.runtime.Guard
|
||||
import at.mocode.frontend.core.wizard.runtime.StepId
|
||||
import at.mocode.frontend.core.wizard.runtime.WizardContext
|
||||
import at.mocode.frontend.core.wizard.runtime.WizardState
|
||||
|
||||
// Minimaler, selbstenthaltener Demo-Flow (2 Steps) für den Spike
|
||||
@@ -35,6 +36,10 @@ object DemoEventGuards {
|
||||
val needsContactPerson: Guard<DemoEventStep, DemoEventAcc> = { _, acc ->
|
||||
acc.veranstalterId == null || acc.veranstalterNr.startsWith("ORG-")
|
||||
}
|
||||
|
||||
val hasSelectedVeranstalter: Guard<DemoEventStep, DemoEventAcc> = { _, acc ->
|
||||
!acc.veranstalterId.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
|
||||
val DemoEventFlow = flow<DemoEventStep, DemoEventAcc>(start = DemoEventStep.ZnsCheck) {
|
||||
@@ -43,11 +48,33 @@ val DemoEventFlow = flow<DemoEventStep, DemoEventAcc>(start = DemoEventStep.ZnsC
|
||||
otherwise(DemoEventStep.VeranstalterSelection)
|
||||
}
|
||||
step(DemoEventStep.VeranstalterSelection) {
|
||||
whenGuard("notSelected", { ctx, acc -> !DemoEventGuards.hasSelectedVeranstalter(ctx, acc) }, go = DemoEventStep.VeranstalterSelection)
|
||||
whenGuard("needsContactPerson", DemoEventGuards.needsContactPerson, go = DemoEventStep.AnsprechpersonMapping)
|
||||
otherwise(DemoEventStep.MetaData)
|
||||
}
|
||||
}
|
||||
|
||||
// Hilfsfunktion, um Guard-Logik explizit zu erzwingen, da die Runtime sonst bei "go = current" abbricht.
|
||||
fun DemoEventFlowNextManual(ctx: WizardContext, state: WizardState<DemoEventStep, DemoEventAcc>): WizardState<DemoEventStep, DemoEventAcc> {
|
||||
if (state.current == DemoEventStep.ZnsCheck) {
|
||||
return if (DemoEventGuards.hasZns(ctx, state.acc)) {
|
||||
state.copy(current = DemoEventStep.VeranstalterSelection, history = state.history + state.current)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
if (state.current == DemoEventStep.VeranstalterSelection) {
|
||||
if (!DemoEventGuards.hasSelectedVeranstalter(ctx, state.acc)) return state
|
||||
|
||||
return if (DemoEventGuards.needsContactPerson(ctx, state.acc)) {
|
||||
state.copy(current = DemoEventStep.AnsprechpersonMapping, history = state.history + state.current)
|
||||
} else {
|
||||
state.copy(current = DemoEventStep.MetaData, history = state.history + state.current)
|
||||
}
|
||||
}
|
||||
return DemoEventFlow.next(ctx, state)
|
||||
}
|
||||
|
||||
// Hilfsfunktion für einfache manuelle Nutzung im Spike
|
||||
fun demoStartState(origin: AppScreen, acc: DemoEventAcc = DemoEventAcc()): WizardState<DemoEventStep, DemoEventAcc> =
|
||||
WizardState(current = DemoEventStep.ZnsCheck, acc = acc)
|
||||
|
||||
+5
-3
@@ -2,7 +2,9 @@ package at.mocode.frontend.core.wizard
|
||||
|
||||
import at.mocode.frontend.core.navigation.AppScreen
|
||||
import at.mocode.frontend.core.wizard.runtime.WizardContext
|
||||
import at.mocode.frontend.core.wizard.samples.*
|
||||
import at.mocode.frontend.core.wizard.samples.DemoEventFlow
|
||||
import at.mocode.frontend.core.wizard.samples.DemoEventStep
|
||||
import at.mocode.frontend.core.wizard.samples.demoStartState
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@@ -13,7 +15,7 @@ class WizardRuntimeTest {
|
||||
origin = AppScreen.Home,
|
||||
isOnline = true,
|
||||
stats = at.mocode.frontend.core.domain.repository.MasterdataStats(
|
||||
lastImport = null,
|
||||
lastImport = "2026-04-21",
|
||||
vereinCount = 1,
|
||||
reiterCount = 0,
|
||||
pferdCount = 0,
|
||||
@@ -32,7 +34,7 @@ class WizardRuntimeTest {
|
||||
origin = AppScreen.Home,
|
||||
isOnline = true,
|
||||
stats = at.mocode.frontend.core.domain.repository.MasterdataStats(
|
||||
lastImport = null,
|
||||
lastImport = "2026-04-21",
|
||||
vereinCount = 1,
|
||||
reiterCount = 0,
|
||||
pferdCount = 0,
|
||||
|
||||
+56
-13
@@ -3,10 +3,7 @@ package at.mocode.frontend.core.wizard
|
||||
import at.mocode.frontend.core.domain.repository.MasterdataStats
|
||||
import at.mocode.frontend.core.navigation.AppScreen
|
||||
import at.mocode.frontend.core.wizard.runtime.WizardContext
|
||||
import at.mocode.frontend.core.wizard.samples.DemoEventAcc
|
||||
import at.mocode.frontend.core.wizard.samples.DemoEventFlow
|
||||
import at.mocode.frontend.core.wizard.samples.DemoEventStep
|
||||
import at.mocode.frontend.core.wizard.samples.demoStartState
|
||||
import at.mocode.frontend.core.wizard.samples.*
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@@ -17,7 +14,7 @@ class WizardRuntimeJvmTest {
|
||||
origin = AppScreen.Home,
|
||||
isOnline = true,
|
||||
stats = MasterdataStats(
|
||||
lastImport = null,
|
||||
lastImport = "2026-04-21",
|
||||
vereinCount = 1,
|
||||
reiterCount = 0,
|
||||
pferdCount = 0,
|
||||
@@ -25,7 +22,7 @@ class WizardRuntimeJvmTest {
|
||||
)
|
||||
)
|
||||
val state0 = demoStartState(AppScreen.Home)
|
||||
val state1 = DemoEventFlow.next(ctx, state0)
|
||||
val state1 = DemoEventFlowNextManual(ctx, state0)
|
||||
assertEquals(DemoEventStep.VeranstalterSelection, state1.current)
|
||||
assertEquals(listOf(DemoEventStep.ZnsCheck), state1.history)
|
||||
}
|
||||
@@ -36,7 +33,7 @@ class WizardRuntimeJvmTest {
|
||||
origin = AppScreen.Home,
|
||||
isOnline = true,
|
||||
stats = MasterdataStats(
|
||||
lastImport = null,
|
||||
lastImport = "2026-04-21",
|
||||
vereinCount = 1,
|
||||
reiterCount = 0,
|
||||
pferdCount = 0,
|
||||
@@ -44,7 +41,7 @@ class WizardRuntimeJvmTest {
|
||||
)
|
||||
)
|
||||
val s0 = demoStartState(AppScreen.Home)
|
||||
val s1 = DemoEventFlow.next(ctx, s0)
|
||||
val s1 = DemoEventFlowNextManual(ctx, s0)
|
||||
val s2 = DemoEventFlow.back(s1)
|
||||
assertEquals(DemoEventStep.ZnsCheck, s2.current)
|
||||
}
|
||||
@@ -52,6 +49,47 @@ class WizardRuntimeJvmTest {
|
||||
@Test
|
||||
fun selection_goes_to_ansprechperson_when_guard_true_by_default() {
|
||||
val ctx = WizardContext(
|
||||
origin = AppScreen.Home,
|
||||
isOnline = true,
|
||||
stats = MasterdataStats(
|
||||
lastImport = "2026-04-21",
|
||||
vereinCount = 1,
|
||||
reiterCount = 0,
|
||||
pferdCount = 0,
|
||||
funktionaerCount = 0
|
||||
)
|
||||
)
|
||||
val s0 = demoStartState(AppScreen.Home)
|
||||
val s1 = DemoEventFlowNextManual(ctx, s0) // -> VeranstalterSelection
|
||||
// Jetzt muss ein Veranstalter gesetzt sein, sonst geht es nicht weiter (notSelected Guard)
|
||||
val s1WithAcc = s1.copy(acc = s1.acc.copy(veranstalterId = "v1", veranstalterNr = "ORG-123"))
|
||||
val s2 = DemoEventFlowNextManual(ctx, s1WithAcc)
|
||||
assertEquals(DemoEventStep.AnsprechpersonMapping, s2.current)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selection_stays_on_selection_when_no_veranstalter_selected() {
|
||||
val ctx = WizardContext(
|
||||
origin = AppScreen.Home,
|
||||
isOnline = true,
|
||||
stats = MasterdataStats(
|
||||
lastImport = "2026-04-21",
|
||||
vereinCount = 1,
|
||||
reiterCount = 0,
|
||||
pferdCount = 0,
|
||||
funktionaerCount = 0
|
||||
)
|
||||
)
|
||||
val s0 = demoStartState(AppScreen.Home)
|
||||
val s1 = DemoEventFlowNextManual(ctx, s0) // -> VeranstalterSelection
|
||||
val s2 = DemoEventFlowNextManual(ctx, s1)
|
||||
// Sollte auf VeranstalterSelection bleiben, da hasSelectedVeranstalter false ist
|
||||
assertEquals(DemoEventStep.VeranstalterSelection, s2.current)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun next_goes_to_selection_only_if_lastImport_exists() {
|
||||
val ctxNoImport = WizardContext(
|
||||
origin = AppScreen.Home,
|
||||
isOnline = true,
|
||||
stats = MasterdataStats(
|
||||
@@ -63,9 +101,13 @@ class WizardRuntimeJvmTest {
|
||||
)
|
||||
)
|
||||
val s0 = demoStartState(AppScreen.Home)
|
||||
val s1 = DemoEventFlow.next(ctx, s0) // -> VeranstalterSelection
|
||||
val s2 = DemoEventFlow.next(ctx, s1)
|
||||
assertEquals(DemoEventStep.AnsprechpersonMapping, s2.current)
|
||||
val s1 = DemoEventFlowNextManual(ctxNoImport, s0)
|
||||
// Sollte auf ZnsCheck bleiben, da hasZns jetzt lastImport prüft
|
||||
assertEquals(DemoEventStep.ZnsCheck, s1.current)
|
||||
|
||||
val ctxWithImport = ctxNoImport.copy(stats = ctxNoImport.stats?.copy(lastImport = "2026-04-21"))
|
||||
val s2 = DemoEventFlowNextManual(ctxWithImport, s0)
|
||||
assertEquals(DemoEventStep.VeranstalterSelection, s2.current)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -74,7 +116,7 @@ class WizardRuntimeJvmTest {
|
||||
origin = AppScreen.Home,
|
||||
isOnline = true,
|
||||
stats = MasterdataStats(
|
||||
lastImport = null,
|
||||
lastImport = "2026-04-21",
|
||||
vereinCount = 1,
|
||||
reiterCount = 0,
|
||||
pferdCount = 0,
|
||||
@@ -88,7 +130,8 @@ class WizardRuntimeJvmTest {
|
||||
history = listOf(DemoEventStep.ZnsCheck),
|
||||
acc = acc
|
||||
)
|
||||
val s2 = DemoEventFlow.next(ctx, s1)
|
||||
val s2 = DemoEventFlowNextManual(ctx, s1)
|
||||
// Da veranstalterId gesetzt und keine "ORG-" Nummer -> MetaData (needsContactPerson false)
|
||||
assertEquals(DemoEventStep.MetaData, s2.current)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -317,6 +317,7 @@ private fun VeranstalterSelectionStep(
|
||||
Text("Schritt 2: Veranstalter auswählen", style = MaterialTheme.typography.titleLarge)
|
||||
Text("Suchen Sie nach dem Verein (Name oder OEPS-Nummer).")
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
MsTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = {
|
||||
@@ -327,8 +328,20 @@ private fun VeranstalterSelectionStep(
|
||||
},
|
||||
label = "Verein suchen (z.B. 6-009)",
|
||||
placeholder = "OEPS-Nummer eingeben...",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !viewModel.state.isCheckingStats
|
||||
)
|
||||
if (viewModel.state.isCheckingStats) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 12.dp).size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.state.error != null) {
|
||||
Text(viewModel.state.error!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
if (viewModel.state.veranstalterId != null) {
|
||||
Card(
|
||||
|
||||
+9
-3
@@ -19,6 +19,7 @@ import at.mocode.frontend.core.wizard.runtime.WizardContext
|
||||
import at.mocode.frontend.core.wizard.runtime.WizardState
|
||||
import at.mocode.frontend.core.wizard.samples.DemoEventAcc
|
||||
import at.mocode.frontend.core.wizard.samples.DemoEventFlow
|
||||
import at.mocode.frontend.core.wizard.samples.DemoEventFlowNextManual
|
||||
import at.mocode.frontend.core.wizard.samples.DemoEventStep
|
||||
import at.mocode.frontend.features.turnier.presentation.TurnierWizardViewModel
|
||||
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
|
||||
@@ -163,8 +164,10 @@ class EventWizardViewModel(
|
||||
}
|
||||
|
||||
fun searchVeranstalterByOepsNr(oepsNr: String) {
|
||||
if (oepsNr.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
state = state.copy(isCheckingStats = true)
|
||||
val verein = vereinRepository.findByOepsNr(oepsNr)
|
||||
if (verein != null) {
|
||||
// Robustes Parsing für Mock-Daten (z. B. "v1")
|
||||
@@ -182,13 +185,16 @@ class EventWizardViewModel(
|
||||
standardOrt = "${verein.plz ?: ""} ${verein.ort ?: ""}".trim(),
|
||||
logo = null
|
||||
)
|
||||
state = state.copy(isCheckingStats = false, znsSearchResults = emptyList())
|
||||
} else if (oepsNr.length >= 3) {
|
||||
// Suche in den ZNS-Stammdaten als Fallback
|
||||
znsImportProvider.searchRemote(oepsNr)
|
||||
state = state.copy(znsSearchResults = znsImportProvider.state.remoteResults)
|
||||
state = state.copy(isCheckingStats = false, znsSearchResults = znsImportProvider.state.remoteResults)
|
||||
} else {
|
||||
state = state.copy(isCheckingStats = false)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
state = state.copy(error = "Fehler bei der Veranstalter-Suche: ${e.message}")
|
||||
state = state.copy(isCheckingStats = false, error = "Fehler bei der Veranstalter-Suche: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,7 +213,7 @@ class EventWizardViewModel(
|
||||
if (WizardFeatureFlags.WizardRuntimeEnabled && isHandledByRuntime(state.currentStep)) {
|
||||
val ctx = buildWizardContext()
|
||||
val currentRuntimeState = ensureWizardStateInitialized()
|
||||
val next = DemoEventFlow.next(ctx, currentRuntimeState)
|
||||
val next = DemoEventFlowNextManual(ctx, currentRuntimeState)
|
||||
wizardState = next
|
||||
val mapped = mapToWizardStep(next.current)
|
||||
if (mapped != null) {
|
||||
|
||||
Reference in New Issue
Block a user