### 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
@@ -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)
@@ -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,
@@ -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)
}
}