### 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:
2026-04-21 21:26:02 +02:00
parent 3f4ba9eea9
commit 9195cdb14d
6 changed files with 138 additions and 32 deletions
@@ -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. - `EventWizardScreen`: Der eingebettete `StammdatenImportScreen` nutzt nun `reEvaluateCurrentStep()`, um einen nahtlosen Übergang nach dem Import zu ermöglichen.
- **Stabilität:** - **Stabilität:**
- Erfolgreiche Kompilierung des Desktop-Shell-Moduls (`:frontend:shells:meldestelle-desktop`). - 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 ## Verifikation
- **Gradle:** `./gradlew :frontend:shells:meldestelle-desktop:compileKotlinJvm` läuft fehlerfrei durch. - **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. 3. Optimierung der ZNS-Importer-Performance für große Datensätze.
🏗️ [Lead Architect] | 👷 [Backend Developer] | 🎨 [Frontend Expert] | 🧹 [Curator] 🏗️ [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.
@@ -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.dsl.flow
import at.mocode.frontend.core.wizard.runtime.Guard import at.mocode.frontend.core.wizard.runtime.Guard
import at.mocode.frontend.core.wizard.runtime.StepId import at.mocode.frontend.core.wizard.runtime.StepId
import at.mocode.frontend.core.wizard.runtime.WizardContext
import at.mocode.frontend.core.wizard.runtime.WizardState import at.mocode.frontend.core.wizard.runtime.WizardState
// Minimaler, selbstenthaltener Demo-Flow (2 Steps) für den Spike // Minimaler, selbstenthaltener Demo-Flow (2 Steps) für den Spike
@@ -35,6 +36,10 @@ object DemoEventGuards {
val needsContactPerson: Guard<DemoEventStep, DemoEventAcc> = { _, acc -> val needsContactPerson: Guard<DemoEventStep, DemoEventAcc> = { _, acc ->
acc.veranstalterId == null || acc.veranstalterNr.startsWith("ORG-") acc.veranstalterId == null || acc.veranstalterNr.startsWith("ORG-")
} }
val hasSelectedVeranstalter: Guard<DemoEventStep, DemoEventAcc> = { _, acc ->
!acc.veranstalterId.isNullOrBlank()
}
} }
val DemoEventFlow = flow<DemoEventStep, DemoEventAcc>(start = DemoEventStep.ZnsCheck) { val DemoEventFlow = flow<DemoEventStep, DemoEventAcc>(start = DemoEventStep.ZnsCheck) {
@@ -43,11 +48,33 @@ val DemoEventFlow = flow<DemoEventStep, DemoEventAcc>(start = DemoEventStep.ZnsC
otherwise(DemoEventStep.VeranstalterSelection) otherwise(DemoEventStep.VeranstalterSelection)
} }
step(DemoEventStep.VeranstalterSelection) { step(DemoEventStep.VeranstalterSelection) {
whenGuard("notSelected", { ctx, acc -> !DemoEventGuards.hasSelectedVeranstalter(ctx, acc) }, go = DemoEventStep.VeranstalterSelection)
whenGuard("needsContactPerson", DemoEventGuards.needsContactPerson, go = DemoEventStep.AnsprechpersonMapping) whenGuard("needsContactPerson", DemoEventGuards.needsContactPerson, go = DemoEventStep.AnsprechpersonMapping)
otherwise(DemoEventStep.MetaData) 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 // Hilfsfunktion für einfache manuelle Nutzung im Spike
fun demoStartState(origin: AppScreen, acc: DemoEventAcc = DemoEventAcc()): WizardState<DemoEventStep, DemoEventAcc> = fun demoStartState(origin: AppScreen, acc: DemoEventAcc = DemoEventAcc()): WizardState<DemoEventStep, DemoEventAcc> =
WizardState(current = DemoEventStep.ZnsCheck, acc = acc) WizardState(current = DemoEventStep.ZnsCheck, acc = acc)
@@ -2,7 +2,9 @@ package at.mocode.frontend.core.wizard
import at.mocode.frontend.core.navigation.AppScreen import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.wizard.runtime.WizardContext 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -13,7 +15,7 @@ class WizardRuntimeTest {
origin = AppScreen.Home, origin = AppScreen.Home,
isOnline = true, isOnline = true,
stats = at.mocode.frontend.core.domain.repository.MasterdataStats( stats = at.mocode.frontend.core.domain.repository.MasterdataStats(
lastImport = null, lastImport = "2026-04-21",
vereinCount = 1, vereinCount = 1,
reiterCount = 0, reiterCount = 0,
pferdCount = 0, pferdCount = 0,
@@ -32,7 +34,7 @@ class WizardRuntimeTest {
origin = AppScreen.Home, origin = AppScreen.Home,
isOnline = true, isOnline = true,
stats = at.mocode.frontend.core.domain.repository.MasterdataStats( stats = at.mocode.frontend.core.domain.repository.MasterdataStats(
lastImport = null, lastImport = "2026-04-21",
vereinCount = 1, vereinCount = 1,
reiterCount = 0, reiterCount = 0,
pferdCount = 0, pferdCount = 0,
@@ -3,10 +3,7 @@ package at.mocode.frontend.core.wizard
import at.mocode.frontend.core.domain.repository.MasterdataStats import at.mocode.frontend.core.domain.repository.MasterdataStats
import at.mocode.frontend.core.navigation.AppScreen import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.wizard.runtime.WizardContext import at.mocode.frontend.core.wizard.runtime.WizardContext
import at.mocode.frontend.core.wizard.samples.DemoEventAcc 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -17,7 +14,7 @@ class WizardRuntimeJvmTest {
origin = AppScreen.Home, origin = AppScreen.Home,
isOnline = true, isOnline = true,
stats = MasterdataStats( stats = MasterdataStats(
lastImport = null, lastImport = "2026-04-21",
vereinCount = 1, vereinCount = 1,
reiterCount = 0, reiterCount = 0,
pferdCount = 0, pferdCount = 0,
@@ -25,7 +22,7 @@ class WizardRuntimeJvmTest {
) )
) )
val state0 = demoStartState(AppScreen.Home) val state0 = demoStartState(AppScreen.Home)
val state1 = DemoEventFlow.next(ctx, state0) val state1 = DemoEventFlowNextManual(ctx, state0)
assertEquals(DemoEventStep.VeranstalterSelection, state1.current) assertEquals(DemoEventStep.VeranstalterSelection, state1.current)
assertEquals(listOf(DemoEventStep.ZnsCheck), state1.history) assertEquals(listOf(DemoEventStep.ZnsCheck), state1.history)
} }
@@ -36,7 +33,7 @@ class WizardRuntimeJvmTest {
origin = AppScreen.Home, origin = AppScreen.Home,
isOnline = true, isOnline = true,
stats = MasterdataStats( stats = MasterdataStats(
lastImport = null, lastImport = "2026-04-21",
vereinCount = 1, vereinCount = 1,
reiterCount = 0, reiterCount = 0,
pferdCount = 0, pferdCount = 0,
@@ -44,7 +41,7 @@ class WizardRuntimeJvmTest {
) )
) )
val s0 = demoStartState(AppScreen.Home) val s0 = demoStartState(AppScreen.Home)
val s1 = DemoEventFlow.next(ctx, s0) val s1 = DemoEventFlowNextManual(ctx, s0)
val s2 = DemoEventFlow.back(s1) val s2 = DemoEventFlow.back(s1)
assertEquals(DemoEventStep.ZnsCheck, s2.current) assertEquals(DemoEventStep.ZnsCheck, s2.current)
} }
@@ -52,6 +49,47 @@ class WizardRuntimeJvmTest {
@Test @Test
fun selection_goes_to_ansprechperson_when_guard_true_by_default() { fun selection_goes_to_ansprechperson_when_guard_true_by_default() {
val ctx = WizardContext( 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, origin = AppScreen.Home,
isOnline = true, isOnline = true,
stats = MasterdataStats( stats = MasterdataStats(
@@ -63,9 +101,13 @@ class WizardRuntimeJvmTest {
) )
) )
val s0 = demoStartState(AppScreen.Home) val s0 = demoStartState(AppScreen.Home)
val s1 = DemoEventFlow.next(ctx, s0) // -> VeranstalterSelection val s1 = DemoEventFlowNextManual(ctxNoImport, s0)
val s2 = DemoEventFlow.next(ctx, s1) // Sollte auf ZnsCheck bleiben, da hasZns jetzt lastImport prüft
assertEquals(DemoEventStep.AnsprechpersonMapping, s2.current) 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 @Test
@@ -74,7 +116,7 @@ class WizardRuntimeJvmTest {
origin = AppScreen.Home, origin = AppScreen.Home,
isOnline = true, isOnline = true,
stats = MasterdataStats( stats = MasterdataStats(
lastImport = null, lastImport = "2026-04-21",
vereinCount = 1, vereinCount = 1,
reiterCount = 0, reiterCount = 0,
pferdCount = 0, pferdCount = 0,
@@ -88,7 +130,8 @@ class WizardRuntimeJvmTest {
history = listOf(DemoEventStep.ZnsCheck), history = listOf(DemoEventStep.ZnsCheck),
acc = acc 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) assertEquals(DemoEventStep.MetaData, s2.current)
} }
} }
@@ -317,6 +317,7 @@ private fun VeranstalterSelectionStep(
Text("Schritt 2: Veranstalter auswählen", style = MaterialTheme.typography.titleLarge) Text("Schritt 2: Veranstalter auswählen", style = MaterialTheme.typography.titleLarge)
Text("Suchen Sie nach dem Verein (Name oder OEPS-Nummer).") Text("Suchen Sie nach dem Verein (Name oder OEPS-Nummer).")
Box(modifier = Modifier.fillMaxWidth()) {
MsTextField( MsTextField(
value = searchQuery, value = searchQuery,
onValueChange = { onValueChange = {
@@ -327,8 +328,20 @@ private fun VeranstalterSelectionStep(
}, },
label = "Verein suchen (z.B. 6-009)", label = "Verein suchen (z.B. 6-009)",
placeholder = "OEPS-Nummer eingeben...", 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) { if (viewModel.state.veranstalterId != null) {
Card( Card(
@@ -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.runtime.WizardState
import at.mocode.frontend.core.wizard.samples.DemoEventAcc import at.mocode.frontend.core.wizard.samples.DemoEventAcc
import at.mocode.frontend.core.wizard.samples.DemoEventFlow 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.core.wizard.samples.DemoEventStep
import at.mocode.frontend.features.turnier.presentation.TurnierWizardViewModel import at.mocode.frontend.features.turnier.presentation.TurnierWizardViewModel
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
@@ -163,8 +164,10 @@ class EventWizardViewModel(
} }
fun searchVeranstalterByOepsNr(oepsNr: String) { fun searchVeranstalterByOepsNr(oepsNr: String) {
if (oepsNr.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
try { try {
state = state.copy(isCheckingStats = true)
val verein = vereinRepository.findByOepsNr(oepsNr) val verein = vereinRepository.findByOepsNr(oepsNr)
if (verein != null) { if (verein != null) {
// Robustes Parsing für Mock-Daten (z. B. "v1") // Robustes Parsing für Mock-Daten (z. B. "v1")
@@ -182,13 +185,16 @@ class EventWizardViewModel(
standardOrt = "${verein.plz ?: ""} ${verein.ort ?: ""}".trim(), standardOrt = "${verein.plz ?: ""} ${verein.ort ?: ""}".trim(),
logo = null logo = null
) )
state = state.copy(isCheckingStats = false, znsSearchResults = emptyList())
} else if (oepsNr.length >= 3) { } else if (oepsNr.length >= 3) {
// Suche in den ZNS-Stammdaten als Fallback // Suche in den ZNS-Stammdaten als Fallback
znsImportProvider.searchRemote(oepsNr) 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) { } 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)) { if (WizardFeatureFlags.WizardRuntimeEnabled && isHandledByRuntime(state.currentStep)) {
val ctx = buildWizardContext() val ctx = buildWizardContext()
val currentRuntimeState = ensureWizardStateInitialized() val currentRuntimeState = ensureWizardStateInitialized()
val next = DemoEventFlow.next(ctx, currentRuntimeState) val next = DemoEventFlowNextManual(ctx, currentRuntimeState)
wizardState = next wizardState = next
val mapped = mapToWizardStep(next.current) val mapped = mapToWizardStep(next.current)
if (mapped != null) { if (mapped != null) {