diff --git a/Sources/Nodes/Core/NodeError.swift b/Sources/Nodes/Core/NodeError.swift new file mode 100644 index 000000000..beae63318 --- /dev/null +++ b/Sources/Nodes/Core/NodeError.swift @@ -0,0 +1,25 @@ +// +// All Contributions by Match Group +// +// Copyright © 2025 Tinder (Match Group, LLC) +// +// Licensed under the Match Group Modified 3-Clause BSD License. +// See https://github.com/Tinder/Nodes/blob/main/LICENSE for license information. +// + +import Foundation + +/** + * Errors that can occur within the Nodes framework. + */ +public enum NodeError: Error, LocalizedError, Equatable { + /// Error thrown when a worker of a specific type cannot be found in the context. + case workerNotFound(String) + + public var errorDescription: String? { + switch self { + case .workerNotFound(let type): + return "Worker of type '\(type)' not found in context" + } + } +} \ No newline at end of file diff --git a/Sources/Nodes/Core/Protocols/WorkerControllerDelegate.swift b/Sources/Nodes/Core/Protocols/WorkerControllerDelegate.swift new file mode 100644 index 000000000..5660429d1 --- /dev/null +++ b/Sources/Nodes/Core/Protocols/WorkerControllerDelegate.swift @@ -0,0 +1,20 @@ +// +// All Contributions by Match Group +// +// Copyright © 2025 Tinder (Match Group, LLC) +// +// Licensed under the Match Group Modified 3-Clause BSD License. +// See https://github.com/Tinder/Nodes/blob/main/LICENSE for license information. +// + +/** + * The delegate protocol for ``WorkerController`` to handle worker-related events. + */ +@preconcurrency +@MainActor +public protocol WorkerControllerDelegate: AnyObject { + /// Called when a worker of a specific type is not found in the controller. + /// + /// - Parameter type: The type of worker that was not found. + func workerController(_ controller: WorkerController, didFailToFindWorkerOfType type: String) +} \ No newline at end of file diff --git a/Sources/Nodes/Internal/WorkerController.swift b/Sources/Nodes/Internal/WorkerController.swift index 117fb97c0..8231fa38b 100644 --- a/Sources/Nodes/Internal/WorkerController.swift +++ b/Sources/Nodes/Internal/WorkerController.swift @@ -18,11 +18,17 @@ public final class WorkerController { /// The array of `Worker` instances managed by the ``WorkerController``. public private(set) var workers: [Worker] = [] + /// The delegate to handle worker-related events. + private weak var delegate: WorkerControllerDelegate? + /// Initializes a new ``WorkerController`` instance to manage a collection of `Worker` instances. /// - /// - Parameter workers: The array of `Worker` instances to be managed by the ``WorkerController``. - public init(workers: [Worker]) { + /// - Parameters: + /// - workers: The array of `Worker` instances to be managed by the ``WorkerController``. + /// - delegate: The delegate to handle worker-related events. + public init(workers: [Worker], delegate: WorkerControllerDelegate) { self.workers = workers + self.delegate = delegate } /// Starts all `Worker` instances in the `workers` array. @@ -59,9 +65,14 @@ public final class WorkerController { /// | worker | The `Worker` instance of type `T`. | /// /// The closure returns `Void` and throws. - public func withFirstWorker(ofType type: T.Type, perform: (_ worker: T) throws -> Void) rethrows { + /// + /// - Throws: ``NodeError/workerNotFound`` if no worker of the specified type exists. + public func withFirstWorker(ofType type: T.Type, perform: (_ worker: T) throws -> Void) throws { guard let worker: T = firstWorker(ofType: type) - else { return } + else { + delegate?.workerController(self, didFailToFindWorkerOfType: String(describing: type)) + throw NodeError.workerNotFound(String(describing: type)) + } try perform(worker) } diff --git a/Tests/NodesTests/InternalTests/WorkerControllerTests.swift b/Tests/NodesTests/InternalTests/WorkerControllerTests.swift index 46a8ccc00..35176a4d7 100644 --- a/Tests/NodesTests/InternalTests/WorkerControllerTests.swift +++ b/Tests/NodesTests/InternalTests/WorkerControllerTests.swift @@ -14,11 +14,13 @@ import XCTest final class WorkerControllerTests: XCTestCase, TestCaseHelpers { private var mockWorkers: [WorkerMock]! + private var mockDelegate: WorkerControllerDelegateMock! @MainActor override func setUp() { super.setUp() tearDown(keyPath: \.mockWorkers, initialValue: [WorkerMock(), WorkerMock(), WorkerMock()]) + tearDown(keyPath: \.mockDelegate, initialValue: WorkerControllerDelegateMock()) } @MainActor @@ -58,7 +60,7 @@ final class WorkerControllerTests: XCTestCase, TestCaseHelpers { func testWithFirstWorkerOfType() { let workerController: WorkerController = givenWorkerController(with: mockWorkers) var worker: WorkerMock? - workerController.withFirstWorker(ofType: WorkerMock.self) { worker = $0 } + try? workerController.withFirstWorker(ofType: WorkerMock.self) { worker = $0 } expect(worker) === mockWorkers.first } @@ -72,16 +74,55 @@ final class WorkerControllerTests: XCTestCase, TestCaseHelpers { func testWithWorkersOfType() { let workerController: WorkerController = givenWorkerController(with: mockWorkers) var workers: [WorkerMock] = [] - workerController.withWorkers(ofType: WorkerMock.self) { workers.append($0) } + try? workerController.withWorkers(ofType: WorkerMock.self) { workers.append($0) } expect(workers) == mockWorkers } @MainActor - private func givenWorkerController(with workers: [Worker], start startWorkers: Bool = false) -> WorkerController { - let workerController: WorkerController = .init(workers: workers) + func testWithFirstWorkerOfTypeWhenWorkerNotFoundNotifiesDelegateAndThrows() { + let workerController: WorkerController = givenWorkerController(with: mockWorkers) + let nonExistentType = NonExistentWorkerMock.self + + expect { try workerController.withFirstWorker(ofType: nonExistentType) { _ in } } + .to(throwError(NodeError.workerNotFound(String(describing: nonExistentType)))) + + expect(mockDelegate.lastFailedWorkerType) == String(describing: nonExistentType) + } + + @MainActor + func testWithFirstWorkerOfTypeWhenDelegateIsNilThrowsWithoutNotification() { + let workerController: WorkerController = givenWorkerController(with: mockWorkers, delegate: nil) + let nonExistentType = NonExistentWorkerMock.self + + expect { try workerController.withFirstWorker(ofType: nonExistentType) { _ in } } + .to(throwError(NodeError.workerNotFound(String(describing: nonExistentType)))) + + expect(mockDelegate.lastFailedWorkerType).to(beNil()) + } + + @MainActor + private func givenWorkerController(with workers: [Worker], start startWorkers: Bool = false, delegate: WorkerControllerDelegate? = nil) -> WorkerController { + let workerController: WorkerController = .init(workers: workers, delegate: delegate ?? mockDelegate) expect(workerController).to(notBeNilAndToDeallocateAfterTest()) if startWorkers { workerController.startWorkers() } addTeardownBlock(with: workerController) { $0.stopWorkers() } return workerController } } + +// MARK: - Test Doubles + +private final class WorkerControllerDelegateMock: WorkerControllerDelegate { + private(set) var lastFailedWorkerType: String? + + func workerController(_ controller: WorkerController, didFailToFindWorkerOfType type: String) { + lastFailedWorkerType = type + } +} + +private final class NonExistentWorkerMock: Worker { + var isWorking: Bool = false + + func start() {} + func stop() {} +}