diff --git a/Sources/Flow/Store/ActionTask.swift b/Sources/Flow/Store/ActionTask.swift index 8be82dc..e23bded 100644 --- a/Sources/Flow/Store/ActionTask.swift +++ b/Sources/Flow/Store/ActionTask.swift @@ -200,6 +200,7 @@ public struct ActionTask { /// Execute an asynchronous operation that returns a result case run( id: String, + name: String?, operation: @MainActor (State) async throws -> ActionResult, onError: (@MainActor (Error, State) -> Void)?, cancelInFlight: Bool, @@ -285,8 +286,8 @@ extension ActionTask { /// return .created(id: outcome.id) /// } /// - /// // Make it cancellable with ID - /// return .run { state in + /// // Named task for better debugging + /// return .run(name: "🔄 Fetch user data") { state in /// let data = try await fetch() /// state.data = data /// return .fetched @@ -294,15 +295,19 @@ extension ActionTask { /// .cancellable(id: "fetch", cancelInFlight: true) /// ``` /// - /// - Parameter operation: The async operation to execute, receiving mutable state and returning a result + /// - Parameters: + /// - name: Optional human-readable name for the task (useful for debugging and profiling) + /// - operation: The async operation to execute, receiving mutable state and returning a result /// - Returns: A new `ActionTask` that will execute the operation public static func run( + name: String? = nil, operation: @escaping @MainActor (State) async throws -> ActionResult ) -> ActionTask { let taskId = TaskIdGenerator.generate() return ActionTask( operation: .run( id: taskId, + name: name, operation: operation, onError: nil, cancelInFlight: false, @@ -542,10 +547,11 @@ extension ActionTask { /// - Returns: A new `ActionTask` with the error handler attached public func `catch`(_ handler: @escaping @MainActor (Error, State) -> Void) -> ActionTask { switch operation { - case .run(let id, let op, _, let cancelInFlight, let priority): + case .run(let id, let name, let op, _, let cancelInFlight, let priority): return ActionTask( operation: .run( id: id, + name: name, operation: op, onError: handler, cancelInFlight: cancelInFlight, @@ -590,11 +596,12 @@ extension ActionTask { cancelInFlight: Bool = false ) -> ActionTask { switch operation { - case .run(_, let op, let onError, _, let priority): + case .run(_, let name, let op, let onError, _, let priority): let stringId = id.taskIdString return ActionTask( operation: .run( id: stringId, + name: name, operation: op, onError: onError, cancelInFlight: cancelInFlight, @@ -641,10 +648,11 @@ extension ActionTask { /// - Returns: A new `ActionTask` with the specified priority public func priority(_ priority: TaskPriority) -> ActionTask { switch operation { - case .run(let id, let op, let onError, let cancelInFlight, _): + case .run(let id, let name, let op, let onError, let cancelInFlight, _): return ActionTask( operation: .run( id: id, + name: name, operation: op, onError: onError, cancelInFlight: cancelInFlight, diff --git a/Sources/Flow/Store/Store.swift b/Sources/Flow/Store/Store.swift index 7850083..31bdc62 100644 --- a/Sources/Flow/Store/Store.swift +++ b/Sources/Flow/Store/Store.swift @@ -204,8 +204,10 @@ public final class Store { /// /// This helper method handles the complexity of executing async operations, /// managing task cancellation, and error handling. + // swiftlint:disable:next function_parameter_count private func executeRunTask( id: String, + name: String?, operation: @escaping @MainActor (F.State) async throws -> F.ActionResult, onError: (@MainActor (Error, F.State) -> Void)?, cancelInFlight: Bool, @@ -220,6 +222,7 @@ public final class Store { let runningTask = taskManager.executeTask( id: id, + name: name, operation: { @MainActor [weak self] in guard let self else { throw StoreError.deallocated @@ -310,9 +313,10 @@ public final class Store { case .just(let result): return result - case .run(let id, let operation, let onError, let cancelInFlight, let priority): + case .run(let id, let name, let operation, let onError, let cancelInFlight, let priority): return try await executeRunTask( id: id, + name: name, operation: operation, onError: onError, cancelInFlight: cancelInFlight, diff --git a/Sources/Flow/Store/TaskManager.swift b/Sources/Flow/Store/TaskManager.swift index edab827..b1952e9 100644 --- a/Sources/Flow/Store/TaskManager.swift +++ b/Sources/Flow/Store/TaskManager.swift @@ -114,6 +114,7 @@ public final class TaskManager { /// /// - Parameters: /// - id: Unique identifier for the task (string representation) + /// - name: Optional human-readable name for the task (useful for debugging and profiling) /// - operation: The asynchronous operation to execute /// - onError: Optional error handler called if the operation throws /// - priority: Optional task priority (defaults to nil, using system default) @@ -123,6 +124,7 @@ public final class TaskManager { /// ```swift /// let task = taskManager.executeTask( /// id: "loadProfile", + /// name: "🔄 Load user profile", /// operation: { /// let profile = try await api.fetchProfile() /// await store.send(.profileLoaded(profile)) @@ -140,6 +142,7 @@ public final class TaskManager { @discardableResult public func executeTask( id: String, + name: String? = nil, operation: @escaping () async throws -> Void, onError: ((Error) async -> Void)?, priority: TaskPriority? = nil @@ -152,7 +155,7 @@ public final class TaskManager { // Use [weak self] to prevent retain cycle (TaskManager ← runningTasks ← Task) // Ensures deinit runs when Store deallocates, cancelling all tasks - let task = Task(priority: priority) { @MainActor [weak self] in + let task = Task(name: name, priority: priority) { @MainActor [weak self] in guard let self else { return } // Defer ensures cleanup happens exactly once, regardless of how the task completes diff --git a/Tests/UnitTests/ActionHandler/ActionHandlerTests.swift b/Tests/UnitTests/ActionHandler/ActionHandlerTests.swift index 53a0d9c..fab3c6a 100644 --- a/Tests/UnitTests/ActionHandler/ActionHandlerTests.swift +++ b/Tests/UnitTests/ActionHandler/ActionHandlerTests.swift @@ -109,7 +109,7 @@ import Testing // THEN: Should return run task #expect(state.isLoading) - if case .run(let id, _, _, _, _) = task.operation { + if case .run(let id, _, _, _, _, _) = task.operation { #expect(id == "test") } else { Issue.record("Expected run task") @@ -286,7 +286,7 @@ import Testing // THEN: Task should be transformed #expect(state.count == 1) - if case .run(let id, _, _, _, _) = task.operation { + if case .run(let id, _, _, _, _, _) = task.operation { #expect(id == "transformed") } else { Issue.record("Expected run task") @@ -301,7 +301,7 @@ import Testing } .transform { task in switch task.operation { - case .run(let id, _, _, _, _): + case .run(let id, _, _, _, _, _): return .cancel(id: id) default: return task @@ -466,7 +466,7 @@ import Testing let task = await sut.handle(action: .asyncOp, state: state) #expect(state.isLoading) - if case .run(let id, _, _, _, _) = task.operation { + if case .run(let id, _, _, _, _, _) = task.operation { #expect(id == "complex") } diff --git a/Tests/UnitTests/ActionHandler/ActionProcessorTests.swift b/Tests/UnitTests/ActionHandler/ActionProcessorTests.swift index c4462f5..8812b64 100644 --- a/Tests/UnitTests/ActionHandler/ActionProcessorTests.swift +++ b/Tests/UnitTests/ActionHandler/ActionProcessorTests.swift @@ -122,7 +122,7 @@ import Testing // THEN: Should return run task #expect(state.isLoading) - if case .run(let id, _, _, _, _) = task.operation { + if case .run(let id, _, _, _, _, _) = task.operation { #expect(id == "test-task") } else { Issue.record("Expected run task") @@ -282,7 +282,7 @@ import Testing // THEN: Task should be transformed #expect(state.count == 1) - if case .run(let id, _, _, _, _) = task.operation { + if case .run(let id, _, _, _, _, _) = task.operation { #expect(id == "transformed") } else { Issue.record("Expected run task") @@ -297,7 +297,7 @@ import Testing } .transform { task in switch task.operation { - case .run(let id, _, _, _, _): + case .run(let id, _, _, _, _, _): return .cancel(id: id) default: return task @@ -398,7 +398,7 @@ import Testing #expect(state.count == 15) #expect(middlewareExecuted) #expect(state.errorMessage == nil) - if case .run(let id, _, _, _, _) = task.operation { + if case .run(let id, _, _, _, _, _) = task.operation { #expect(id == "main-task") } } diff --git a/Tests/UnitTests/Store/ActionTaskCancellableTests.swift b/Tests/UnitTests/Store/ActionTaskCancellableTests.swift index 41d0c17..27fd5b4 100644 --- a/Tests/UnitTests/Store/ActionTaskCancellableTests.swift +++ b/Tests/UnitTests/Store/ActionTaskCancellableTests.swift @@ -32,7 +32,7 @@ import Testing // THEN: Should have the specified ID and cancelInFlight = false switch sut.operation { - case .run(let id, _, _, let cancelInFlight, _): + case .run(let id, _, _, _, let cancelInFlight, _): #expect(id == "search") #expect(!cancelInFlight) default: @@ -47,7 +47,7 @@ import Testing // THEN: Should have cancelInFlight = true switch sut.operation { - case .run(let id, _, _, let cancelInFlight, _): + case .run(let id, _, _, _, let cancelInFlight, _): #expect(id == "search") #expect(cancelInFlight) default: @@ -62,7 +62,7 @@ import Testing // THEN: Should use the new ID from cancellable switch sut.operation { - case .run(let id, _, _, _, _): + case .run(let id, _, _, _, _, _): #expect(id == "new-id") default: Issue.record("Expected run task") @@ -105,7 +105,7 @@ import Testing // THEN: Should have both ID, cancelInFlight, and error handler switch sut.operation { - case .run(let id, _, let onError, let cancelInFlight, _): + case .run(let id, _, _, let onError, let cancelInFlight, _): #expect(id == "search") #expect(cancelInFlight) #expect(onError != nil) @@ -122,7 +122,7 @@ import Testing // THEN: Should preserve error handler and set cancellable switch sut.operation { - case .run(let id, _, let onError, let cancelInFlight, _): + case .run(let id, _, _, let onError, let cancelInFlight, _): #expect(id == "search") #expect(cancelInFlight) #expect(onError != nil) @@ -138,7 +138,7 @@ import Testing // THEN: Should convert Int to String switch sut.operation { - case .run(let id, _, _, let cancelInFlight, _): + case .run(let id, _, _, _, let cancelInFlight, _): #expect(id == "42") #expect(cancelInFlight) default: @@ -154,7 +154,7 @@ import Testing // THEN: Should convert UUID to String switch sut.operation { - case .run(let id, _, _, let cancelInFlight, _): + case .run(let id, _, _, _, let cancelInFlight, _): #expect(id == uuid.uuidString) #expect(cancelInFlight) default: @@ -174,7 +174,7 @@ import Testing // THEN: Should use enum raw value switch sut.operation { - case .run(let id, _, _, let cancelInFlight, _): + case .run(let id, _, _, _, let cancelInFlight, _): #expect(id == "search") #expect(cancelInFlight) default: @@ -189,7 +189,7 @@ import Testing // THEN: cancelInFlight should default to false switch sut.operation { - case .run(_, _, _, let cancelInFlight, _): + case .run(_, _, _, _, let cancelInFlight, _): #expect(!cancelInFlight) default: Issue.record("Expected run task") @@ -204,7 +204,7 @@ import Testing // THEN: Last call should override switch sut.operation { - case .run(let id, _, _, let cancelInFlight, _): + case .run(let id, _, _, _, let cancelInFlight, _): #expect(id == "second") #expect(cancelInFlight) default: diff --git a/Tests/UnitTests/Store/ActionTaskPriorityTests.swift b/Tests/UnitTests/Store/ActionTaskPriorityTests.swift index 1f9eb65..55d6d63 100644 --- a/Tests/UnitTests/Store/ActionTaskPriorityTests.swift +++ b/Tests/UnitTests/Store/ActionTaskPriorityTests.swift @@ -33,7 +33,7 @@ import Testing // THEN: Should have high priority switch result.operation { - case .run(_, _, _, _, let priority): + case .run(_, _, _, _, _, let priority): #expect(priority == .high) default: Issue.record("Expected run task") @@ -51,7 +51,7 @@ import Testing // THEN: Should have low priority switch result.operation { - case .run(_, _, _, _, let priority): + case .run(_, _, _, _, _, let priority): #expect(priority == .low) default: Issue.record("Expected run task") @@ -69,7 +69,7 @@ import Testing // THEN: Should have background priority switch result.operation { - case .run(_, _, _, _, let priority): + case .run(_, _, _, _, _, let priority): #expect(priority == .background) default: Issue.record("Expected run task") @@ -87,7 +87,7 @@ import Testing // THEN: Should have userInitiated priority switch result.operation { - case .run(_, _, _, _, let priority): + case .run(_, _, _, _, _, let priority): #expect(priority == .userInitiated) default: Issue.record("Expected run task") @@ -102,7 +102,7 @@ import Testing // THEN: Priority should be nil (system default) switch sut.operation { - case .run(_, _, _, _, let priority): + case .run(_, _, _, _, _, let priority): #expect(priority == nil) default: Issue.record("Expected run task") @@ -125,7 +125,7 @@ import Testing // THEN: Should have both priority and cancellable ID switch result.operation { - case .run(let id, _, _, let cancelInFlight, let priority): + case .run(let id, _, _, _, let cancelInFlight, let priority): #expect(id == "test-task") #expect(cancelInFlight == true) #expect(priority == .high) @@ -150,7 +150,7 @@ import Testing // THEN: Should have both priority and error handler switch result.operation { - case .run(_, _, let onError, _, let priority): + case .run(_, _, _, let onError, _, let priority): #expect(priority == .userInitiated) #expect(onError != nil) default: @@ -175,7 +175,7 @@ import Testing // THEN: Should preserve all configurations switch result.operation { - case .run(let id, _, let onError, let cancelInFlight, let priority): + case .run(let id, _, _, let onError, let cancelInFlight, let priority): #expect(id == "full-chain") #expect(cancelInFlight == false) #expect(priority == .high) @@ -202,7 +202,7 @@ import Testing // THEN: Should preserve all configurations regardless of order switch result.operation { - case .run(let id, _, let onError, _, let priority): + case .run(let id, _, _, let onError, _, let priority): #expect(id == "order-test") #expect(priority == .low) #expect(onError != nil) @@ -225,7 +225,7 @@ import Testing // THEN: Should have the new priority switch result.operation { - case .run(_, _, _, _, let priority): + case .run(_, _, _, _, _, let priority): #expect(priority == .low) default: Issue.record("Expected run task") diff --git a/Tests/UnitTests/Store/ActionTaskTests.swift b/Tests/UnitTests/Store/ActionTaskTests.swift index e5f23bf..c045be9 100644 --- a/Tests/UnitTests/Store/ActionTaskTests.swift +++ b/Tests/UnitTests/Store/ActionTaskTests.swift @@ -72,7 +72,7 @@ import Testing // THEN: Should have run storeTask with correct ID switch sut.operation { - case .run(let id, _, _, _, _): + case .run(let id, _, _, _, _, _): #expect(id == taskId) default: Issue.record("Expected run task, got different type") @@ -85,7 +85,7 @@ import Testing // THEN: Should have run storeTask with auto-generated ID switch sut.operation { - case .run(let id, _, _, _, _): + case .run(let id, _, _, _, _, _): #expect(id.hasPrefix("auto-task-"), "ID should have auto-task prefix") #expect(id.count > "auto-task-".count, "ID should have unique suffix") default: @@ -102,11 +102,11 @@ import Testing var id1: String? var id2: String? - if case .run(let id, _, _, _, _) = task1.operation { + if case .run(let id, _, _, _, _, _) = task1.operation { id1 = id } - if case .run(let id, _, _, _, _) = task2.operation { + if case .run(let id, _, _, _, _, _) = task2.operation { id2 = id } @@ -147,7 +147,7 @@ import Testing // THEN: Should accept and store the long ID switch sut.operation { - case .run(let id, _, _, _, _): + case .run(let id, _, _, _, _, _): #expect(id == longId) #expect(id.count == 1000) default: @@ -165,7 +165,7 @@ import Testing // THEN: Should accept and store the ID with special characters switch sut.operation { - case .run(let id, _, _, _, _): + case .run(let id, _, _, _, _, _): #expect(id == specialId) default: Issue.record("Expected run task") @@ -333,7 +333,7 @@ import Testing // THEN: Should have run task with error handler switch result.operation { - case .run(let id, _, let onError, _, _): + case .run(let id, _, _, let onError, _, _): #expect(id == "test") #expect(onError != nil, "Error handler should be attached") default: @@ -350,7 +350,7 @@ import Testing // THEN: Should preserve auto-generated ID and attach handler switch result.operation { - case .run(let id, _, let onError, _, _): + case .run(let id, _, _, let onError, _, _): #expect(id.hasPrefix("auto-task-"), "Should preserve auto-generated ID") #expect(onError != nil, "Error handler should be attached") default: @@ -371,7 +371,7 @@ import Testing // THEN: Should have the last error handler switch result.operation { - case .run(_, _, let onError, _, _): + case .run(_, _, _, let onError, _, _): #expect(onError != nil, "Should have error handler") // Note: Can't easily test which handler is attached in unit test // This is tested in integration tests @@ -391,7 +391,7 @@ import Testing // THEN: Should preserve the original task ID switch result.operation { - case .run(let id, _, _, _, _): + case .run(let id, _, _, _, _, _): #expect(id == originalId, "Task ID should be preserved") default: Issue.record("Expected run task") @@ -411,7 +411,7 @@ import Testing // THEN: Should successfully attach the handler switch result.operation { - case .run(let id, _, let onError, _, _): + case .run(let id, _, _, let onError, _, _): #expect(id == "test") #expect(onError != nil) default: @@ -541,4 +541,94 @@ import Testing Issue.record("Expected cancels task, got different type") } } + + // MARK: - Task Naming + + @Test func run_withName() { + // GIVEN: A run task with a name + let sut: ActionTask = .run(name: "Fetch user data") { _ in } + + // THEN: Should have the specified name + switch sut.operation { + case .run(_, let name, _, _, _, _): + #expect(name == "Fetch user data") + default: + Issue.record("Expected run task") + } + } + + @Test func run_withoutName() { + // GIVEN: A run task without a name (backward compatibility) + let sut: ActionTask = .run { _ in } + + // THEN: Should have nil name + switch sut.operation { + case .run(_, let name, _, _, _, _): + #expect(name == nil) + default: + Issue.record("Expected run task") + } + } + + @Test func run_namePreservedAfterCatch() { + // GIVEN: A named run task with error handler + let sut: ActionTask = .run(name: "Load profile") { _ in } + .catch { _, _ in } + + // THEN: Should preserve the name + switch sut.operation { + case .run(_, let name, _, _, _, _): + #expect(name == "Load profile") + default: + Issue.record("Expected run task") + } + } + + @Test func run_namePreservedAfterCancellable() { + // GIVEN: A named run task made cancellable + let sut: ActionTask = .run(name: "Fetch data") { _ in } + .cancellable(id: "fetch") + + // THEN: Should preserve the name + switch sut.operation { + case .run(_, let name, _, _, _, _): + #expect(name == "Fetch data") + default: + Issue.record("Expected run task") + } + } + + @Test func run_namePreservedAfterPriority() { + // GIVEN: A named run task with priority + let sut: ActionTask = .run(name: "Critical operation") { _ in } + .priority(.high) + + // THEN: Should preserve the name + switch sut.operation { + case .run(_, let name, _, _, _, _): + #expect(name == "Critical operation") + default: + Issue.record("Expected run task") + } + } + + @Test func run_namePreservedThroughChaining() { + // GIVEN: A named run task with all configurations + let sut: ActionTask = .run(name: "🔄 Load user") { _ in } + .cancellable(id: "load", cancelInFlight: true) + .priority(.userInitiated) + .catch { _, _ in } + + // THEN: Should preserve the name through all chaining + switch sut.operation { + case .run(let id, let name, _, let onError, let cancelInFlight, let priority): + #expect(name == "🔄 Load user") + #expect(id == "load") + #expect(cancelInFlight == true) + #expect(priority == .userInitiated) + #expect(onError != nil) + default: + Issue.record("Expected run task") + } + } }