diff --git a/Tests/SwiftUI-UDF-Tests/Dialog/DialogDSLTests.swift b/Tests/SwiftUI-UDF-Tests/Dialog/DialogDSLTests.swift index 97a41b97..d690f391 100644 --- a/Tests/SwiftUI-UDF-Tests/Dialog/DialogDSLTests.swift +++ b/Tests/SwiftUI-UDF-Tests/Dialog/DialogDSLTests.swift @@ -15,7 +15,7 @@ import Testing // MARK: - DSL Tests @MainActor -extension DialogTests { +extension DialogRegistryTests.DialogTests { @Test("AlertDialog builder creates correct payload and applies properties") func alertBuilderAppliesPropertiesCorrectly() { diff --git a/Tests/SwiftUI-UDF-Tests/Dialog/DialogRegistryTests.swift b/Tests/SwiftUI-UDF-Tests/Dialog/DialogRegistryTests.swift new file mode 100644 index 00000000..db876d41 --- /dev/null +++ b/Tests/SwiftUI-UDF-Tests/Dialog/DialogRegistryTests.swift @@ -0,0 +1,4 @@ +import Testing + +@Suite(.serialized) +struct DialogRegistryTests {} diff --git a/Tests/SwiftUI-UDF-Tests/Dialog/DialogTests.swift b/Tests/SwiftUI-UDF-Tests/Dialog/DialogTests.swift index 8cb54a0f..6b5fa85a 100644 --- a/Tests/SwiftUI-UDF-Tests/Dialog/DialogTests.swift +++ b/Tests/SwiftUI-UDF-Tests/Dialog/DialogTests.swift @@ -56,386 +56,388 @@ extension DialogType { } } -@Suite(.serialized) struct DialogTests { - struct AppState: AppReducer { - var form = FormWithDialog() - } - - struct FormWithDialog: UDF.Form { - enum DialogId: Hashable { - case dialogWithAction - case toastDialog - case customToastWithIcon - case customViewToast +extension DialogRegistryTests { + @Suite(.serialized) struct DialogTests { + struct AppState: AppReducer { + var form = FormWithDialog() } - - var dialog: DialogStatus = .dismissed - - nonisolated mutating func reduce(_ action: some Action) { - switch action { - case is Actions.PresentDialogWithAction: - dialog = .init(id: DialogId.dialogWithAction) - - case is Actions.PresentToastDialog: - dialog = .init(id: DialogId.toastDialog) - - case is Actions.PresentCustomToastWithIcon: - dialog = .init(id: DialogId.customToastWithIcon) - - case is Actions.PresentCustomViewToast: - dialog = .init(id: DialogId.customViewToast) - - default: - break + + struct FormWithDialog: UDF.Form { + enum DialogId: Hashable { + case dialogWithAction + case toastDialog + case customToastWithIcon + case customViewToast + } + + var dialog: DialogStatus = .dismissed + + nonisolated mutating func reduce(_ action: some Action) { + switch action { + case is Actions.PresentDialogWithAction: + dialog = .init(id: DialogId.dialogWithAction) + + case is Actions.PresentToastDialog: + dialog = .init(id: DialogId.toastDialog) + + case is Actions.PresentCustomToastWithIcon: + dialog = .init(id: DialogId.customToastWithIcon) + + case is Actions.PresentCustomViewToast: + dialog = .init(id: DialogId.customViewToast) + + default: + break + } } } - } - - init() { - Dialog.clearAll() - } - - @Test func legacyDialogRegistration_allowsPresentationById() async { - let store = await TestStore(initial: AppState()) - #expect(await store.state.form.dialog.status == .dismissed) - await Dialog.register(id: FormWithDialog.DialogId.dialogWithAction) { - DialogCustomType.custom( - content: .init( - title: "Custom Title", - message: "Custom Desciprion", - actions: { - DialogButton(title: "Cancel") - DialogButton(title: "Primary", role: .destructive) - } - ), - style: .alert - ) + init() { + Dialog.clearAll() } - await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.dialogWithAction) } - await store.dispatch(Actions.PresentDialogWithAction()) - let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } - #expect(success) - } - - @Test func whendialogRegistered_dialogCanBePresentedById() async { - let store = await TestStore(initial: AppState()) - #expect(await store.state.form.dialog.status == .dismissed) - - await Dialog.register(id: FormWithDialog.DialogId.dialogWithAction) { - DialogType.dialogWithAction { - print("Custom dialog action") + + @Test func legacyDialogRegistration_allowsPresentationById() async { + let store = await TestStore(initial: AppState()) + #expect(await store.state.form.dialog.status == .dismissed) + + await Dialog.register(id: FormWithDialog.DialogId.dialogWithAction) { + DialogCustomType.custom( + content: .init( + title: "Custom Title", + message: "Custom Desciprion", + actions: { + DialogButton(title: "Cancel") + DialogButton(title: "Primary", role: .destructive) + } + ), + style: .alert + ) } + await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.dialogWithAction) } + await store.dispatch(Actions.PresentDialogWithAction()) + let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } + #expect(success) } - await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.dialogWithAction) } - - await store.dispatch(Actions.PresentDialogWithAction()) - let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } - #expect(success) - - if case .presented(let dialogType) = await store.state.form.dialog.status { - #expect(dialogType.style == .alert) - } else { - Issue.record("Expected presented dialog") - } - } - - @Test func basicdialogInitializers() { - // Test success dialog - let successdialog = DialogStatus(success: "Operation completed") - #expect(successdialog.status != .dismissed) - - if case .presented(let dialogType) = successdialog.status { - #expect(dialogType.category == .success) - #expect(dialogType.style == .alert) // Default style - } else { - Issue.record("Expected presented dialog") - } - - // Test error dialog - let errordialog = DialogStatus(error: "Something went wrong") - #expect(errordialog.status != .dismissed) - - if case .presented(let dialogType) = errordialog.status { - #expect(dialogType.category == .error) - } else { - Issue.record("Expected presented dialog") - } - - // Test warning dialog - let warningdialog = DialogStatus(warning: "Storage almost full") - #expect(warningdialog.status != .dismissed) - - // Test info dialog - let infodialog = DialogStatus(info: "3 new messages") - #expect(infodialog.status != .dismissed) - } - - @Test func dialogWithToastStyle() { - // Test dialog with toast style - let toastdialog = DialogStatus( - success: "Toast success message", - style: .toast() - ) - - #expect(toastdialog.status != .dismissed) - - if case .presented(let dialogType) = toastdialog.status { - if case .toast = dialogType.style { - #expect(true, "Correct toast style") + + @Test func whendialogRegistered_dialogCanBePresentedById() async { + let store = await TestStore(initial: AppState()) + #expect(await store.state.form.dialog.status == .dismissed) + + await Dialog.register(id: FormWithDialog.DialogId.dialogWithAction) { + DialogType.dialogWithAction { + print("Custom dialog action") + } + } + await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.dialogWithAction) } + + await store.dispatch(Actions.PresentDialogWithAction()) + let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } + #expect(success) + + if case .presented(let dialogType) = await store.state.form.dialog.status { + #expect(dialogType.style == .alert) } else { - Issue.record("Expected toast style dialog") + Issue.record("Expected presented dialog") } - } else { - Issue.record("Expected presented dialog") } - } - - // MARK: - Toast-Specific Tests - @Test func whenToastRegistered_ToastCanBePresentedById() async { - let store = await TestStore(initial: AppState()) - #expect(await store.state.form.dialog.status == .dismissed) - - await Dialog.register(id: FormWithDialog.DialogId.toastDialog) { - DialogType.toastWithAction { - print("Toast action executed") + + @Test func basicdialogInitializers() { + // Test success dialog + let successdialog = DialogStatus(success: "Operation completed") + #expect(successdialog.status != .dismissed) + + if case .presented(let dialogType) = successdialog.status { + #expect(dialogType.category == .success) + #expect(dialogType.style == .alert) // Default style + } else { + Issue.record("Expected presented dialog") } - } - await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.toastDialog) } - - await store.dispatch(Actions.PresentToastDialog()) - let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } - #expect(success) - - // Verify it's a toast style - if case .presented(let dialogType) = await store.state.form.dialog.status { - if case .toast = dialogType.style { - #expect(true, "Correct toast style") + + // Test error dialog + let errordialog = DialogStatus(error: "Something went wrong") + #expect(errordialog.status != .dismissed) + + if case .presented(let dialogType) = errordialog.status { + #expect(dialogType.category == .error) } else { - Issue.record("Expected toast style dialog") + Issue.record("Expected presented dialog") } - } else { - Issue.record("Expected presented dialog") - } - } - - @Test func customToastWithIcon() async { - let store = await TestStore(initial: AppState()) - - await Dialog.register(id: FormWithDialog.DialogId.customToastWithIcon) { - DialogType.customToastWithIcon() + + // Test warning dialog + let warningdialog = DialogStatus(warning: "Storage almost full") + #expect(warningdialog.status != .dismissed) + + // Test info dialog + let infodialog = DialogStatus(info: "3 new messages") + #expect(infodialog.status != .dismissed) } - await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.customToastWithIcon) } - - await store.dispatch(Actions.PresentCustomToastWithIcon()) - let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } - #expect(success) - - // Verify it's a toast with custom icon - if case .presented(let dialogProtocol) = await store.state.form.dialog.status, - let toast = dialogProtocol as? Toast { - - // Check style is toast - if case .toast(let config) = toast.style { - #expect(config.theme == .vibrant) + + @Test func dialogWithToastStyle() { + // Test dialog with toast style + let toastdialog = DialogStatus( + success: "Toast success message", + style: .toast() + ) + + #expect(toastdialog.status != .dismissed) + + if case .presented(let dialogType) = toastdialog.status { + if case .toast = dialogType.style { + #expect(true, "Correct toast style") + } else { + Issue.record("Expected toast style dialog") + } } else { - Issue.record("Expected toast style") + Issue.record("Expected presented dialog") } - - // Check custom icon - #expect(toast.payload.icon != nil) - } else { - Issue.record("Expected custom dialog with content") - } - } - - @Test func customViewToast() async { - let store = await TestStore(initial: AppState()) - - await Dialog.register(id: FormWithDialog.DialogId.customViewToast) { - DialogType.customViewToast() } - await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.customViewToast) } - - await store.dispatch(Actions.PresentCustomViewToast()) - let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } - #expect(success) - - // Verify it's a toast with custom view - if case .presented(let dialogType) = await store.state.form.dialog.status { - // Check style is toast - if case .toast(let config) = dialogType.style { - #expect(config.position == .center) - } else { - Issue.record("Expected toast style") + + // MARK: - Toast-Specific Tests + @Test func whenToastRegistered_ToastCanBePresentedById() async { + let store = await TestStore(initial: AppState()) + #expect(await store.state.form.dialog.status == .dismissed) + + await Dialog.register(id: FormWithDialog.DialogId.toastDialog) { + DialogType.toastWithAction { + print("Toast action executed") + } } - - // Check custom view - #expect(!(dialogType is DialogType)) - await MainActor.run { - #expect(dialogType.getCustomContentView() != nil) + await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.toastDialog) } + + await store.dispatch(Actions.PresentToastDialog()) + let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } + #expect(success) + + // Verify it's a toast style + if case .presented(let dialogType) = await store.state.form.dialog.status { + if case .toast = dialogType.style { + #expect(true, "Correct toast style") + } else { + Issue.record("Expected toast style dialog") + } + } else { + Issue.record("Expected presented dialog") } - } else { - Issue.record("Expected custom dialog with custom view") - } - } - - @Test func toastConfiguration() { - let customConfig = UDF.ToastConfiguration( - theme: .vibrant, - position: .bottom, - defaultDuration: 3.0 - ) - - let dialog = DialogStatus( - error: "Toast with custom config", - style: .toast(customConfig) - ) - - if case .presented(let dialogType) = dialog.status, - let toastConfig = dialogType.toastConfiguration { - #expect(toastConfig.theme == .vibrant) - #expect(toastConfig.position == .bottom) - #expect(toastConfig.defaultDuration == 3.0) - } else { - Issue.record("Expected toast with configuration") } - } - - // MARK: - Complex Content Tests - @MainActor - @Test func customDialogWithContent() { - let dialog = DialogStatus(dialog: AlertDialog { - DialogTitle("Custom Title") - DialogMessage("Custom message") - DialogButton(title: "Delete", role: .destructive) { - print("Delete action") + + @Test func customToastWithIcon() async { + let store = await TestStore(initial: AppState()) + + await Dialog.register(id: FormWithDialog.DialogId.customToastWithIcon) { + DialogType.customToastWithIcon() + } + await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.customToastWithIcon) } + + await store.dispatch(Actions.PresentCustomToastWithIcon()) + let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } + #expect(success) + + // Verify it's a toast with custom icon + if case .presented(let dialogProtocol) = await store.state.form.dialog.status, + let toast = dialogProtocol as? Toast { + + // Check style is toast + if case .toast(let config) = toast.style { + #expect(config.theme == .vibrant) + } else { + Issue.record("Expected toast style") + } + + // Check custom icon + #expect(toast.payload.icon != nil) + } else { + Issue.record("Expected custom dialog with content") } - DialogButton(title: "Cancel", role: .cancel) - }) - - #expect(dialog.status != .dismissed) - - if case .presented(let dialogType) = dialog.status { - #expect(dialogType.title == "Custom Title") - #expect(dialogType.message == "Custom message") - #expect(!dialogType.actions.isEmpty) - #expect(dialogType.actions.count == 2) - } else { - Issue.record("Expected custom dialog") } - } - - @MainActor - @Test func toastWithContentAndActions() { - let dialog = DialogStatus(dialog: Toast { - DialogMessage("Toast message") - DialogButton(title: "Action") { - print("Toast action") + + @Test func customViewToast() async { + let store = await TestStore(initial: AppState()) + + await Dialog.register(id: FormWithDialog.DialogId.customViewToast) { + DialogType.customViewToast() } - }) - - if case .presented(let dialogType) = dialog.status { - if case .toast = dialogType.style { - #expect(true, "Correct toast style") + await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.customViewToast) } + + await store.dispatch(Actions.PresentCustomViewToast()) + let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } + #expect(success) + + // Verify it's a toast with custom view + if case .presented(let dialogType) = await store.state.form.dialog.status { + // Check style is toast + if case .toast(let config) = dialogType.style { + #expect(config.position == .center) + } else { + Issue.record("Expected toast style") + } + + // Check custom view + #expect(!(dialogType is DialogType)) + await MainActor.run { + #expect(dialogType.getCustomContentView() != nil) + } } else { - Issue.record("Expected toast style") + Issue.record("Expected custom dialog with custom view") } - - // Check actions - #expect(!dialogType.actions.isEmpty) - #expect(dialogType.actions.count == 1) - } else { - Issue.record("Expected presented dialog") } - } - - // MARK: - Dismissed State Tests - @Test func dismissedState() { - let dismissedDialog = DialogStatus.dismissed - #expect(dismissedDialog.status == .dismissed) - - let emptyDialog = DialogStatus() - #expect(emptyDialog.status == .dismissed) - - // Test with nil/empty strings - let nilErrorDialog = DialogStatus(error: nil) - #expect(nilErrorDialog.status == .dismissed) - - let emptySuccessDialog = DialogStatus(success: "") - #expect(emptySuccessDialog.status == .dismissed) - } - - // MARK: - Auto-Dismiss Dialog Status Tests - @Test func manualDismissUpdatesDialogStatus() async { - let store = await TestStore(initial: AppState()) - // Register a toast with long duration (won't auto-dismiss during test) - await Dialog.register(id: FormWithDialog.DialogId.toastDialog) { - Toast(config: .init(defaultDuration: 10.0)) { - DialogMessage("This toast should be manually dismissed") + @Test func toastConfiguration() { + let customConfig = UDF.ToastConfiguration( + theme: .vibrant, + position: .bottom, + defaultDuration: 3.0 + ) + + let dialog = DialogStatus( + error: "Toast with custom config", + style: .toast(customConfig) + ) + + if case .presented(let dialogType) = dialog.status, + let toastConfig = dialogType.toastConfiguration { + #expect(toastConfig.theme == .vibrant) + #expect(toastConfig.position == .bottom) + #expect(toastConfig.defaultDuration == 3.0) + } else { + Issue.record("Expected toast with configuration") } } - await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.toastDialog) } - // Present the toast - await store.dispatch(Actions.PresentToastDialog()) - let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } - #expect(success) - - // For manual dismiss, we'll test by creating a dismissed dialog directly - // (This mimics the end result of what should happen when user taps dismiss) - let dismissedDialog = DialogStatus.dismissed - #expect(dismissedDialog.status == .dismissed, "Manual dismiss should result in dismissed state") - - // The main test is that auto-dismiss should achieve the same result - // The real manual dismiss testing is done through UI interactions, not direct state mutation - } - - @Test func zeroDurationToastDoesNotAutoDismiss() async { - let store = await TestStore(initial: AppState()) + // MARK: - Complex Content Tests + @MainActor + @Test func customDialogWithContent() { + let dialog = DialogStatus(dialog: AlertDialog { + DialogTitle("Custom Title") + DialogMessage("Custom message") + DialogButton(title: "Delete", role: .destructive) { + print("Delete action") + } + DialogButton(title: "Cancel", role: .cancel) + }) + + #expect(dialog.status != .dismissed) + + if case .presented(let dialogType) = dialog.status { + #expect(dialogType.title == "Custom Title") + #expect(dialogType.message == "Custom message") + #expect(!dialogType.actions.isEmpty) + #expect(dialogType.actions.count == 2) + } else { + Issue.record("Expected custom dialog") + } + } - // Register a toast with zero duration (manual dismiss only) - await Dialog.register(id: FormWithDialog.DialogId.toastDialog) { - Toast(config: .init(defaultDuration: 0)) { - DialogMessage("This toast should not auto-dismiss") + @MainActor + @Test func toastWithContentAndActions() { + let dialog = DialogStatus(dialog: Toast { + DialogMessage("Toast message") + DialogButton(title: "Action") { + print("Toast action") + } + }) + + if case .presented(let dialogType) = dialog.status { + if case .toast = dialogType.style { + #expect(true, "Correct toast style") + } else { + Issue.record("Expected toast style") + } + + // Check actions + #expect(!dialogType.actions.isEmpty) + #expect(dialogType.actions.count == 1) + } else { + Issue.record("Expected presented dialog") } } - await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.toastDialog) } - // Present the toast - await store.dispatch(Actions.PresentToastDialog()) - let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } - #expect(success) + // MARK: - Dismissed State Tests + @Test func dismissedState() { + let dismissedDialog = DialogStatus.dismissed + #expect(dismissedDialog.status == .dismissed) + + let emptyDialog = DialogStatus() + #expect(emptyDialog.status == .dismissed) + + // Test with nil/empty strings + let nilErrorDialog = DialogStatus(error: nil) + #expect(nilErrorDialog.status == .dismissed) + + let emptySuccessDialog = DialogStatus(success: "") + #expect(emptySuccessDialog.status == .dismissed) + } - // Wait longer than typical auto-dismiss time - await sleep() + // MARK: - Auto-Dismiss Dialog Status Tests + @Test func manualDismissUpdatesDialogStatus() async { + let store = await TestStore(initial: AppState()) + + // Register a toast with long duration (won't auto-dismiss during test) + await Dialog.register(id: FormWithDialog.DialogId.toastDialog) { + Toast(config: .init(defaultDuration: 10.0)) { + DialogMessage("This toast should be manually dismissed") + } + } + await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.toastDialog) } + + // Present the toast + await store.dispatch(Actions.PresentToastDialog()) + let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } + #expect(success) + + // For manual dismiss, we'll test by creating a dismissed dialog directly + // (This mimics the end result of what should happen when user taps dismiss) + let dismissedDialog = DialogStatus.dismissed + #expect(dismissedDialog.status == .dismissed, "Manual dismiss should result in dismissed state") + + // The main test is that auto-dismiss should achieve the same result + // The real manual dismiss testing is done through UI interactions, not direct state mutation + } - // Verify the dialog status is still presented (not auto-dismissed) - #expect(await store.state.form.dialog.status != .dismissed, "Toast with zero duration should not auto-dismiss") - } - - // MARK: - Registry Tests - @MainActor - @Test func registryBehavior() { - let testId = "test-dialog" - - // Test unregistered ID returns dismissed - let unregisteredDialog = DialogStatus(id: testId) - #expect(unregisteredDialog.status == .dismissed) - - // Register and test - Dialog.register(id: testId) { - DialogType.success(message: "Registered dialog", style: .toast()) + @Test func zeroDurationToastDoesNotAutoDismiss() async { + let store = await TestStore(initial: AppState()) + + // Register a toast with zero duration (manual dismiss only) + await Dialog.register(id: FormWithDialog.DialogId.toastDialog) { + Toast(config: .init(defaultDuration: 0)) { + DialogMessage("This toast should not auto-dismiss") + } + } + await waitForCondition { Dialog.isRegistered(id: FormWithDialog.DialogId.toastDialog) } + + // Present the toast + await store.dispatch(Actions.PresentToastDialog()) + let success = await waitForCondition { await store.state.form.dialog.status != .dismissed } + #expect(success) + + // Wait longer than typical auto-dismiss time + await sleep() + + // Verify the dialog status is still presented (not auto-dismissed) + #expect(await store.state.form.dialog.status != .dismissed, "Toast with zero duration should not auto-dismiss") } - - let registeredDialog = DialogStatus(id: testId) - #expect(registeredDialog.status != .dismissed) - - if case .presented(let dialogType) = registeredDialog.status { - #expect(dialogType.category == .success) - } else { - Issue.record("Expected presented dialog from registry") + + // MARK: - Registry Tests + @MainActor + @Test func registryBehavior() { + let testId = "test-dialog" + + // Test unregistered ID returns dismissed + let unregisteredDialog = DialogStatus(id: testId) + #expect(unregisteredDialog.status == .dismissed) + + // Register and test + Dialog.register(id: testId) { + DialogType.success(message: "Registered dialog", style: .toast()) + } + + let registeredDialog = DialogStatus(id: testId) + #expect(registeredDialog.status != .dismissed) + + if case .presented(let dialogType) = registeredDialog.status { + #expect(dialogType.category == .success) + } else { + Issue.record("Expected presented dialog from registry") + } } } } diff --git a/Tests/SwiftUI-UDF-Tests/Dialog/ToastDismissCallbackTests.swift b/Tests/SwiftUI-UDF-Tests/Dialog/ToastDismissCallbackTests.swift index 72e9bee7..aca02800 100644 --- a/Tests/SwiftUI-UDF-Tests/Dialog/ToastDismissCallbackTests.swift +++ b/Tests/SwiftUI-UDF-Tests/Dialog/ToastDismissCallbackTests.swift @@ -3,194 +3,196 @@ import SwiftUI import Testing import UDFSwiftTesting -@Suite(.serialized) struct ToastDismissCallbackTests { - - private class CallbackTracker: @unchecked Sendable { - var executed = false - } - - // MARK: - ToastConfiguration Tests - - @Test func test_ToastConfiguration_DefaultOnAutoDismiss_IsNil() { - let config = ToastConfiguration() - #expect(config.onAutoDismiss == nil) - } - - @Test func test_ToastConfiguration_WithOnAutoDismiss_StoresCallback() { - let tracker = CallbackTracker() - let config = ToastConfiguration(onAutoDismiss: { tracker.executed = true }) - - #expect(config.onAutoDismiss != nil) - - // Test callback execution - config.onAutoDismiss?() - #expect(tracker.executed) - } - - @Test func test_ToastConfiguration_Equatable_WithCallbacks() { - let config1 = ToastConfiguration(onAutoDismiss: { }) - let config2 = ToastConfiguration(onAutoDismiss: { }) - let config3 = ToastConfiguration(onAutoDismiss: nil) - let config4 = ToastConfiguration() - - // Configurations with callbacks should be considered equal if other properties match - // (we only check if callback is nil or not, not the actual callback) - #expect(config1 == config2) - #expect(config3 == config4) - #expect(config1 != config3) - } - - @Test func test_ToastConfiguration_Hashable_WithCallbacks() { - let config1 = ToastConfiguration(onAutoDismiss: { }) - let config2 = ToastConfiguration(onAutoDismiss: nil) - - // Should be able to hash configurations with callbacks - let set: Set = [config1, config2] - #expect(set.count == 2) - } - - // MARK: - DialogRegistration Tests - - @MainActor - @Test func test_DialogRegistry_RegisterToast_WithOnAutoDismiss() async { - let testID = "test-toast-callback" - let tracker = CallbackTracker() - - // Register toast with callback - DialogRegistry.registerToast(id: testID) { - DialogContent("Test Toast with Callback") - } configuration: { - ToastConfiguration(defaultDuration: 0.1) - } onAutoDismiss: { - tracker.executed = true +extension DialogRegistryTests { + @Suite(.serialized) struct ToastDismissCallbackTests { + + private class CallbackTracker: @unchecked Sendable { + var executed = false } - - // Wait for async barrier write to complete - await waitForMainActorCondition { Dialog.isRegistered(id: testID) } - - // Retrieve the registered toast - let retrievedDialog = _DialogRegistry.get(id: testID) - #expect(retrievedDialog != nil) - - // Verify it has the callback in configuration - if let customType = retrievedDialog as? DialogCustomType, - case .custom(_, let style) = customType, - case .toast(let config) = style { + + // MARK: - ToastConfiguration Tests + + @Test func test_ToastConfiguration_DefaultOnAutoDismiss_IsNil() { + let config = ToastConfiguration() + #expect(config.onAutoDismiss == nil) + } + + @Test func test_ToastConfiguration_WithOnAutoDismiss_StoresCallback() { + let tracker = CallbackTracker() + let config = ToastConfiguration(onAutoDismiss: { tracker.executed = true }) + #expect(config.onAutoDismiss != nil) // Test callback execution config.onAutoDismiss?() #expect(tracker.executed) - } else { - #expect(Bool(false), "Retrieved dialog should be DialogCustomType with toast style") } - - // Clean up - Dialog.unregister(id: testID) - } - - @MainActor - @Test func test_DialogRegistry_RegisterToast_WithoutOnAutoDismiss() { - let testID = "test-toast-no-callback" - - // Register toast without callback - DialogRegistry.registerToast(id: testID) { - DialogContent("Test Toast without Callback") + + @Test func test_ToastConfiguration_Equatable_WithCallbacks() { + let config1 = ToastConfiguration(onAutoDismiss: { }) + let config2 = ToastConfiguration(onAutoDismiss: { }) + let config3 = ToastConfiguration(onAutoDismiss: nil) + let config4 = ToastConfiguration() + + // Configurations with callbacks should be considered equal if other properties match + // (we only check if callback is nil or not, not the actual callback) + #expect(config1 == config2) + #expect(config3 == config4) + #expect(config1 != config3) } - - // Retrieve and verify no callback - let retrievedDialog = _DialogRegistry.get(id: testID) - if let customType = retrievedDialog as? DialogCustomType, - case .custom(_, let style) = customType, - case .toast(let config) = style { - #expect(config.onAutoDismiss == nil) - } else { - #expect(Bool(false), "Retrieved dialog should be DialogCustomType with toast style") + + @Test func test_ToastConfiguration_Hashable_WithCallbacks() { + let config1 = ToastConfiguration(onAutoDismiss: { }) + let config2 = ToastConfiguration(onAutoDismiss: nil) + + // Should be able to hash configurations with callbacks + let set: Set = [config1, config2] + #expect(set.count == 2) } - - // Clean up - Dialog.unregister(id: testID) - } - - // MARK: - ToastQueueManager Tests - - @MainActor - @Test func test_ToastQueueManager_ExecutesCallbackOnAutoDismiss() async { - let queueManager = ToastQueueManager() - let tracker = CallbackTracker() - - let config = ToastConfiguration( - defaultDuration: 0.1, - onAutoDismiss: { tracker.executed = true } - ) - let toast = Toast(config: config) { - DialogMessage("Test Toast") + // MARK: - DialogRegistration Tests + + @MainActor + @Test func test_DialogRegistry_RegisterToast_WithOnAutoDismiss() async { + let testID = "test-toast-callback" + let tracker = CallbackTracker() + + // Register toast with callback + DialogRegistry.registerToast(id: testID) { + DialogContent("Test Toast with Callback") + } configuration: { + ToastConfiguration(defaultDuration: 0.1) + } onAutoDismiss: { + tracker.executed = true + } + + // Wait for async barrier write to complete + await waitForMainActorCondition { Dialog.isRegistered(id: testID) } + + // Retrieve the registered toast + let retrievedDialog = _DialogRegistry.get(id: testID) + #expect(retrievedDialog != nil) + + // Verify it has the callback in configuration + if let customType = retrievedDialog as? DialogCustomType, + case .custom(_, let style) = customType, + case .toast(let config) = style { + #expect(config.onAutoDismiss != nil) + + // Test callback execution + config.onAutoDismiss?() + #expect(tracker.executed) + } else { + #expect(Bool(false), "Retrieved dialog should be DialogCustomType with toast style") + } + + // Clean up + Dialog.unregister(id: testID) } - - queueManager.enqueue(toast) - - // Verify toast is visible - #expect(queueManager.visibleToasts.count == 1) - - // Wait for auto-dismiss - await sleep(for: 0.5) - - // Verify callback was executed - #expect(tracker.executed) - } - - @MainActor - @Test func test_ToastQueueManager_ManualDismiss_DoesNotExecuteCallback() { - let queueManager = ToastQueueManager() - let tracker = CallbackTracker() - - let config = ToastConfiguration( - defaultDuration: 10.0, // Long duration - onAutoDismiss: { tracker.executed = true } - ) - let toast = Toast(config: config) { - DialogMessage("Test Toast") + + @MainActor + @Test func test_DialogRegistry_RegisterToast_WithoutOnAutoDismiss() { + let testID = "test-toast-no-callback" + + // Register toast without callback + DialogRegistry.registerToast(id: testID) { + DialogContent("Test Toast without Callback") + } + + // Retrieve and verify no callback + let retrievedDialog = _DialogRegistry.get(id: testID) + if let customType = retrievedDialog as? DialogCustomType, + case .custom(_, let style) = customType, + case .toast(let config) = style { + #expect(config.onAutoDismiss == nil) + } else { + #expect(Bool(false), "Retrieved dialog should be DialogCustomType with toast style") + } + + // Clean up + Dialog.unregister(id: testID) } - - queueManager.enqueue(toast) - - // Get the toast ID for manual dismissal - let toastId = queueManager.visibleToasts.first?.id - #expect(toastId != nil) - - // Manually dismiss the toast - queueManager.dismiss(toastId!) - - // Callback should NOT be executed for manual dismissal - #expect(!tracker.executed) - - // Toast should be removed - #expect(queueManager.visibleToasts.count == 0) - } - - @MainActor - @Test func test_ToastQueueManager_NoCallbackForZeroDuration() async { - let queueManager = ToastQueueManager() - let tracker = CallbackTracker() - - let config = ToastConfiguration( - defaultDuration: 0, // No auto-dismiss - onAutoDismiss: { tracker.executed = true } - ) - let toast = Toast(config: config) { - DialogMessage("Test Toast") + + // MARK: - ToastQueueManager Tests + + @MainActor + @Test func test_ToastQueueManager_ExecutesCallbackOnAutoDismiss() async { + let queueManager = ToastQueueManager() + let tracker = CallbackTracker() + + let config = ToastConfiguration( + defaultDuration: 0.1, + onAutoDismiss: { tracker.executed = true } + ) + + let toast = Toast(config: config) { + DialogMessage("Test Toast") + } + + queueManager.enqueue(toast) + + // Verify toast is visible + #expect(queueManager.visibleToasts.count == 1) + + // Wait for auto-dismiss + await sleep(for: 0.5) + + // Verify callback was executed + #expect(tracker.executed) + } + + @MainActor + @Test func test_ToastQueueManager_ManualDismiss_DoesNotExecuteCallback() { + let queueManager = ToastQueueManager() + let tracker = CallbackTracker() + + let config = ToastConfiguration( + defaultDuration: 10.0, // Long duration + onAutoDismiss: { tracker.executed = true } + ) + let toast = Toast(config: config) { + DialogMessage("Test Toast") + } + + queueManager.enqueue(toast) + + // Get the toast ID for manual dismissal + let toastId = queueManager.visibleToasts.first?.id + #expect(toastId != nil) + + // Manually dismiss the toast + queueManager.dismiss(toastId!) + + // Callback should NOT be executed for manual dismissal + #expect(!tracker.executed) + + // Toast should be removed + #expect(queueManager.visibleToasts.count == 0) + } + + @MainActor + @Test func test_ToastQueueManager_NoCallbackForZeroDuration() async { + let queueManager = ToastQueueManager() + let tracker = CallbackTracker() + + let config = ToastConfiguration( + defaultDuration: 0, // No auto-dismiss + onAutoDismiss: { tracker.executed = true } + ) + let toast = Toast(config: config) { + DialogMessage("Test Toast") + } + + queueManager.enqueue(toast) + + // Wait to ensure no auto-dismiss happens + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + // Verify callback was NOT executed (no auto-dismiss) + #expect(!tracker.executed) + + // Toast should still be visible + #expect(queueManager.visibleToasts.count == 1) } - - queueManager.enqueue(toast) - - // Wait to ensure no auto-dismiss happens - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds - - // Verify callback was NOT executed (no auto-dismiss) - #expect(!tracker.executed) - - // Toast should still be visible - #expect(queueManager.visibleToasts.count == 1) } } diff --git a/Tests/SwiftUI-UDF-Tests/Middlewares/MiddlewareCancellationTests.swift b/Tests/SwiftUI-UDF-Tests/Middlewares/MiddlewareCancellationTests.swift index 2cc02516..4b718eec 100644 --- a/Tests/SwiftUI-UDF-Tests/Middlewares/MiddlewareCancellationTests.swift +++ b/Tests/SwiftUI-UDF-Tests/Middlewares/MiddlewareCancellationTests.swift @@ -93,11 +93,13 @@ import Foundation let store = await TestStore(initial: AppState()) await store.subscribe(ReducibleMiddlewareToCancel.self, environment: ReducibleMiddlewareToCancel.Environment()) await store.dispatch(Actions.Loading()) - var success = await waitForCondition { await store.state.middlewareFlow == .loading } + var success = await store.state.middlewareFlow == .loading #expect(success) await store.dispatch(Actions.CancelLoading()) - success = await waitForCondition { await store.state.middlewareFlow == .none } + await store.wait() + + success = await store.state.middlewareFlow == .none #expect(success) } @@ -106,7 +108,7 @@ import Foundation await store.subscribe(ObservableMiddlewareToCancel.self, environment: ()) await store.dispatch(Actions.Loading()) - var success = await waitForCondition { await store.state.middlewareFlow == .loading } + var success = await store.state.middlewareFlow == .loading #expect(success) await withTaskGroup(of: Void.self) { group in for _ in 0..<10 { @@ -116,7 +118,7 @@ import Foundation } } let acceptableFlowState: [MiddlewareFlow] = [.none, .cancel] - success = await waitForCondition { acceptableFlowState.contains(await store.state.middlewareFlow) } + success = acceptableFlowState.contains(await store.state.middlewareFlow) #expect(success) await store.wait()