diff --git a/Examples/AdvancedPatterns/MultiStepWizard/MultiStepWizardFeature.swift b/Examples/AdvancedPatterns/MultiStepWizard/MultiStepWizardFeature.swift index 724697e..092be43 100644 --- a/Examples/AdvancedPatterns/MultiStepWizard/MultiStepWizardFeature.swift +++ b/Examples/AdvancedPatterns/MultiStepWizard/MultiStepWizardFeature.swift @@ -1,6 +1,21 @@ import Flow import Foundation +/// Application-specific errors for wizard operations. +enum WizardError: Error, LocalizedError { + case validationFailed(step: String, errors: [String]) + case submissionFailed(underlying: Error) + + var errorDescription: String? { + switch self { + case .validationFailed(let step, let errors): + return "Validation failed in \(step): \(errors.joined(separator: ", "))" + case .submissionFailed(let underlying): + return "Submission failed: \(underlying.localizedDescription)" + } + } +} + /// Multi-step wizard/form with complex validation and navigation. /// /// Demonstrates: @@ -39,7 +54,7 @@ struct MultiStepWizardFeature: Feature { // UI state var isValidating = false var isSubmitting = false - var error: FlowError? + var error: WizardError? // Validation errors per step var personalInfoErrors: [String] = [] @@ -219,15 +234,15 @@ struct MultiStepWizardFeature: Feature { // Reset wizard after successful submission resetWizard(state: state) } catch { - throw FlowError.networkError(underlying: error) + throw WizardError.submissionFailed(underlying: error) } } .catch { error, state in state.isSubmitting = false - if let vfError = error as? FlowError { - state.error = vfError + if let wizardError = error as? WizardError { + state.error = wizardError } else { - state.error = .networkError(underlying: error) + state.error = .submissionFailed(underlying: error) } } diff --git a/Examples/AdvancedPatterns/NetworkingWithRetry/RetryNetworkFeature.swift b/Examples/AdvancedPatterns/NetworkingWithRetry/RetryNetworkFeature.swift index b4b7008..c052cb4 100644 --- a/Examples/AdvancedPatterns/NetworkingWithRetry/RetryNetworkFeature.swift +++ b/Examples/AdvancedPatterns/NetworkingWithRetry/RetryNetworkFeature.swift @@ -1,12 +1,27 @@ import Flow import Foundation +/// Application-specific errors for retry networking. +enum RetryError: Error, LocalizedError { + case maxRetriesExceeded(attempts: Int) + case networkFailure(underlying: Error) + + var errorDescription: String? { + switch self { + case .maxRetriesExceeded(let attempts): + return "Failed after \(attempts) retry attempts" + case .networkFailure(let underlying): + return "Network error: \(underlying.localizedDescription)" + } + } +} + /// Advanced networking feature with exponential backoff retry logic. /// /// Demonstrates: /// - Retry with exponential backoff /// - Maximum retry attempts -/// - Error handling with FlowError +/// - Error handling with custom error types /// - Task cancellation /// - Loading states during retries struct RetryNetworkFeature: Feature { @@ -32,7 +47,7 @@ struct RetryNetworkFeature: Feature { final class State { var data: [Item]? var isLoading = false - var error: FlowError? + var error: RetryError? // Retry tracking var retryCount = 0 @@ -41,7 +56,7 @@ struct RetryNetworkFeature: Feature { init( data: [Item]? = nil, isLoading: Bool = false, - error: FlowError? = nil + error: RetryError? = nil ) { self.data = data self.isLoading = isLoading @@ -73,9 +88,7 @@ struct RetryNetworkFeature: Feature { case .retryFetch: guard state.retryCount < maxRetries else { - state.error = .custom( - message: "Maximum retry attempts (\(maxRetries)) exceeded" - ) + state.error = .maxRetriesExceeded(attempts: maxRetries) state.isLoading = false return .none } @@ -121,7 +134,7 @@ struct RetryNetworkFeature: Feature { ) } else { // Max retries exceeded - state.error = .networkError(underlying: error) + state.error = .networkFailure(underlying: error) return .none } } @@ -152,20 +165,17 @@ struct RetryNetworkFeature: Feature { // Retry recursively try await performFetch(state: state) } else { - throw FlowError.custom( - message: "Failed after \(maxRetries) retries", - underlying: error - ) + throw RetryError.maxRetriesExceeded(attempts: maxRetries) } } } .cancellable(id: "fetch-data", cancelInFlight: true) .catch { error, state in state.isLoading = false - if let vfError = error as? FlowError { - state.error = vfError + if let retryError = error as? RetryError { + state.error = retryError } else { - state.error = .networkError(underlying: error) + state.error = .networkFailure(underlying: error) } } } diff --git a/Examples/AdvancedPatterns/PaginatedList/PaginatedListFeature.swift b/Examples/AdvancedPatterns/PaginatedList/PaginatedListFeature.swift index 027b522..bba99e6 100644 --- a/Examples/AdvancedPatterns/PaginatedList/PaginatedListFeature.swift +++ b/Examples/AdvancedPatterns/PaginatedList/PaginatedListFeature.swift @@ -1,6 +1,18 @@ import Flow import Foundation +/// Application-specific errors for pagination operations. +enum PaginationError: Error, LocalizedError { + case fetchFailed(underlying: Error) + + var errorDescription: String? { + switch self { + case .fetchFailed(let underlying): + return "Failed to fetch items: \(underlying.localizedDescription)" + } + } +} + /// Paginated list feature with infinite scrolling. /// /// Demonstrates: @@ -28,7 +40,7 @@ struct PaginatedListFeature: Feature { var nextCursor: String? // Pagination cursor var hasMore = true // More pages available - var error: FlowError? + var error: PaginationError? init( items: [Item] = [], @@ -72,16 +84,16 @@ struct PaginatedListFeature: Feature { state.isInitialLoading = false state.error = nil } catch { - throw FlowError.networkError(underlying: error) + throw PaginationError.fetchFailed(underlying: error) } } .cancellable(id: "fetch-page", cancelInFlight: true) .catch { error, state in state.isInitialLoading = false - if let vfError = error as? FlowError { - state.error = vfError + if let paginationError = error as? PaginationError { + state.error = paginationError } else { - state.error = .networkError(underlying: error) + state.error = .fetchFailed(underlying: error) } } @@ -102,16 +114,16 @@ struct PaginatedListFeature: Feature { state.isRefreshing = false state.error = nil } catch { - throw FlowError.networkError(underlying: error) + throw PaginationError.fetchFailed(underlying: error) } } .cancellable(id: "fetch-page", cancelInFlight: true) .catch { error, state in state.isRefreshing = false - if let vfError = error as? FlowError { - state.error = vfError + if let paginationError = error as? PaginationError { + state.error = paginationError } else { - state.error = .networkError(underlying: error) + state.error = .fetchFailed(underlying: error) } } @@ -139,16 +151,16 @@ struct PaginatedListFeature: Feature { state.isLoadingMore = false state.error = nil } catch { - throw FlowError.networkError(underlying: error) + throw PaginationError.fetchFailed(underlying: error) } } .cancellable(id: "fetch-page", cancelInFlight: true) .catch { error, state in state.isLoadingMore = false - if let vfError = error as? FlowError { - state.error = vfError + if let paginationError = error as? PaginationError { + state.error = paginationError } else { - state.error = .networkError(underlying: error) + state.error = .fetchFailed(underlying: error) } } @@ -165,10 +177,10 @@ struct PaginatedListFeature: Feature { state.isRefreshing = false state.isLoadingMore = false - if let vfError = error as? FlowError { - state.error = vfError + if let paginationError = error as? PaginationError { + state.error = paginationError } else { - state.error = .networkError(underlying: error) + state.error = .fetchFailed(underlying: error) } return .none diff --git a/Examples/AdvancedPatterns/README.md b/Examples/AdvancedPatterns/README.md index 18cc4eb..5a3422a 100644 --- a/Examples/AdvancedPatterns/README.md +++ b/Examples/AdvancedPatterns/README.md @@ -9,7 +9,7 @@ **学習内容:** - 指数バックオフリトライ - 最大リトライ回数の制御 -- FlowError によるエラーハンドリング +- カスタムエラー型によるエラーハンドリング - タスクキャンセル - リトライ中のローディング状態 @@ -36,7 +36,7 @@ return .run { state in try await Task.sleep(for: delay) try await performFetch(state: state) // 再帰的リトライ } else { - throw FlowError.custom(message: "Max retries exceeded") + throw RetryError.maxRetriesExceeded(attempts: maxRetries) } } } @@ -215,14 +215,30 @@ struct RobustPaginatedListFeature: Feature { ### 1. エラーハンドリング -全ての例で FlowError を使用: +各機能で専用のエラー型を定義してエラーハンドリング: ```swift +// アプリケーション固有のエラー型を定義 +enum RetryError: Error, LocalizedError { + case maxRetriesExceeded(attempts: Int) + case networkFailure(underlying: Error) + + var errorDescription: String? { + switch self { + case .maxRetriesExceeded(let attempts): + return "Failed after \(attempts) retry attempts" + case .networkFailure(let underlying): + return "Network error: \(underlying.localizedDescription)" + } + } +} + +// エラーハンドリング .catch { error, state in - if let vfError = error as? FlowError { - state.error = vfError + if let retryError = error as? RetryError { + state.error = retryError } else { - state.error = .networkError(underlying: error) + state.error = .networkFailure(underlying: error) } } ``` @@ -260,7 +276,7 @@ return .run { state in @Observable final class State { var isLoading = false - var error: FlowError? + var error: RetryError? // アプリケーション固有のエラー型 } // ❌ Bad: Feature に状態を持つ @@ -290,7 +306,7 @@ swift test --filter MultiStepWizardFeature /// Demonstrates: /// - Retry with exponential backoff /// - Maximum retry attempts -/// - Error handling with FlowError +/// - Error handling with custom error types /// ... struct RetryNetworkFeature: Feature { ... } ``` diff --git a/Sources/Flow/Store/ActionTask.swift b/Sources/Flow/Store/ActionTask.swift index 801f80f..fe87b02 100644 --- a/Sources/Flow/Store/ActionTask.swift +++ b/Sources/Flow/Store/ActionTask.swift @@ -482,7 +482,7 @@ extension ActionTask { /// Concatenates an array of tasks to run sequentially. /// - /// Throws `FlowError.noTasksToExecute` if the task array is empty. + /// Throws `StoreError.noTasksToExecute` if the task array is empty. /// This strict behavior helps catch logic errors early. /// /// ## Example: Dynamic Tasks with Guard @@ -507,10 +507,10 @@ extension ActionTask { /// /// - Parameter tasks: Array of tasks to concatenate (must not be empty) /// - Returns: A single task that runs all tasks sequentially - /// - Throws: `FlowError.noTasksToExecute` if tasks array is empty + /// - Throws: `StoreError.noTasksToExecute` if tasks array is empty public static func concatenate(_ tasks: [ActionTask]) throws -> ActionTask { guard let first = tasks.first else { - throw FlowError.noTasksToExecute(context: "concatenate(_:)") + throw StoreError.noTasksToExecute(context: "concatenate(_:)") } // TCA-style reduce pattern implementing Monoid return tasks.dropFirst().reduce(first) { $0.concatenate(with: $1) } diff --git a/Sources/Flow/Store/FlowError.swift b/Sources/Flow/Store/FlowError.swift deleted file mode 100644 index ff1d833..0000000 --- a/Sources/Flow/Store/FlowError.swift +++ /dev/null @@ -1,298 +0,0 @@ -import Foundation - -/// Errors that can occur within the Flow framework. -/// -/// `FlowError` provides user-friendly error messages with actionable suggestions -/// for common issues. All errors conform to `LocalizedError` for automatic localization -/// and helpful error descriptions. -/// -/// ## Example Usage -/// ```swift -/// // In your Feature -/// ActionHandler { action, state in -/// switch action { -/// case .submit(let data): -/// guard validate(data) else { -/// state.error = FlowError.validationFailed( -/// reason: "Email format is invalid", -/// suggestion: "Please enter a valid email address" -/// ) -/// return .none -/// } -/// -/// return .run { state in -/// do { -/// try await api.submit(data) -/// } catch { -/// throw FlowError.networkError(underlying: error) -/// } -/// } -/// } -/// } -/// ``` -/// -/// ## Topics -/// ### Error Types -/// - ``validationFailed(reason:suggestion:)`` -/// - ``networkError(underlying:)`` -/// - ``taskError(taskId:underlying:)`` -/// - ``middlewareError(middlewareId:underlying:)`` -/// - ``stateError(reason:)`` -public enum FlowError: Error { - /// Validation failed during action processing. - /// - /// Use this when user input or state validation fails. - /// - /// - Parameters: - /// - reason: Why validation failed - /// - suggestion: How to fix the issue (optional) - /// - /// ## Example - /// ```swift - /// guard email.contains("@") else { - /// throw FlowError.validationFailed( - /// reason: "Email must contain @", - /// suggestion: "Enter a valid email like user@example.com" - /// ) - /// } - /// ``` - case validationFailed(reason: String, suggestion: String? = nil) - - /// Network or API operation failed. - /// - /// Wraps underlying network errors with Flow context. - /// - /// - Parameter underlying: The original network error - /// - /// ## Example - /// ```swift - /// do { - /// try await api.fetch() - /// } catch { - /// throw FlowError.networkError(underlying: error) - /// } - /// ``` - case networkError(underlying: Error) - - /// Task execution failed. - /// - /// Use this when a specific ActionTask fails. - /// - /// - Parameters: - /// - taskId: The ID of the failed task - /// - underlying: The original error - /// - /// ## Example - /// ```swift - /// return .run(id: "fetch-data") { state in - /// do { - /// try await fetchData() - /// } catch { - /// throw FlowError.taskError( - /// taskId: "fetch-data", - /// underlying: error - /// ) - /// } - /// } - /// ``` - case taskError(taskId: String, underlying: Error) - - /// Middleware execution failed. - /// - /// Internal error indicating middleware encountered an issue. - /// Note: Middleware should handle errors internally, but this provides - /// a fallback for unexpected failures. - /// - /// - Parameters: - /// - middlewareId: The ID of the middleware that failed - /// - underlying: The original error - case middlewareError(middlewareId: String, underlying: Error) - - /// Invalid state transition or state inconsistency. - /// - /// Use this when state becomes invalid or a transition is not allowed. - /// - /// - Parameter reason: Why the state is invalid - /// - /// ## Example - /// ```swift - /// case .checkout: - /// guard !state.cart.isEmpty else { - /// throw FlowError.stateError( - /// reason: "Cannot checkout with empty cart" - /// ) - /// } - /// ``` - case stateError(reason: String) - - /// Custom error with a specific message. - /// - /// Use this for domain-specific errors that don't fit other categories. - /// - /// - Parameters: - /// - message: The error message - /// - underlying: Optional underlying error - case custom(message: String, underlying: Error? = nil) - - /// No tasks provided to concatenate. - /// - /// Thrown when attempting to concatenate an empty task array. - /// This typically indicates a logic error where tasks were expected but none were generated. - /// - /// ## Recovery - /// If empty task lists are valid for your use case, explicitly check before concatenating: - /// ```swift - /// guard !tasks.isEmpty else { - /// return .none // Explicitly handle empty case - /// } - /// return try .concatenate(tasks) - /// ``` - /// - /// ## Debugging - /// Check why the task array is empty: - /// - Is the data source empty? - /// - Is there a filter that's too restrictive? - /// - Is there a mapping error? - /// - /// - Parameter context: Additional context about where the error occurred - case noTasksToExecute(context: String? = nil) -} - -// MARK: - LocalizedError Conformance - -extension FlowError: LocalizedError { - /// User-facing error description. - public var errorDescription: String? { - switch self { - case .validationFailed(let reason, let suggestion): - if let suggestion = suggestion { - return "Validation failed: \(reason). \(suggestion)" - } - return "Validation failed: \(reason)" - - case .networkError(let underlying): - return "Network error: \(underlying.localizedDescription)" - - case .taskError(let taskId, let underlying): - return "Task '\(taskId)' failed: \(underlying.localizedDescription)" - - case .middlewareError(let middlewareId, let underlying): - return "Middleware '\(middlewareId)' encountered an error: \(underlying.localizedDescription)" - - case .stateError(let reason): - return "Invalid state: \(reason)" - - case .custom(let message, let underlying): - if let underlying = underlying { - return "\(message): \(underlying.localizedDescription)" - } - return message - - case .noTasksToExecute(let context): - if let context = context { - return """ - No tasks to execute in \(context). Empty task arrays are not allowed. \ - If this is intentional, explicitly return .none instead. - """ - } - return """ - No tasks to execute. Empty task arrays are not allowed. \ - If empty is valid, explicitly check and return .none. - """ - } - } - - /// Recovery suggestion for the user. - public var recoverySuggestion: String? { - switch self { - case .validationFailed(_, let suggestion): - return suggestion - - case .networkError: - return "Check your internet connection and try again." - - case .taskError(let taskId, _): - return "Task '\(taskId)' can be retried or cancelled." - - case .middlewareError: - return "This is an internal error. Please report this issue." - - case .stateError: - return "Ensure the application is in a valid state before this operation." - - case .custom: - return nil - - case .noTasksToExecute: - return """ - Check if empty is expected: - - guard !tasks.isEmpty else { - return .none // Explicit: empty is OK - } - return try .concatenate(tasks) - """ - } - } - - /// Additional failure reason (for debugging). - public var failureReason: String? { - switch self { - case .validationFailed(let reason, _): - return reason - - case .networkError(let underlying): - return "Underlying network error: \(underlying)" - - case .taskError(let taskId, let underlying): - return "Task '\(taskId)' failed with: \(underlying)" - - case .middlewareError(let middlewareId, let underlying): - return "Middleware '\(middlewareId)' failed with: \(underlying)" - - case .stateError(let reason): - return reason - - case .custom(_, let underlying): - return underlying?.localizedDescription - - case .noTasksToExecute(let context): - if let context = context { - return "Empty task array in \(context)" - } - return "Empty task array provided to concatenate" - } - } -} - -// MARK: - CustomStringConvertible - -extension FlowError: CustomStringConvertible { - /// Human-readable description for debugging. - public var description: String { - errorDescription ?? "Unknown Flow error" - } -} - -// MARK: - CustomDebugStringConvertible - -extension FlowError: CustomDebugStringConvertible { - /// Detailed description for debugging. - public var debugDescription: String { - var components: [String] = [] - - if let description = errorDescription { - components.append("Description: \(description)") - } - - if let reason = failureReason { - components.append("Reason: \(reason)") - } - - if let suggestion = recoverySuggestion { - components.append("Suggestion: \(suggestion)") - } - - return "FlowError(\n \(components.joined(separator: "\n "))\n)" - } -} diff --git a/Sources/Flow/Store/Store.swift b/Sources/Flow/Store/Store.swift index f0c8845..f624d86 100644 --- a/Sources/Flow/Store/Store.swift +++ b/Sources/Flow/Store/Store.swift @@ -1,14 +1,6 @@ import Foundation import Observation -/// Errors that can occur during Store operations. -public enum StoreError: Error, Sendable { - /// The store was deallocated before the operation could complete - case deallocated - /// The operation was cancelled - case cancelled -} - /// The main store for managing application state and dispatching actions. /// /// `Store` provides a Redux-like unidirectional data flow architecture for SwiftUI apps @@ -366,7 +358,7 @@ public final class Store { // INVARIANT: flattenConcatenated() always returns ≥1 element // // Proof: - // 1. concatenate([]) now throws FlowError.noTasksToExecute + // 1. concatenate([]) now throws StoreError.noTasksToExecute // 2. .concatenated can only be constructed via concatenate() // 3. Therefore, .concatenated always contains ≥1 task // 4. flattenConcatenated() preserves this property diff --git a/Sources/Flow/Store/StoreError.swift b/Sources/Flow/Store/StoreError.swift new file mode 100644 index 0000000..2063ec9 --- /dev/null +++ b/Sources/Flow/Store/StoreError.swift @@ -0,0 +1,182 @@ +import Foundation + +/// Errors that occur within the Flow framework's core store operations. +/// +/// `StoreError` represents framework-level errors related to store lifecycle +/// and API misuse. These are distinct from application-domain errors, which +/// should be defined by the application itself. +/// +/// ## Error Categories +/// +/// ### Lifecycle Errors +/// - ``deallocated``: Store was deallocated during an async operation +/// - ``cancelled``: Operation was explicitly cancelled +/// +/// ### API Misuse Errors +/// - ``noTasksToExecute(context:)``: Attempted to concatenate empty task array +/// +/// ## Example: Handling Store Errors +/// ```swift +/// do { +/// let task = try ActionTask.concatenate(tasks) +/// return task +/// } catch let error as StoreError { +/// // Handle framework-level errors +/// print("Store error: \(error)") +/// return .none +/// } catch { +/// // Handle application-specific errors +/// return .none +/// } +/// ``` +/// +/// ## Topics +/// ### Error Cases +/// - ``deallocated`` +/// - ``cancelled`` +/// - ``noTasksToExecute(context:)`` +public enum StoreError: Error, Sendable { + /// The store was deallocated before the operation could complete. + /// + /// This occurs when the `Store` instance is released while an async task + /// is still running. This is typically not an error condition in SwiftUI apps + /// where view dismissal naturally cancels ongoing operations. + case deallocated + + /// The operation was cancelled. + /// + /// This occurs when a task is explicitly cancelled via its cancellation ID + /// or when the parent operation is cancelled. + case cancelled + + /// No tasks provided to concatenate. + /// + /// Thrown when attempting to concatenate an empty task array. + /// This typically indicates a logic error where tasks were expected but none were generated. + /// + /// ## Why This Is An Error + /// + /// Empty task arrays usually indicate a programming mistake: + /// - Forgot to check if data is available + /// - Applied filters that removed all items + /// - Logic error in task generation + /// + /// Making this an error helps catch bugs early during development. + /// + /// ## Recovery + /// + /// If empty task lists are valid for your use case, explicitly check before concatenating: + /// ```swift + /// guard !tasks.isEmpty else { + /// return .none // Explicitly handle empty case + /// } + /// return try .concatenate(tasks) + /// ``` + /// + /// ## Debugging + /// + /// Check why the task array is empty: + /// - Is the data source empty? + /// - Is there a filter that's too restrictive? + /// - Is there a mapping error? + /// + /// - Parameter context: Additional context about where the error occurred + case noTasksToExecute(context: String? = nil) +} + +// MARK: - LocalizedError Conformance + +extension StoreError: LocalizedError { + /// User-facing error description. + public var errorDescription: String? { + switch self { + case .deallocated: + return "The store was deallocated before the operation could complete." + + case .cancelled: + return "The operation was cancelled." + + case .noTasksToExecute(let context): + if let context = context { + return """ + No tasks to execute in \(context). Empty task arrays are not allowed. \ + If this is intentional, explicitly return .none instead. + """ + } + return """ + No tasks to execute. Empty task arrays are not allowed. \ + If empty is valid, explicitly check and return .none. + """ + } + } + + /// Recovery suggestion for the user. + public var recoverySuggestion: String? { + switch self { + case .deallocated: + return "Ensure the store remains in memory for the duration of the operation." + + case .cancelled: + return "This is expected behavior when operations are cancelled." + + case .noTasksToExecute: + return """ + Check if empty is expected: + + guard !tasks.isEmpty else { + return .none // Explicit: empty is OK + } + return try .concatenate(tasks) + """ + } + } + + /// Additional failure reason (for debugging). + public var failureReason: String? { + switch self { + case .deallocated: + return "Store instance was released during an async operation" + + case .cancelled: + return "Task was cancelled before completion" + + case .noTasksToExecute(let context): + if let context = context { + return "Empty task array in \(context)" + } + return "Empty task array provided to concatenate" + } + } +} + +// MARK: - CustomStringConvertible + +extension StoreError: CustomStringConvertible { + /// Human-readable description for debugging. + public var description: String { + errorDescription ?? "Unknown Store error" + } +} + +// MARK: - CustomDebugStringConvertible + +extension StoreError: CustomDebugStringConvertible { + /// Detailed description for debugging. + public var debugDescription: String { + var components: [String] = [] + + if let description = errorDescription { + components.append("Description: \(description)") + } + + if let reason = failureReason { + components.append("Reason: \(reason)") + } + + if let suggestion = recoverySuggestion { + components.append("Suggestion: \(suggestion)") + } + + return "StoreError(\n \(components.joined(separator: "\n "))\n)" + } +} diff --git a/Tests/UnitTests/Store/FlowErrorTests.swift b/Tests/UnitTests/Store/FlowErrorTests.swift deleted file mode 100644 index abd9e10..0000000 --- a/Tests/UnitTests/Store/FlowErrorTests.swift +++ /dev/null @@ -1,205 +0,0 @@ -import Foundation -import Testing - -@testable import Flow - -// MARK: - Test Helpers - -private struct DummyError: Error {} - -@Observable -private final class ErrorTestState { - var error: FlowError? - var data: String? -} - -private struct ErrorTestFeature: Feature { - typealias State = ErrorTestState - - enum Action: Sendable { - case load - } - - func handle() -> ActionHandler { - ActionHandler { action, state in - switch action { - case .load: - return .run { _ in - throw FlowError.networkError( - underlying: DummyError() - ) - } - .catch { error, state in - if let vfError = error as? FlowError { - state.error = vfError - } - } - } - } - } -} - -/// Tests for FlowError to ensure user-friendly error messages. -@Suite("FlowError Tests") -struct FlowErrorTests { - // MARK: - Validation Errors - - @Test("Validation error with suggestion") - func validationErrorWithSuggestion() { - let error = FlowError.validationFailed( - reason: "Email format is invalid", - suggestion: "Please enter a valid email like user@example.com" - ) - - #expect( - error.errorDescription - == "Validation failed: Email format is invalid. Please enter a valid email like user@example.com" - ) - #expect(error.recoverySuggestion == "Please enter a valid email like user@example.com") - #expect(error.failureReason == "Email format is invalid") - } - - @Test("Validation error without suggestion") - func validationErrorWithoutSuggestion() { - let error = FlowError.validationFailed( - reason: "Password too short" - ) - - #expect(error.errorDescription == "Validation failed: Password too short") - #expect(error.recoverySuggestion == nil) - } - - // MARK: - Network Errors - - @Test("Network error wrapping") - func networkErrorWrapping() { - struct DummyNetworkError: Error, LocalizedError { - var errorDescription: String? { "Connection timeout" } - } - - let underlying = DummyNetworkError() - let error = FlowError.networkError(underlying: underlying) - - #expect(error.errorDescription?.contains("Connection timeout") == true) - #expect(error.recoverySuggestion == "Check your internet connection and try again.") - } - - // MARK: - Task Errors - - @Test("Task error with ID") - func taskErrorWithId() { - struct DummyTaskError: Error, LocalizedError { - var errorDescription: String? { "API returned 404" } - } - - let underlying = DummyTaskError() - let error = FlowError.taskError( - taskId: "fetch-user", - underlying: underlying - ) - - #expect(error.errorDescription?.contains("fetch-user") == true) - #expect(error.errorDescription?.contains("404") == true) - #expect(error.recoverySuggestion?.contains("fetch-user") == true) - } - - // MARK: - Middleware Errors - - @Test("Middleware error") - func middlewareError() { - struct DummyMiddlewareError: Error, LocalizedError { - var errorDescription: String? { "Analytics unavailable" } - } - - let underlying = DummyMiddlewareError() - let error = FlowError.middlewareError( - middlewareId: "AnalyticsMiddleware", - underlying: underlying - ) - - #expect(error.errorDescription?.contains("AnalyticsMiddleware") == true) - #expect(error.recoverySuggestion == "This is an internal error. Please report this issue.") - } - - // MARK: - State Errors - - @Test("State error") - func stateError() { - let error = FlowError.stateError( - reason: "Cannot checkout with empty cart" - ) - - #expect(error.errorDescription == "Invalid state: Cannot checkout with empty cart") - #expect(error.failureReason == "Cannot checkout with empty cart") - } - - // MARK: - Custom Errors - - @Test("Custom error without underlying") - func customErrorWithoutUnderlying() { - let error = FlowError.custom( - message: "Feature unavailable in this region" - ) - - #expect(error.errorDescription == "Feature unavailable in this region") - #expect(error.recoverySuggestion == nil) - } - - @Test("Custom error with underlying") - func customErrorWithUnderlying() { - struct DummyError: Error, LocalizedError { - var errorDescription: String? { "Database locked" } - } - - let underlying = DummyError() - let error = FlowError.custom( - message: "Cannot save data", - underlying: underlying - ) - - #expect(error.errorDescription?.contains("Cannot save data") == true) - #expect(error.errorDescription?.contains("Database locked") == true) - } - - // MARK: - String Conversion - - @Test("CustomStringConvertible") - @MainActor - func customStringConvertible() { - let error = FlowError.validationFailed(reason: "Test") - let description = String(describing: error) - - #expect(description.contains("Validation failed")) - } - - @Test("CustomDebugStringConvertible") - @MainActor - func customDebugStringConvertible() { - let error = FlowError.validationFailed( - reason: "Email invalid", - suggestion: "Use valid format" - ) - - let debugDescription = String(reflecting: error) - - #expect(debugDescription.contains("Description:")) - #expect(debugDescription.contains("Reason:")) - #expect(debugDescription.contains("Suggestion:")) - } - - // MARK: - Integration with ActionTask - - @Test("Using FlowError in ActionTask.catch") - @MainActor - func usingInActionTaskCatch() async { - let store = Store( - initialState: ErrorTestState(), - feature: ErrorTestFeature() - ) - - await store.send(.load).value - - #expect(store.state.error != nil) - #expect(store.state.error?.errorDescription?.contains("Network error") == true) - } -}