diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 06d44a4..b929fb2 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -14,7 +14,7 @@ jobs: runs-on: macos-latest steps: - name: 🔨 Select Xcode - run: xcodes select 16.1 + run: xcodes select 16.2 - name: Checkout Code uses: actions/checkout@v4 - name: 🛠️ Build @@ -24,11 +24,11 @@ jobs: runs-on: macos-latest steps: - name: 🔨 Select Xcode - run: xcodes select 16.1 + run: xcodes select 16.2 - name: Checkout Code uses: actions/checkout@v4 - name: 🧪 Run tests - run: xcodebuild test -scheme "pingx" -testPlan "pingx" -destination "OS=18.1,name=iPhone 16 Pro" + run: xcodebuild test -scheme "pingx" -testPlan "pingx" -destination "OS=18.2,name=iPhone 16 Pro" - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4.0.1 with: diff --git a/Example/Podfile b/Example/Podfile index eccb62f..fa8af05 100644 --- a/Example/Podfile +++ b/Example/Podfile @@ -1,6 +1,6 @@ use_frameworks! -platform :ios, '10.0' +platform :ios, '16.0' target 'pingx_Example' do pod 'pingx', :path => '../' diff --git a/Example/Podfile.lock b/Example/Podfile.lock index 5a6e190..dcda7f5 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - pingx (1.0.9) + - pingx (1.1.0) DEPENDENCIES: - pingx (from `../`) @@ -9,8 +9,8 @@ EXTERNAL SOURCES: :path: "../" SPEC CHECKSUMS: - pingx: 2f1582e813a34ebcc3915fdaefb09e3dc93f3473 + pingx: dbb0887216ff095cfb8ffad2f7f10f07f71c4053 -PODFILE CHECKSUM: 4c5247958d334b884adb4a4531fca1e4770e66c2 +PODFILE CHECKSUM: ddeffc2916e764f9db59b21e77dbf1eac5714944 COCOAPODS: 1.16.2 diff --git a/Example/pingx.xcodeproj/project.pbxproj b/Example/pingx.xcodeproj/project.pbxproj index 6ccbc4a..271c9f9 100644 --- a/Example/pingx.xcodeproj/project.pbxproj +++ b/Example/pingx.xcodeproj/project.pbxproj @@ -7,26 +7,30 @@ objects = { /* Begin PBXBuildFile section */ - 146872512D23313B00373862 /* Presenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1468724D2D23313B00373862 /* Presenter.swift */; }; + 145E71772E0FFF6100F92C01 /* PingResultDisplayModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 145E71762E0FFF5D00F92C01 /* PingResultDisplayModel.swift */; }; + 1467664E2DE31BFD00B89B10 /* CallbackPingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1467664D2DE31BFD00B89B10 /* CallbackPingView.swift */; }; + 146766502DE31C0E00B89B10 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1467664F2DE31C0E00B89B10 /* HomeView.swift */; }; + 146766522DE31D1000B89B10 /* CallbackPingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 146766512DE31D0A00B89B10 /* CallbackPingViewModel.swift */; }; + 146766552DE322BC00B89B10 /* AsyncPingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 146766542DE322BC00B89B10 /* AsyncPingView.swift */; }; + 146766572DE322C200B89B10 /* AsyncPingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 146766562DE322BF00B89B10 /* AsyncPingViewModel.swift */; }; 146872522D23313B00373862 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 146872472D23313B00373862 /* AppDelegate.swift */; }; - 146872532D23313B00373862 /* PresenterImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1468724E2D23313B00373862 /* PresenterImpl.swift */; }; - 146872542D23313B00373862 /* PingxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1468724C2D23313B00373862 /* PingxView.swift */; }; - 146872552D23313B00373862 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1468724F2D23313B00373862 /* ViewController.swift */; }; 146872572D23313B00373862 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 1468724B2D23313B00373862 /* LaunchScreen.xib */; }; 146872582D23313B00373862 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 146872482D23313B00373862 /* Images.xcassets */; }; FE343DB94858EB002C0DA943 /* Pods_pingx_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7978D2CD5145265FA076CCB8 /* Pods_pingx_Example.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 145E71762E0FFF5D00F92C01 /* PingResultDisplayModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingResultDisplayModel.swift; sourceTree = ""; }; + 1467664D2DE31BFD00B89B10 /* CallbackPingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallbackPingView.swift; sourceTree = ""; }; + 1467664F2DE31C0E00B89B10 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + 146766512DE31D0A00B89B10 /* CallbackPingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallbackPingViewModel.swift; sourceTree = ""; }; + 146766542DE322BC00B89B10 /* AsyncPingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPingView.swift; sourceTree = ""; }; + 146766562DE322BF00B89B10 /* AsyncPingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPingViewModel.swift; sourceTree = ""; }; 1468721D2D2330C500373862 /* pingx_Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = pingx_Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 146872472D23313B00373862 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 146872482D23313B00373862 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; 146872492D23313B00373862 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 1468724A2D23313B00373862 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; - 1468724C2D23313B00373862 /* PingxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingxView.swift; sourceTree = ""; }; - 1468724D2D23313B00373862 /* Presenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presenter.swift; sourceTree = ""; }; - 1468724E2D23313B00373862 /* PresenterImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenterImpl.swift; sourceTree = ""; }; - 1468724F2D23313B00373862 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 16ECE250B3B64C6E99822325 /* Pods-pingx_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-pingx_Example.release.xcconfig"; path = "Target Support Files/Pods-pingx_Example/Pods-pingx_Example.release.xcconfig"; sourceTree = ""; }; 27E15695FB38592072BFDC43 /* Pods-pingx.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-pingx.release.xcconfig"; path = "Target Support Files/Pods-pingx/Pods-pingx.release.xcconfig"; sourceTree = ""; }; 7978D2CD5145265FA076CCB8 /* Pods_pingx_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_pingx_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -46,6 +50,50 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 145E71752E0FFF5400F92C01 /* DisplayModels */ = { + isa = PBXGroup; + children = ( + 145E71762E0FFF5D00F92C01 /* PingResultDisplayModel.swift */, + ); + path = DisplayModels; + sourceTree = ""; + }; + 146766482DE31BC200B89B10 /* Views */ = { + isa = PBXGroup; + children = ( + 146766532DE322AE00B89B10 /* AsyncPingView */, + 1467664A2DE31BD400B89B10 /* HomeView */, + 146766492DE31BCD00B89B10 /* CallbackPingView */, + ); + path = Views; + sourceTree = ""; + }; + 146766492DE31BCD00B89B10 /* CallbackPingView */ = { + isa = PBXGroup; + children = ( + 146766512DE31D0A00B89B10 /* CallbackPingViewModel.swift */, + 1467664D2DE31BFD00B89B10 /* CallbackPingView.swift */, + ); + path = CallbackPingView; + sourceTree = ""; + }; + 1467664A2DE31BD400B89B10 /* HomeView */ = { + isa = PBXGroup; + children = ( + 1467664F2DE31C0E00B89B10 /* HomeView.swift */, + ); + path = HomeView; + sourceTree = ""; + }; + 146766532DE322AE00B89B10 /* AsyncPingView */ = { + isa = PBXGroup; + children = ( + 146766562DE322BF00B89B10 /* AsyncPingViewModel.swift */, + 146766542DE322BC00B89B10 /* AsyncPingView.swift */, + ); + path = AsyncPingView; + sourceTree = ""; + }; 146872142D2330C500373862 = { isa = PBXGroup; children = ( @@ -67,14 +115,12 @@ 146872502D23313B00373862 /* pingx */ = { isa = PBXGroup; children = ( + 145E71752E0FFF5400F92C01 /* DisplayModels */, + 146766482DE31BC200B89B10 /* Views */, 146872472D23313B00373862 /* AppDelegate.swift */, 146872482D23313B00373862 /* Images.xcassets */, 146872492D23313B00373862 /* Info.plist */, 1468724B2D23313B00373862 /* LaunchScreen.xib */, - 1468724C2D23313B00373862 /* PingxView.swift */, - 1468724D2D23313B00373862 /* Presenter.swift */, - 1468724E2D23313B00373862 /* PresenterImpl.swift */, - 1468724F2D23313B00373862 /* ViewController.swift */, ); path = pingx; sourceTree = ""; @@ -214,11 +260,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 146872512D23313B00373862 /* Presenter.swift in Sources */, + 1467664E2DE31BFD00B89B10 /* CallbackPingView.swift in Sources */, + 146766572DE322C200B89B10 /* AsyncPingViewModel.swift in Sources */, 146872522D23313B00373862 /* AppDelegate.swift in Sources */, - 146872532D23313B00373862 /* PresenterImpl.swift in Sources */, - 146872542D23313B00373862 /* PingxView.swift in Sources */, - 146872552D23313B00373862 /* ViewController.swift in Sources */, + 145E71772E0FFF6100F92C01 /* PingResultDisplayModel.swift in Sources */, + 146766522DE31D1000B89B10 /* CallbackPingViewModel.swift in Sources */, + 146766502DE31C0E00B89B10 /* HomeView.swift in Sources */, + 146766552DE322BC00B89B10 /* AsyncPingView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -244,9 +292,9 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = GX6R5HS8S3; + DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = pingx/Info.plist; @@ -256,7 +304,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -267,6 +315,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -280,9 +329,9 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = GX6R5HS8S3; + DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = pingx/Info.plist; @@ -292,7 +341,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -302,6 +351,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -367,6 +417,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; @@ -422,6 +473,7 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; VALIDATE_PRODUCT = YES; }; name = Release; diff --git a/Example/pingx/AppDelegate.swift b/Example/pingx/AppDelegate.swift index e33f8d0..0f731a3 100644 --- a/Example/pingx/AppDelegate.swift +++ b/Example/pingx/AppDelegate.swift @@ -1,4 +1,4 @@ -import UIKit +import SwiftUI // MARK: - AppDelegate @@ -11,11 +11,8 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - let presenter = PresenterImpl() - let viewController = ViewController(presenter: presenter) - window = UIWindow(frame: UIScreen.main.bounds) - window?.rootViewController = viewController + window?.rootViewController = UIHostingController(rootView: HomeView()) window?.makeKeyAndVisible() return true diff --git a/Sources/pingx/Factory/TimerFactory/Api/TimerFactory.swift b/Example/pingx/DisplayModels/PingResultDisplayModel.swift similarity index 67% rename from Sources/pingx/Factory/TimerFactory/Api/TimerFactory.swift rename to Example/pingx/DisplayModels/PingResultDisplayModel.swift index d78cae0..8d95610 100644 --- a/Sources/pingx/Factory/TimerFactory/Api/TimerFactory.swift +++ b/Example/pingx/DisplayModels/PingResultDisplayModel.swift @@ -22,13 +22,22 @@ // SOFTWARE. // -import Foundation +import pingx -// sourcery: AutoMockable -protocol TimerFactory { - func createDispatchSourceTimer( - timeInterval: TimeInterval, - eventQueue: DispatchQueue, - eventHandler: @escaping () -> Void - ) -> PingxTimer +struct PingResultDisplayModel { + let pingResult: PingResult + + func makeUserFriendlyMessage() -> String { + switch pingResult { + case .success(let response): + return """ + Success + Destination: \(response.destination.address), + Response time: \(String(format: "%.05f", response.duration.milliseconds)) ms, + Sequence number: \(response.sequenceNumber) + """ + case .failure(let error): + return "Error: \(error.errorDescription ?? "")" + } + } } diff --git a/Example/pingx/PingxView.swift b/Example/pingx/PingxView.swift deleted file mode 100644 index 5fb60d1..0000000 --- a/Example/pingx/PingxView.swift +++ /dev/null @@ -1,3 +0,0 @@ -// MARK: - PingxView - -protocol PingxView: AnyObject {} diff --git a/Example/pingx/Presenter.swift b/Example/pingx/Presenter.swift deleted file mode 100644 index 604f383..0000000 --- a/Example/pingx/Presenter.swift +++ /dev/null @@ -1,5 +0,0 @@ -// MARK: - Presenter - -protocol Presenter: AnyObject { - func didTapSendButton() -} diff --git a/Example/pingx/PresenterImpl.swift b/Example/pingx/PresenterImpl.swift deleted file mode 100644 index 14a4ce3..0000000 --- a/Example/pingx/PresenterImpl.swift +++ /dev/null @@ -1,54 +0,0 @@ -import pingx - -// MARK: - PresenterImpl - -final class PresenterImpl { - - // MARK: Properties - - private let pinger: Pinger - - // MARK: Initializer - - init(pinger: Pinger = ContinuousPinger()) { - self.pinger = pinger - pinger.delegate = self - } -} - -// MARK: - Presenter - -extension PresenterImpl: Presenter { - func didTapSendButton() { - let request = Request(destination: Constants.destinationAddress, demand: .unlimited) - pinger.ping(request: request) - } -} - -// MARK: - PingerDelegate - -extension PresenterImpl: PingerDelegate { - func pinger( - _ pinger: Pinger, - request: Request, - didReceive response: Response - ) { - print("Destination: \(request.destination)\nResponse: \(response)") - } - - func pinger( - _ pinger: Pinger, - request: Request, - didCompleteWithError error: PingerError - ) { - print("Destination: \(request.destination)\nError: \(error)") - } -} - -// MARK: - Constants - -private extension PresenterImpl { - enum Constants { - static var destinationAddress: IPv4Address { .init(address: (8, 8, 8, 8)) } - } -} diff --git a/Example/pingx/ViewController.swift b/Example/pingx/ViewController.swift deleted file mode 100644 index ba72ae7..0000000 --- a/Example/pingx/ViewController.swift +++ /dev/null @@ -1,77 +0,0 @@ -import UIKit - -// MARK: - ViewController - -final class ViewController: UIViewController { - - // MARK: UI Components - - private lazy var sendButton: UIButton = { - let button = UIButton(type: .system) - - button.translatesAutoresizingMaskIntoConstraints = false - button.setTitle("Send", for: .normal) - button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside) - - return button - }() - - // MARK: Properties - - private let presenter: Presenter - - // MARK: Initializer - - init(presenter: Presenter) { - self.presenter = presenter - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -// MARK: - Override - -extension ViewController { - override func viewDidLoad() { - super.viewDidLoad() - setUp() - } -} - -// MARK: - PingxView - -extension ViewController: PingxView {} - -// MARK: - Actions Extension - -private extension ViewController { - @objc - func didTapButton() { - presenter.didTapSendButton() - } -} - -// MARK: - UI Extension - -private extension ViewController { - func setUp() { - setUpHierarchy() - setUpConstraints() - } - - func setUpHierarchy() { - view.backgroundColor = .white - view.addSubview(sendButton) - } - - func setUpConstraints() { - NSLayoutConstraint.activate([ - sendButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - sendButton.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - } -} diff --git a/Example/pingx/Views/AsyncPingView/AsyncPingView.swift b/Example/pingx/Views/AsyncPingView/AsyncPingView.swift new file mode 100644 index 0000000..53e383a --- /dev/null +++ b/Example/pingx/Views/AsyncPingView/AsyncPingView.swift @@ -0,0 +1,53 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI + +struct AsyncPingView: View { + @StateObject private var viewModel = AsyncPingViewModel() + + var body: some View { + VStack { + if viewModel.isPingActive { + Button("Stop pinging", action: viewModel.stopPinging) + } else { + Button("Start pinging", action: viewModel.startPinging) + } + + List { + ForEach(viewModel.pingResultDisplayModels.indices, id: \.self) { index in + let displayModel = viewModel.pingResultDisplayModels[index] + Text(displayModel.makeUserFriendlyMessage()) + } + } + } + .onDisappear { + viewModel.stopPinging() + } + } +} + +#Preview { + AsyncPingView() +} diff --git a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift new file mode 100644 index 0000000..8fe89e9 --- /dev/null +++ b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift @@ -0,0 +1,81 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import pingx + +@MainActor +final class AsyncPingViewModel: ObservableObject { + private enum Constants { + static var destinationAddress: IPv4Address { IPv4Address(address: (8, 8, 8, 8)) } + } + + @Published private(set) var isPingActive = false + @Published private(set) var pingResultDisplayModels: [PingResultDisplayModel] = [] + + private let pinger: AsyncPingerProtocol + private var pingTask: Task? + + init(pinger: AsyncPingerProtocol) { + self.pinger = pinger + } + + convenience init() { + self.init( + pinger: AsyncPinger(configuration: .default) + ) + } + + func startPinging() { + isPingActive = true + pingResultDisplayModels.removeAll() + + let request = Request( + destination: Constants.destinationAddress, + demand: .max(5) + ) + + pingTask = Task { [weak self, pinger] in + let sequence = pinger.ping(request: request) + + for try await result in sequence { + guard !Task.isCancelled else { break } + + DispatchQueue.main.async { [weak self] in + let displayModel = PingResultDisplayModel(pingResult: result) + self?.pingResultDisplayModels.append(displayModel) + } + } + + DispatchQueue.main.async { [weak self] in + self?.isPingActive = false + } + } + } + + func stopPinging() { + pingTask?.cancel() + pingTask = nil + isPingActive = false + } +} diff --git a/Example/pingx/Views/CallbackPingView/CallbackPingView.swift b/Example/pingx/Views/CallbackPingView/CallbackPingView.swift new file mode 100644 index 0000000..22b9dd8 --- /dev/null +++ b/Example/pingx/Views/CallbackPingView/CallbackPingView.swift @@ -0,0 +1,54 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI +import pingx + +struct CallbackPingView: View { + @StateObject private var viewModel = CallbackPingViewModel() + + var body: some View { + VStack { + if viewModel.isPingActive { + Button("Stop pinging", action: viewModel.stopPinging) + } else { + Button("Start pinging", action: viewModel.startPinging) + } + + List { + ForEach(viewModel.pingResultDisplayModels.indices, id: \.self) { index in + let displayModel = viewModel.pingResultDisplayModels[index] + Text(displayModel.makeUserFriendlyMessage()) + } + } + } + .onDisappear { + viewModel.stopPinging() + } + } +} + +#Preview { + CallbackPingView() +} diff --git a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift new file mode 100644 index 0000000..01a5b65 --- /dev/null +++ b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift @@ -0,0 +1,80 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import pingx + +@MainActor +final class CallbackPingViewModel: ObservableObject { + private enum Constants { + static var destinationAddress: IPv4Address { IPv4Address(address: (8, 8, 8, 8)) } + } + + @Published private(set) var isPingActive = false + @Published private(set) var pingResultDisplayModels = [PingResultDisplayModel]() + + private let pinger: PingerProtocol + private var activeRequest: Request? + + init(pinger: PingerProtocol) { + self.pinger = pinger + } + + convenience init() { + self.init( + pinger: Pinger(configuration: .default) + ) + } + + func startPinging() { + isPingActive = true + pingResultDisplayModels.removeAll() + + let demandCount = 5 + let request = Request( + destination: Constants.destinationAddress, + demand: .max(UInt(demandCount)) + ) + + activeRequest = request + pinger.ping(request: request) { [weak self] result in + DispatchQueue.main.async { + let displayModel = PingResultDisplayModel(pingResult: result) + self?.pingResultDisplayModels.append(displayModel) + + if self?.pingResultDisplayModels.count == demandCount { + self?.isPingActive = false + } + } + } + } + + func stopPinging() { + isPingActive = false + + guard let activeRequestId = activeRequest?.identifier else { return } + pinger.cancel(requestId: activeRequestId) + + activeRequest = nil + } +} diff --git a/Example/pingx/Views/HomeView/HomeView.swift b/Example/pingx/Views/HomeView/HomeView.swift new file mode 100644 index 0000000..3c0207c --- /dev/null +++ b/Example/pingx/Views/HomeView/HomeView.swift @@ -0,0 +1,56 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI + +struct HomeView: View { + private enum Destination: Hashable { + case asyncPing + case callbackPing + } + + @State private var navigationPath = NavigationPath() + + var body: some View { + NavigationStack(path: $navigationPath) { + List { + NavigationLink("AsyncPinger", value: Destination.asyncPing) + NavigationLink("Pinger", value: Destination.callbackPing) + } + .navigationTitle("pingx") + .navigationDestination(for: Destination.self) { destination in + switch destination { + case .asyncPing: + AsyncPingView() + case .callbackPing: + CallbackPingView() + } + } + } + } +} + +#Preview { + HomeView() +} diff --git a/LICENSE b/LICENSE index 92d02bc..5dec943 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2024 shineRR +Copyright (c) 2025 shineRR Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 241aae8..33eaf4a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ## Introduction - This ultralight and easy-to-use library is designed to help developers accurately measure and analyze network ping latency in their applications. Whether you're working on a project that requires real-time communication, online gaming, or network performance monitoring, this library provides a seamless solution to assess and optimize ping times. +**pingx** is a lightweight Swift library for determining network latency between a client and server using ICMP (Internet Control Message Protocol) packets. It provides a simple and flexible API for sending and managing ping requests to IPv4 addresses. ## Installation @@ -33,30 +33,86 @@ To integrate pingx into your Xcode project using Swift Package Manager, add the ```swift dependencies: [ - .package(url: "https://github.com/shineRR/pingx", .upToNextMajor(from: "1.0.0")) + .package(url: "https://github.com/shineRR/pingx", .upToNextMajor(from: "1.1.0")) ] ``` -## Example +## Usage -To run the example project, clone the repo, and run `pod install` from the Example directory first. +### IPv4 Address Conversion + +```swift +import pingx + +let converter = IPv4AddressStringConverter() +let destination = try converter.convert(address: "8.8.8.8") +``` -## Integration +### Request -Import the pingx module into your Swift code and initialize the Pinger instance. +The Request class represents a single ping request configuration. It encapsulates all necessary information to perform an ICMP ping to a specified IPv4 address. ```swift import pingx -let pinger = ContinuousPinger() -pinger.delegate = self - let destination = IPv4Address(address: (8, 8, 8, 8)) -let request = Request(destination: destination, demand: .unlimited) +let request = Request( + destination: destination, // Destination + timeoutInterval: .seconds(1), // Timeout interval. Available options for timeout interval: .seconds, .milliseconds, .nanoseconds + demand: .max(5) // Send 5 ping requests (default is 1). +) // Available options for demand: + // - .none: send no requests + // - .max(n): send up to n requests + // - .unlimited: send unlimited requests + +``` + +### Asynchronous Pinging + +Ping example: +```swift +import pingx + +let pinger = AsyncPinger() +let request = Request(destination: destination) +let sequence = pinger.ping(request: request) + +for try await result in sequence { + print("Result: \(result)") +} +``` + +### Callback-based Pinging + +Ping example: +```swift +import pingx + +let pinger = Pinger() +let request = Request(destination: destination) + +pinger.ping(request: request) { result in + print("Result: \(result)") +} +``` -pinger.ping(request: request) +Request cancellation example: +```swift +import pingx + +let pinger = Pinger() +let request = Request(destination: destination) + +pinger.ping(request: request) { result in + print("Result: \(result)") +} +pinger.cancel(requestId: request.id) ``` +## Example + +To run the example project, clone the repo, and run `pod install` from the Example directory first. + ## Author pingx is developed and maintained by [shineRR](https://github.com/shineRR). diff --git a/Sources/pingx/Checksum/Impl/ICMPChecksum.swift b/Sources/pingx/Checksum/ICMPChecksum.swift similarity index 67% rename from Sources/pingx/Checksum/Impl/ICMPChecksum.swift rename to Sources/pingx/Checksum/ICMPChecksum.swift index 33bc8df..a0821ad 100644 --- a/Sources/pingx/Checksum/Impl/ICMPChecksum.swift +++ b/Sources/pingx/Checksum/ICMPChecksum.swift @@ -28,38 +28,28 @@ struct ICMPChecksum { func callAsFunction(icmpHeader: ICMPHeader) throws -> UInt16 { let typecode = Data([icmpHeader.type, icmpHeader.code]).withUnsafeBytes { $0.load(as: UInt16.self) } var sum = UInt64(typecode) + UInt64(icmpHeader.identifier) + UInt64(icmpHeader.sequenceNumber) - let payload = arrayPayload(icmpHeader.payload) - - for i in stride(from: 0, to: payload.count, by: 2) { - sum += Data([payload[i], payload[i + 1]]).withUnsafeBytes { UInt64($0.load(as: UInt16.self)) } + let payloadBytes = icmpHeader.payload.bytes + + for offset in stride(from: 0, to: payloadBytes.count, by: 2) { + sum += Data([ + payloadBytes[offset], + payloadBytes[offset + 1] + ]).withUnsafeBytes { UInt64($0.load(as: UInt16.self)) } } - + sum = (sum >> 16) + (sum & 0xFFFF) sum += sum >> 16 - + guard sum >= UInt16.min, sum <= UInt16.max else { throw ChecksumError.outOfBounds } - + return ~UInt16(sum) } } extension ICMPChecksum { - enum ChecksumError: Error { - case outOfBounds - } -} + enum ChecksumError: CustomNSError { + static let errorDomain = "pingx.ChecksumError" -private extension ICMPChecksum { - func arrayPayload(_ payload: Payload) -> [UInt8] { - let identifier: [UInt8] = [ - payload.identifier.0, payload.identifier.1, payload.identifier.2, payload.identifier.3, - payload.identifier.4, payload.identifier.5, payload.identifier.6, payload.identifier.7 - ] - var timestamp = payload.timestamp - - return identifier + Data( - bytes: ×tamp, - count: MemoryLayout.size - ).withUnsafeBytes { Array($0) } + case outOfBounds } } diff --git a/Sources/pingx/Converter/Api/IPv4AddressConverterApi.swift b/Sources/pingx/Converter/Api/IPv4AddressStringConverter.swift similarity index 96% rename from Sources/pingx/Converter/Api/IPv4AddressConverterApi.swift rename to Sources/pingx/Converter/Api/IPv4AddressStringConverter.swift index 18935c2..d1598da 100644 --- a/Sources/pingx/Converter/Api/IPv4AddressConverterApi.swift +++ b/Sources/pingx/Converter/Api/IPv4AddressStringConverter.swift @@ -23,7 +23,5 @@ // public protocol IPv4AddressStringConverter { - - /// Converts a string to `IPv4Address`. func convert(address: String) throws -> IPv4Address } diff --git a/Sources/pingx/Converter/Error/IPv4AddressConverterError.swift b/Sources/pingx/Converter/Error/IPv4AddressConverterError.swift index 39ab8ff..a47093e 100644 --- a/Sources/pingx/Converter/Error/IPv4AddressConverterError.swift +++ b/Sources/pingx/Converter/Error/IPv4AddressConverterError.swift @@ -25,7 +25,7 @@ import Foundation public enum IPv4AddressConverterError: CustomNSError { - public static let errorDomain = "com.pingx.IPAddressConverterError" + public static let errorDomain = "pingx.IPAddressConverterError" case invalidAddress case octetOutOfRange diff --git a/Sources/pingx/Converter/Impl/IPv4AddressConverter.swift b/Sources/pingx/Converter/Impl/IPv4AddressConverter.swift index 79b30ca..583ab0d 100644 --- a/Sources/pingx/Converter/Impl/IPv4AddressConverter.swift +++ b/Sources/pingx/Converter/Impl/IPv4AddressConverter.swift @@ -26,20 +26,19 @@ import Foundation public struct IPv4AddressConverter: IPv4AddressStringConverter { private enum Constants { - static var ipv4OctetsCount: Int { 4 } - static var allowedCharacters: CharacterSet { - CharacterSet.decimalDigits.union(CharacterSet(charactersIn: ".-")) - } + static let ipv4OctetsCount = 4 + static let separator = "." + static let allowedCharacters = CharacterSet.decimalDigits.union(CharacterSet(charactersIn: ".-")) } public init() {} public func convert(address: String) throws -> IPv4Address { let components = address.trimmingCharacters(in: Constants.allowedCharacters.inverted) - .components(separatedBy: ".") + .components(separatedBy: Constants.separator) .compactMap(Int.init) guard components.count == Constants.ipv4OctetsCount else { throw IPv4AddressConverterError.invalidAddress } - + let ipv4Octets = components.compactMap(UInt8.init) guard ipv4Octets.count == Constants.ipv4OctetsCount else { throw IPv4AddressConverterError.octetOutOfRange } diff --git a/Sources/pingx/Factory/PacketFactory/Api/PacketFactory.swift b/Sources/pingx/Extensions/Result+Extensions.swift similarity index 89% rename from Sources/pingx/Factory/PacketFactory/Api/PacketFactory.swift rename to Sources/pingx/Extensions/Result+Extensions.swift index 020470a..0b06629 100644 --- a/Sources/pingx/Factory/PacketFactory/Api/PacketFactory.swift +++ b/Sources/pingx/Extensions/Result+Extensions.swift @@ -22,7 +22,9 @@ // SOFTWARE. // -// sourcery: AutoMockable -protocol PacketFactory { - func create(identifier: UInt16, type: PacketType) throws -> Packet +extension Result { + var error: Failure? { + guard case .failure(let error) = self else { return nil } + return error + } } diff --git a/Sources/pingx/Extensions/String+Extensions.swift b/Sources/pingx/Extensions/String+Extensions.swift index a5235a3..8b7530c 100644 --- a/Sources/pingx/Extensions/String+Extensions.swift +++ b/Sources/pingx/Extensions/String+Extensions.swift @@ -24,12 +24,9 @@ import Foundation -// MARK: - String+Extensions - -public extension String { +extension String { var socketAddress: Data { var socketAddress = sockaddr_in() - socketAddress.sin_len = UInt8(MemoryLayout.size) socketAddress.sin_family = UInt8(AF_INET) socketAddress.sin_port = .zero diff --git a/Sources/pingx/Factory/PacketFactory/Impl/PacketFactoryImpl.swift b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift similarity index 69% rename from Sources/pingx/Factory/PacketFactory/Impl/PacketFactoryImpl.swift rename to Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift index a852999..2669d0c 100644 --- a/Sources/pingx/Factory/PacketFactory/Impl/PacketFactoryImpl.swift +++ b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift @@ -24,28 +24,18 @@ import Foundation -final class PacketFactoryImpl: PacketFactory { - func create(identifier: UInt16, type: PacketType) throws -> Packet { - let packet: Packet - - switch type { - case .icmp: - packet = try icmpPacket(identifier: identifier) - } - - return packet - } +// sourcery: AutoMockable +protocol ICMPHeaderFactoryProtocol { + func make(from request: Request) throws -> ICMPHeader } -// MARK: - Private API - -private extension PacketFactoryImpl { - func icmpPacket(identifier: UInt16) throws -> Packet { +struct ICMPHeaderFactory: ICMPHeaderFactoryProtocol { + func make(from request: Request) throws -> ICMPHeader { var icmpHeader = ICMPHeader( - type: .echoRequest, - identifier: identifier, - sequenceNumber: CFSwapInt16HostToBig(UInt16.random(in: 0..) throws -> any PingxSocket -} diff --git a/Sources/pingx/Factory/SocketFactory/Impl/SocketFactoryImpl.swift b/Sources/pingx/Factory/SocketFactory/SocketFactory.swift similarity index 79% rename from Sources/pingx/Factory/SocketFactory/Impl/SocketFactoryImpl.swift rename to Sources/pingx/Factory/SocketFactory/SocketFactory.swift index 8075218..131f59a 100644 --- a/Sources/pingx/Factory/SocketFactory/Impl/SocketFactoryImpl.swift +++ b/Sources/pingx/Factory/SocketFactory/SocketFactory.swift @@ -24,10 +24,13 @@ import Foundation -final class SocketFactoryImpl: SocketFactory { - typealias SocketCommand = CommandBlock - - func create(command: SocketCommand) throws -> any PingxSocket { +// sourcery: AutoMockable +protocol SocketFactoryProtocol { + func make(command: CommandBlock) throws -> any PingxSocketProtocol +} + +struct SocketFactory: SocketFactoryProtocol { + func make(command: CommandBlock) throws -> any PingxSocketProtocol { let unmanaged = Unmanaged.passRetained(command) var context = CFSocketContext( version: .zero, @@ -48,14 +51,14 @@ final class SocketFactoryImpl: SocketFactory { (callbackType as CFSocketCallBackType) == CFSocketCallBackType.dataCallBack else { return } - let commandBlock = Unmanaged.fromOpaque(info).takeUnretainedValue() + let commandBlock = Unmanaged>.fromOpaque(info).takeUnretainedValue() let cfdata = Unmanaged.fromOpaque(data).takeUnretainedValue() commandBlock.closure(cfdata as Data) }, &context ) - guard let socket = socket else { throw PacketSenderError.socketCreationError } + guard let socket = socket else { throw AsyncPingerError.socketCreationError } let native = CFSocketGetNative(socket) var value: Int32 = 1 @@ -66,22 +69,20 @@ final class SocketFactoryImpl: SocketFactory { &value, socklen_t(MemoryLayout.size(ofValue: value)) ) == .zero else { - throw PacketSenderError.socketCreationError + throw AsyncPingerError.socketCreationError } - guard let socketSource = CFSocketCreateRunLoopSource( - kCFAllocatorDefault, - socket, - .zero - ) else { throw PacketSenderError.socketCreationError } - + guard let socketSource = CFSocketCreateRunLoopSource(kCFAllocatorDefault, socket, .zero) else { + throw AsyncPingerError.socketCreationError + } + CFRunLoopAddSource( CFRunLoopGetMain(), socketSource, .commonModes ) - return PingxSocketImpl( + return PingxSocket( socket: socket, socketSource: socketSource, unmanaged: unmanaged diff --git a/Sources/pingx/Model/Atomic/Atomic.swift b/Sources/pingx/Model/Atomic/Atomic.swift index 8647f56..cc63813 100644 --- a/Sources/pingx/Model/Atomic/Atomic.swift +++ b/Sources/pingx/Model/Atomic/Atomic.swift @@ -26,19 +26,19 @@ import Foundation @propertyWrapper final class Atomic { - + // MARK: Properties - - private let queue = DispatchQueue(label: UUID().uuidString) + + private let queue = DispatchQueue(label: "pingx.Atomic.syncQueue") private var value: T - + var wrappedValue: T { get { queue.sync { value } } set { queue.sync { value = newValue } } } - + // MARK: Initializer - + init(wrappedValue: T) { self.value = wrappedValue } diff --git a/Sources/pingx/Model/CommandBlock/CommandBlock.swift b/Sources/pingx/Model/CommandBlock/CommandBlock.swift index dd22f40..baf891b 100644 --- a/Sources/pingx/Model/CommandBlock/CommandBlock.swift +++ b/Sources/pingx/Model/CommandBlock/CommandBlock.swift @@ -22,14 +22,14 @@ // SOFTWARE. // -public final class CommandBlock { - +final class CommandBlock { + // MARK: Properties - - public let closure: (T) -> Void - + + let closure: (T) -> Void + // MARK: Initializer - + init(closure: @escaping (T) -> Void) { self.closure = closure } diff --git a/Sources/pingx/Model/IP/IPv4/IPv4Address.swift b/Sources/pingx/Model/IP/IPv4/IPv4Address.swift index b1b879f..e425ad0 100644 --- a/Sources/pingx/Model/IP/IPv4/IPv4Address.swift +++ b/Sources/pingx/Model/IP/IPv4/IPv4Address.swift @@ -24,8 +24,8 @@ import Foundation -public struct IPv4Address { - +public struct IPv4Address: Hashable, Sendable { + // MARK: Properties public let address: (UInt8, UInt8, UInt8, UInt8) @@ -40,11 +40,9 @@ public struct IPv4Address { let converter = IPv4AddressConverter() self.address = try converter.convert(address: address).address } -} - -// MARK: - Hashable - -extension IPv4Address: Hashable { + + // MARK: Methods + public static func == (lhs: IPv4Address, rhs: IPv4Address) -> Bool { lhs.address.0 == rhs.address.0 && lhs.address.1 == rhs.address.1 && @@ -57,18 +55,14 @@ extension IPv4Address: Hashable { hasher.combine(address.1) hasher.combine(address.2) hasher.combine(address.3) - _ = hasher.finalize() } } // MARK: - Internal API extension IPv4Address { - var stringAddress: String { - "\(address.0).\(address.1).\(address.2).\(address.3)" - } - var socketAddress: Data { - stringAddress.socketAddress + let stringAddress = "\(address.0).\(address.1).\(address.2).\(address.3)" + return stringAddress.socketAddress } } diff --git a/Sources/pingx/Model/IPHeader/IPHeader.swift b/Sources/pingx/Model/IPHeader/IPHeader.swift index 1e9a73a..9f493ff 100644 --- a/Sources/pingx/Model/IPHeader/IPHeader.swift +++ b/Sources/pingx/Model/IPHeader/IPHeader.swift @@ -22,20 +22,20 @@ // SOFTWARE. // -public struct IPHeader { +struct IPHeader: Equatable { // MARK: Properties - public let versionAndHeaderLength: UInt8 - public let serviceType: UInt8 - public let totalLength: UInt16 // Total length of the header and data portion of the packet, counted in octets. - public let identifier: UInt16 - public let flagsAndFragmentOffset: UInt16 // Ripped from the IP protocol. - public let timeToLive: UInt8 - public let `protocol`: UInt8 - public let headerChecksum: UInt16 - public let sourceAddress: IPv4Address - public let destinationAddress: IPv4Address + let versionAndHeaderLength: UInt8 + let serviceType: UInt8 + let totalLength: UInt16 // Total length of the header and data portion of the packet, counted in octets. + let identifier: UInt16 + let flagsAndFragmentOffset: UInt16 // Ripped from the IP protocol. + let timeToLive: UInt8 + let `protocol`: UInt8 + let headerChecksum: UInt16 + let sourceAddress: IPv4Address + let destinationAddress: IPv4Address // MARK: Initializer diff --git a/Sources/pingx/Pinger/Configuration/PingerConfiguration.swift b/Sources/pingx/Model/Identifier/PingxIdentifier.swift similarity index 80% rename from Sources/pingx/Pinger/Configuration/PingerConfiguration.swift rename to Sources/pingx/Model/Identifier/PingxIdentifier.swift index f1bc72b..2ef1652 100644 --- a/Sources/pingx/Pinger/Configuration/PingerConfiguration.swift +++ b/Sources/pingx/Model/Identifier/PingxIdentifier.swift @@ -24,16 +24,15 @@ import Foundation -public struct PingerConfiguration { - - // MARK: Properties - - /// The interval between requests. - public let interval: DispatchTimeInterval - - // MARK: Initializer - - public init(interval: DispatchTimeInterval = .seconds(1)) { - self.interval = interval +public struct PingxIdentifier: Hashable, Sendable { + let id: UInt16 + let uniqueToken: UUID + + init( + id: UInt16 = CFSwapInt16HostToBig(UInt16.random(in: 0.. Void - - init( - timeInterval: TimeInterval, - eventQueue: DispatchQueue, - eventHandler: @escaping () -> Void - ) { - self.timeinterval = timeInterval - self.eventQueue = eventQueue - self.eventHandler = eventHandler +public enum Interval: Sendable, Hashable { + case seconds(TimeInterval) + case milliseconds(TimeInterval) + case nanoseconds(TimeInterval) + + public var seconds: TimeInterval { + switch self { + case .seconds(let value): + return value + case .milliseconds(let value): + return value / 1_000 + case .nanoseconds(let value): + return value / 1_000_000_000 + } } - func start() { - guard dispatchTimer == nil else { return } - - dispatchTimer = DispatchSource.makeTimerSource(queue: eventQueue) - dispatchTimer?.schedule(deadline: .now(), repeating: timeinterval) - dispatchTimer?.setEventHandler( - handler: DispatchWorkItem(block: eventHandler) - ) - dispatchTimer?.resume() + public var milliseconds: TimeInterval { + switch self { + case .seconds(let value): + return value * 1_000 + case .milliseconds(let value): + return value + case .nanoseconds(let value): + return value / 1_000_000 + } } - - func stop() { - dispatchTimer?.cancel() - dispatchTimer = nil + + public var nanoseconds: TimeInterval { + switch self { + case .seconds(let value): + return value * 1_000_000_000 + case .milliseconds(let value): + return value * 1_000_000 + case .nanoseconds(let value): + return value + } } } diff --git a/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift b/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift index 37bffd6..8bc64ae 100644 --- a/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift +++ b/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift @@ -24,7 +24,7 @@ import Foundation -struct ICMPHeader { +struct ICMPHeader: Equatable { // MARK: Properties @@ -60,9 +60,7 @@ struct ICMPHeader { } } -// MARK: - Packet - -extension ICMPHeader: Packet { +extension ICMPHeader { var data: Data { var packet = self return Data(bytes: &packet, count: MemoryLayout.size) diff --git a/Sources/pingx/Model/Packets/ICMP/ICMPPacket.swift b/Sources/pingx/Model/Packets/ICMP/ICMPPacket.swift index f0e2d7b..473718b 100644 --- a/Sources/pingx/Model/Packets/ICMP/ICMPPacket.swift +++ b/Sources/pingx/Model/Packets/ICMP/ICMPPacket.swift @@ -22,10 +22,10 @@ // SOFTWARE. // -struct ICMPPacket { - +struct ICMPPacket: Equatable { + // MARK: Properties - + let ipHeader: IPHeader let icmpHeader: ICMPHeader } diff --git a/Sources/pingx/Model/Packets/ICMP/ICMPType.swift b/Sources/pingx/Model/Packets/ICMP/ICMPType.swift index b6ef32a..34c6bba 100644 --- a/Sources/pingx/Model/Packets/ICMP/ICMPType.swift +++ b/Sources/pingx/Model/Packets/ICMP/ICMPType.swift @@ -22,56 +22,56 @@ // SOFTWARE. // -enum ICMPType: UInt8 { - +enum ICMPType: UInt8, Hashable { + /// Echo reply (used to ping) case echoReply = 0 - + /// Destination network unreachable case destinationUnreachable = 3 - + /// Source quench (congestion control) case sourceQuench = 4 - + /// Redirect Datagram for the Network case redirectMessage = 5 - + /// Echo request (used to ping) case echoRequest = 8 - + /// Router Advertisement case routerAdvertisement = 9 - + /// Router discovery/selection/solicitation case routerSolicitation = 10 - + /// TTL expired in transit / Fragment reassembly time exceeded case timeExceeded = 11 - + /// Parameter Problem: Bad IP header case badIpHeader = 12 - + /// Timestamp case timestamp = 13 - + /// Timestamp reply case timestampReply = 14 - + /// Information Request(deprecated case informationRequest = 15 - + /// Information Reply(deprecated) case informationReply = 16 - + /// Address Mask Request(deprecated) case addressMaskRequest = 17 - + /// Address Mask Reply(deprecated) case addressMaskReply = 18 - + /// Request Extended Echo (XPing) case extendedEchoRequest = 42 - + // Extended Echo Reply case extendedEchoReply = 43 } diff --git a/Sources/pingx/Model/Payload/Payload.swift b/Sources/pingx/Model/Payload/Payload.swift index e917f38..95e98bb 100644 --- a/Sources/pingx/Model/Payload/Payload.swift +++ b/Sources/pingx/Model/Payload/Payload.swift @@ -24,25 +24,35 @@ import Foundation -struct Payload { - - // MARK: Typealias - - typealias PayloadID = (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) - +struct Payload: Equatable { + // MARK: Properties - - // "pingx" - let identifier: PayloadID + + let rawUniqueToken: uuid_t let timestamp: CFAbsoluteTime - + // MARK: Initializer - + init( - identifier: PayloadID = (112, 105, 110, 103, 120, 0, 0, 0), + rawUniqueToken: uuid_t, timestamp: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() ) { - self.identifier = identifier + self.rawUniqueToken = rawUniqueToken self.timestamp = timestamp } + + static func == (lhs: Payload, rhs: Payload) -> Bool { + lhs.bytes == rhs.bytes + } +} + +extension Payload { + var bytes: [UInt8] { + var result = [UInt8]() + + withUnsafeBytes(of: rawUniqueToken) { result.append(contentsOf: $0) } + withUnsafeBytes(of: timestamp) { result.append(contentsOf: $0) } + + return result + } } diff --git a/Sources/pingx/Model/Request/Request.swift b/Sources/pingx/Model/Request/Request.swift index 97e7f86..f0a072d 100644 --- a/Sources/pingx/Model/Request/Request.swift +++ b/Sources/pingx/Model/Request/Request.swift @@ -24,117 +24,145 @@ import Foundation -public final class Request: Identifiable, Equatable { - +public struct Request: Hashable, Sendable { + + public typealias Identifier = PingxIdentifier + // MARK: Properties - + /// The unique identifier for the request. - public let id = CFSwapInt16HostToBig(UInt16.random(in: 0.. Bool { - lhs.id == rhs.id && lhs.destination == rhs.destination + lhs.identifier == rhs.identifier && + lhs.type == rhs.type && + lhs.destination == rhs.destination && + lhs.timeoutInterval == rhs.timeoutInterval } - - func setTimeRemainingUntilDeadline(_ timeRemainingUntilDeadline: TimeInterval) { - self.timeRemainingUntilDeadline = timeRemainingUntilDeadline + + public func hash(into hasher: inout Hasher) { + hasher.combine(identifier) + hasher.combine(type) + hasher.combine(destination) + hasher.combine(timeoutInterval) + hasher.combine(demand) + hasher.combine(sequenceNumber) } - - func setDemand(_ demand: Request.Demand) { + + mutating func setDemand(_ demand: Demand) { self.demand = demand } - - func decreaseDemandAndUpdateTimeRemainingUntilDeadline() { - setDemand(demand - .max(1)) - setTimeRemainingUntilDeadline(timeoutInterval) + + mutating func decreaseDemand() { + demand = demand - .max(1) + } + + mutating func incrementSequenceNumber() { + let (result, overflow) = sequenceNumber.addingReportingOverflow(1) + sequenceNumber = overflow ? .zero : result } } // MARK: - Demand public extension Request { - struct Demand: Equatable, Hashable { - + struct Demand: Hashable, Sendable { + // MARK: Properties - + /// Represents the current demand, which indicates the number of values requested. - public let max: Int? - + public let max: UInt? + // MARK: Initializer - - init(max: Int?) { + + init(max: UInt?) { self.max = max } - + // MARK: Static - + /// A request for as many values as the pinger can produce. public static let unlimited = Request.Demand(max: nil) - + /// A request for no elements from the pinger. /// /// This is equivalent to `Demand.max(0)`. public static let none = Request.Demand(max: .zero) - + /// Creates a demand for the given maximum number of elements. /// - /// - Parameter value: The maximum number of elements. Providing a negative value for this parameter results in a fatal error. - public static func max(_ max: Int) -> Demand { - guard max >= .zero else { FatalError.trigger("The value cannot be lower than 0.", #file, #line) } - return .init(max: max) + /// - Parameter value: The maximum number of elements. + public static func max(_ max: UInt) -> Demand { + Demand(max: max) } - + static func - (lhs: Request.Demand, rhs: Request.Demand) -> Request.Demand { if lhs == .unlimited { return lhs } else if rhs == .unlimited { return .none } else { - return .init( - max: Swift.max( - (lhs.max ?? .zero) - (rhs.max ?? .zero), - .zero - ) - ) + let lValue = lhs.max ?? .zero + let rValue = rhs.max ?? .zero + + let (result, overflow) = lValue.subtractingReportingOverflow(rValue) + return overflow ? .none : .max(result) } } - + static func + (lhs: Request.Demand, rhs: Request.Demand) -> Request.Demand { if lhs == .unlimited || rhs == .unlimited { return .unlimited } - return .init( - max: (lhs.max ?? .zero) + (rhs.max ?? .zero) - ) + + let lValue = lhs.max ?? .zero + let rValue = rhs.max ?? .zero + + let (result, overflow) = lValue.addingReportingOverflow(rValue) + return overflow ? .max(.max) : .max(result) } } } diff --git a/Sources/pingx/Pinger/Error/PingerError.swift b/Sources/pingx/Model/Response/PingError.swift similarity index 50% rename from Sources/pingx/Pinger/Error/PingerError.swift rename to Sources/pingx/Model/Response/PingError.swift index 7364055..f87bff9 100644 --- a/Sources/pingx/Pinger/Error/PingerError.swift +++ b/Sources/pingx/Model/Response/PingError.swift @@ -24,12 +24,47 @@ import Foundation -public enum PingerError: CustomNSError { - public static let errorDomain: String = "com.pingx.PingerError" - - case pingInProgress - case socketFailed - case invalidDemand - case invalidResponse +public enum PingError: CustomNSError { + public static let errorDomain: String = "pingx.PingError" + + public var errorDescription: String? { + switch self { + case .cancelled: + return "The operation was cancelled." + case .timeout: + return "The ping request timed out." + case .socketFailed: + return "Failed to open or communicate through the socket." + case .responseStructureInconsistent: + return "Unexpected or malformed ping response." + case .internalError(let error): + return "An internal error occurred: \(error.localizedDescription)" + } + } + + public var errorCode: Int { + switch self { + case .cancelled: + 101 + case .timeout: + 102 + case .socketFailed: + 103 + case .responseStructureInconsistent: + 104 + case .internalError: + 105 + } + } + + public var underlyingError: CustomNSError? { + guard case .internalError(let error) = self else { return nil } + return error + } + + case cancelled case timeout + case socketFailed + case responseStructureInconsistent + case internalError(CustomNSError) } diff --git a/Sources/pingx/Model/FatalError/FatalError.swift b/Sources/pingx/Model/Response/PingResult.swift similarity index 94% rename from Sources/pingx/Model/FatalError/FatalError.swift rename to Sources/pingx/Model/Response/PingResult.swift index 1d9e927..a3f47a9 100644 --- a/Sources/pingx/Model/FatalError/FatalError.swift +++ b/Sources/pingx/Model/Response/PingResult.swift @@ -22,6 +22,4 @@ // SOFTWARE. // -enum FatalError { - static var trigger = Swift.fatalError -} +public typealias PingResult = Result diff --git a/Sources/pingx/Model/Response/Response.swift b/Sources/pingx/Model/Response/Response.swift index 1b67ecc..26b4398 100644 --- a/Sources/pingx/Model/Response/Response.swift +++ b/Sources/pingx/Model/Response/Response.swift @@ -24,13 +24,16 @@ import Foundation -public struct Response { - +public struct Response: Sendable { + // MARK: Properties - + /// Destination address. public let destination: IPv4Address - - /// Time elapsed between the request and the response. (ms) - public let duration: TimeInterval + + /// Time elapsed between the request and the response. + public let duration: Interval + + /// Sequence number to match request/response. + public let sequenceNumber: UInt16 } diff --git a/Sources/pingx/Model/Socket/Impl/PingxSocketImpl.swift b/Sources/pingx/Model/Socket/PingxSocket.swift similarity index 81% rename from Sources/pingx/Model/Socket/Impl/PingxSocketImpl.swift rename to Sources/pingx/Model/Socket/PingxSocket.swift index b794058..c3ee000 100644 --- a/Sources/pingx/Model/Socket/Impl/PingxSocketImpl.swift +++ b/Sources/pingx/Model/Socket/PingxSocket.swift @@ -24,32 +24,37 @@ import Foundation -final class PingxSocketImpl: PingxSocket { - - // MARK: Typealias - - typealias Instance = T - +// sourcery: AutoMockable +protocol PingxSocketProtocol: Sendable { + func send(address: CFData, data: CFData, timeout: CFTimeInterval) -> CFSocketError +} + +final class PingxSocket: PingxSocketProtocol, @unchecked Sendable { + // MARK: Properties - + let socket: CFSocket let socketSource: CFRunLoopSource - let unmanaged: Unmanaged - + let unmanaged: Unmanaged + // MARK: Initializer - + init( socket: CFSocket, socketSource: CFRunLoopSource, - unmanaged: Unmanaged + unmanaged: Unmanaged ) { self.socket = socket self.socketSource = socketSource self.unmanaged = unmanaged } - + + deinit { + invalidate() + } + // MARK: Methods - + func send(address: CFData, data: CFData, timeout: CFTimeInterval) -> CFSocketError { CFSocketSendData( socket, @@ -58,8 +63,8 @@ final class PingxSocketImpl: PingxSocket { timeout ) } - - func invalidate() { + + private func invalidate() { CFRunLoopSourceInvalidate(socketSource) CFSocketInvalidate(socket) unmanaged.release() diff --git a/Sources/pingx/Model/Timer/Api/PingxTimer.swift b/Sources/pingx/Model/Timer/Api/PingxTimer.swift deleted file mode 100644 index 9491ab4..0000000 --- a/Sources/pingx/Model/Timer/Api/PingxTimer.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// The MIT License (MIT) -// -// Copyright © 2025 Ilya Baryka. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -// sourcery: AutoMockable -protocol PingxTimer { - func start() - func stop() -} diff --git a/Sources/pingx/PacketSender/Api/PacketSender.swift b/Sources/pingx/PacketSender/Api/PacketSender.swift deleted file mode 100644 index 97cf05e..0000000 --- a/Sources/pingx/PacketSender/Api/PacketSender.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// The MIT License (MIT) -// -// Copyright © 2025 Ilya Baryka. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -// sourcery: AutoMockable -protocol PacketSender: AnyObject { - - // MARK: Delegate - - var delegate: PacketSenderDelegate? { get set } - - // MARK: Methods - - func send(_ request: Request) - func invalidate() -} diff --git a/Sources/pingx/PacketSender/Impl/PacketSenderImpl.swift b/Sources/pingx/PacketSender/Impl/PacketSenderImpl.swift deleted file mode 100644 index dc8be92..0000000 --- a/Sources/pingx/PacketSender/Impl/PacketSenderImpl.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// The MIT License (MIT) -// -// Copyright © 2025 Ilya Baryka. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import Foundation - -final class PacketSenderImpl { - - // MARK: Typealias - - private typealias Instance = SocketFactoryImpl.SocketCommand - - // MARK: Delegate - - weak var delegate: PacketSenderDelegate? - - // MARK: Properties - - private let socketFactory: SocketFactory - private let packetFactory: PacketFactory - private var pingxSocket: (any PingxSocket)! - - // MARK: Initializer - - init( - socketFactory: SocketFactory = SocketFactoryImpl(), - packetFactory: PacketFactory = PacketFactoryImpl() - ) { - self.socketFactory = socketFactory - self.packetFactory = packetFactory - } - - deinit { - invalidate() - } -} - -// MARK: - PacketSender - -extension PacketSenderImpl: PacketSender { - func send(_ request: Request) { - do { - try checkSocketCreation() - } catch { - delegate?.packetSender(packetSender: self, request: request, didCompleteWithError: .socketCreationError) - return - } - - guard let packet = try? packetFactory.create(identifier: request.id, type: request.type) else { - delegate?.packetSender(packetSender: self, request: request, didCompleteWithError: .unableToCreatePacket) - return - } - - let error = pingxSocket.send( - address: request.destination.socketAddress as CFData, - data: packet.data as CFData, - timeout: request.sendTimeout - ) - handleSocketError(error, request: request) - } - - func invalidate() { - guard pingxSocket != nil else { return } - pingxSocket.invalidate() - pingxSocket = nil - } -} - -// MARK: - Private API - -private extension PacketSenderImpl { - func checkSocketCreation() throws { - guard pingxSocket == nil else { return } - - let command: CommandBlock = CommandBlock { [weak self] data in - guard let self else { return } - self.delegate?.packetSender(packetSender: self, didReceive: data) - } - - pingxSocket = try socketFactory.create(command: command) - } - - func handleSocketError(_ error: CFSocketError, request: Request) { - switch error { - case .error: - delegate?.packetSender(packetSender: self, request: request, didCompleteWithError: .error) - case .timeout: - delegate?.packetSender(packetSender: self, request: request, didCompleteWithError: .timeout) - default: - return - } - } -} diff --git a/Sources/pingx/Model/Socket/Api/PingxSocket.swift b/Sources/pingx/Pinger/Configuration/PingConfiguration.swift similarity index 76% rename from Sources/pingx/Model/Socket/Api/PingxSocket.swift rename to Sources/pingx/Pinger/Configuration/PingConfiguration.swift index 1a3de3e..6fe0baf 100644 --- a/Sources/pingx/Model/Socket/Api/PingxSocket.swift +++ b/Sources/pingx/Pinger/Configuration/PingConfiguration.swift @@ -24,15 +24,16 @@ import Foundation -// sourcery: AutoMockable -protocol PingxSocket { - - // MARK: Typealias - - associatedtype Instance: AnyObject = CommandBlock - - // MARK: Methods - - func send(address: CFData, data: CFData, timeout: CFTimeInterval) -> CFSocketError - func invalidate() +public struct PingConfiguration { + public let intervalBetweenRequests: Interval + + public init(intervalBetweenRequests: Interval) { + self.intervalBetweenRequests = intervalBetweenRequests + } +} + +public extension PingConfiguration { + static var `default`: PingConfiguration { + PingConfiguration(intervalBetweenRequests: .seconds(1)) + } } diff --git a/Sources/pingx/Pinger/Delegate/PingerDelegate.swift b/Sources/pingx/Pinger/Delegate/PingerDelegate.swift deleted file mode 100644 index f719184..0000000 --- a/Sources/pingx/Pinger/Delegate/PingerDelegate.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// The MIT License (MIT) -// -// Copyright © 2025 Ilya Baryka. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -public protocol PingerDelegate: AnyObject { - - /// Called when a ping request is successful. - func pinger(_ pinger: Pinger, request: Request, didReceive response: Response) - - /// Called when an error occurs during a ping request. - func pinger(_ pinger: Pinger, request: Request, didCompleteWithError error: PingerError) -} diff --git a/Sources/pingx/PacketSender/Error/PacketSenderError.swift b/Sources/pingx/Pinger/Error/AsyncPingerError.swift similarity index 69% rename from Sources/pingx/PacketSender/Error/PacketSenderError.swift rename to Sources/pingx/Pinger/Error/AsyncPingerError.swift index 6341306..4fa275a 100644 --- a/Sources/pingx/PacketSender/Error/PacketSenderError.swift +++ b/Sources/pingx/Pinger/Error/AsyncPingerError.swift @@ -24,9 +24,30 @@ import Foundation -enum PacketSenderError: Error { +enum AsyncPingerError: CustomNSError { + static let errorDomain: String = "pingx.AsyncPingerError" + + var errorCode: Int { + switch self { + case .cancelled: + 101 + case .socketCreationError: + 102 + case .timeout: + 103 + case .responseStructureInconsistent: + 104 + case .unableToCreatePacket: + 105 + case .unknown: + 106 + } + } + + case cancelled case socketCreationError - case unableToCreatePacket - case error case timeout + case responseStructureInconsistent(ICMPResponseValidationError) + case unableToCreatePacket + case unknown } diff --git a/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift b/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift index e6e161f..05cec40 100644 --- a/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift +++ b/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift @@ -27,16 +27,14 @@ enum ICMPResponseValidationError: Error { switch self { case .checksumMismatch(let icmpHeader), .invalidCode(let icmpHeader), - .invalidType(let icmpHeader), - .invalidPayload(let icmpHeader): + .invalidType(let icmpHeader): return icmpHeader case .missedIpHeader, .missedIcmpHeader: return nil } } - + case checksumMismatch(ICMPHeader) - case invalidPayload(ICMPHeader) case invalidType(ICMPHeader) case invalidCode(ICMPHeader) case missedIpHeader diff --git a/Sources/pingx/Pinger/Api/Pinger.swift b/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift similarity index 67% rename from Sources/pingx/Pinger/Api/Pinger.swift rename to Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift index 82fd594..0fe2bd6 100644 --- a/Sources/pingx/Pinger/Api/Pinger.swift +++ b/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift @@ -24,64 +24,44 @@ import Foundation -public protocol Pinger: AnyObject { - - // MARK: Properties - - /// Notifies of the received event/error. - var delegate: PingerDelegate? { get set } - - // MARK: Methods - - /// Starts pinging a specific `Request`. - func ping(request: Request) - - /// Stops pinging a specific `Request`. - func stop(request: Request) - - /// Stops pinging a specifc request ID. - func stop(requestId: Request.ID) +// sourcery: AutoMockable +protocol ICMPPacketExtractorProtocol { + func extract(from data: Data) throws -> ICMPPacket } -// MARK: Default Implementation - -extension Pinger { - func extractICMPPackage(from data: Data) throws -> ICMPPacket { +struct ICMPPacketExtractor: ICMPPacketExtractorProtocol { + func extract(from data: Data) throws -> ICMPPacket { guard data.count >= MemoryLayout.size + MemoryLayout.size else { guard data.count >= MemoryLayout.size else { throw ICMPResponseValidationError.missedIpHeader } throw ICMPResponseValidationError.missedIcmpHeader } - + let ipHeader = data.withUnsafeBytes { $0.load(as: IPHeader.self) } let offset = data.count - MemoryLayout.size let icmpHeader = data.withUnsafeBytes { $0.loadUnaligned(fromByteOffset: offset, as: ICMPHeader.self) } let icmpPackage = ICMPPacket(ipHeader: ipHeader, icmpHeader: icmpHeader) - + try validateICMPPackage(icmpPackage) - + return icmpPackage } - - private func validateICMPPackage(_ icmpPackage: ICMPPacket) throws { - let identifier = Payload().identifier - - guard compareIdentifier(lhs: icmpPackage.icmpHeader.payload.identifier, rhs: identifier) else { - throw ICMPResponseValidationError.invalidPayload(icmpPackage.icmpHeader) - } - +} + +private extension ICMPPacketExtractor { + func validateICMPPackage(_ icmpPackage: ICMPPacket) throws(ICMPResponseValidationError) { guard icmpPackage.icmpHeader.type == ICMPType.echoReply.rawValue else { throw ICMPResponseValidationError.invalidType(icmpPackage.icmpHeader) } - + guard icmpPackage.icmpHeader.code == .zero else { throw ICMPResponseValidationError.invalidCode(icmpPackage.icmpHeader) } - + do { let checksum = try ICMPChecksum()(icmpHeader: icmpPackage.icmpHeader) - + guard icmpPackage.icmpHeader.checksum == checksum else { throw ICMPResponseValidationError.checksumMismatch(icmpPackage.icmpHeader) } @@ -89,15 +69,4 @@ extension Pinger { throw ICMPResponseValidationError.checksumMismatch(icmpPackage.icmpHeader) } } - - private func compareIdentifier(lhs: Payload.PayloadID, rhs: Payload.PayloadID) -> Bool { - lhs.0 == rhs.0 && - lhs.1 == rhs.1 && - lhs.2 == rhs.2 && - lhs.3 == rhs.3 && - lhs.4 == rhs.4 && - lhs.5 == rhs.5 && - lhs.6 == rhs.6 && - lhs.7 == rhs.7 - } } diff --git a/Sources/pingx/Pinger/Helpers/SafeCheckedContinuation.swift b/Sources/pingx/Pinger/Helpers/SafeCheckedContinuation.swift new file mode 100644 index 0000000..92c01e7 --- /dev/null +++ b/Sources/pingx/Pinger/Helpers/SafeCheckedContinuation.swift @@ -0,0 +1,53 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +final class SafeCheckedContinuation: @unchecked Sendable { + private let continuation: CheckedContinuation + private let lock = NSLock() + private var isResumed = false + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resume(returning value: sending T) { + resume(with: .success(value)) + } + + func resume(throwing error: E) { + resume(with: .failure(error)) + } + + func resume(with result: Result) { + lock.lock() + defer { lock.unlock() } + + guard !isResumed else { return } + isResumed = true + + continuation.resume(with: result) + } +} diff --git a/Sources/pingx/Pinger/Impl/ContinuousPinger.swift b/Sources/pingx/Pinger/Impl/ContinuousPinger.swift deleted file mode 100644 index 22039c5..0000000 --- a/Sources/pingx/Pinger/Impl/ContinuousPinger.swift +++ /dev/null @@ -1,202 +0,0 @@ -// -// The MIT License (MIT) -// -// Copyright © 2025 Ilya Baryka. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import Foundation - -public final class ContinuousPinger: Pinger { - - // MARK: Delegate - - public weak var delegate: PingerDelegate? - - // MARK: Properties - - private let pingerQueue = DispatchQueue( - label: "com.pingx.ContinuousPinger.pingerQueue", - qos: .userInitiated - ) - private let configuration: PingerConfiguration - private let packetSender: PacketSender - private let timerFactory: TimerFactory - - @Atomic - private var outgoingRequests: [UInt16: Request] = [:] { - didSet { - if outgoingRequests.isEmpty { - invalidateTimer() - } else if timer == nil { - setUpTimer() - } - } - } - - @Atomic - private var timer: PingxTimer? - - // MARK: Initializer - - init( - configuration: PingerConfiguration, - packetSender: PacketSender = PacketSenderImpl(), - timerFactory: TimerFactory = TimerFactoryImpl() - ) { - self.configuration = configuration - self.packetSender = packetSender - self.timerFactory = timerFactory - packetSender.delegate = self - } - - public convenience init( - configuration: PingerConfiguration = PingerConfiguration() - ) { - self.init( - configuration: configuration, - packetSender: PacketSenderImpl(), - timerFactory: TimerFactoryImpl() - ) - } - - deinit { - invalidateTimer() - } - - public func ping(request: Request) { - func validateAndSendRequest() { - guard outgoingRequests[request.id] == nil else { - delegate?.pinger(self, request: request, didCompleteWithError: .pingInProgress) - return - } - - guard request.demand != .none else { - delegate?.pinger(self, request: request, didCompleteWithError: .invalidDemand) - return - } - - outgoingRequests[request.id] = request - packetSender.send(request) - } - - perform(validateAndSendRequest, on: pingerQueue) - } - - public func stop(request: Request) { - pingerQueue.async { [weak self] in - self?.outgoingRequests.removeValue(forKey: request.id) - } - } - - public func stop(requestId: Request.ID) { - pingerQueue.async { [weak self] in - self?.outgoingRequests.removeValue(forKey: requestId) - } - } -} - -// MARK: - Private API - -private extension ContinuousPinger { - func setUpTimer() { - timer = timerFactory.createDispatchSourceTimer(timeInterval: 1.0, eventQueue: pingerQueue) { [weak self] in - self?.updateTimeoutTimeForOutgoingRequests() - } - } - - func updateTimeoutTimeForOutgoingRequests() { - for request in outgoingRequests.values { - let newTimeRemainingUntilDeadline = request.timeRemainingUntilDeadline - 1 - - if newTimeRemainingUntilDeadline <= .zero { - request.decreaseDemandAndUpdateTimeRemainingUntilDeadline() - delegate?.pinger(self, request: request, didCompleteWithError: .timeout) - } else { - request.setTimeRemainingUntilDeadline(newTimeRemainingUntilDeadline) - } - - scheduleNextRequestIfPositiveDemand(request) - } - } - - func invalidateTimer() { - timer?.stop() - timer = nil - } - - func scheduleNextRequestIfPositiveDemand(_ request: Request) { - guard request.demand != .none else { - outgoingRequests.removeValue(forKey: request.id) - return - } - - performAfter( - deadline: .now() + configuration.interval, - packetSender.send, - value: request, - on: pingerQueue - ) - } -} - -// MARK: - PacketSenderDelegate - -extension ContinuousPinger: PacketSenderDelegate { - func packetSender(packetSender: PacketSender, didReceive data: Data) { - perform(handlePacketSenderResponse, value: data, on: pingerQueue) - } - - private func handlePacketSenderResponse(data: Data) { - do { - let package = try extractICMPPackage(from: data) - let response = Response( - destination: package.ipHeader.sourceAddress, - duration: (CFAbsoluteTimeGetCurrent() - package.icmpHeader.payload.timestamp) * 1000 - ) - - guard let request = outgoingRequests[package.icmpHeader.identifier] else { return } - request.decreaseDemandAndUpdateTimeRemainingUntilDeadline() - - delegate?.pinger(self, request: request, didReceive: response) - scheduleNextRequestIfPositiveDemand(request) - } catch let error as ICMPResponseValidationError { - guard - let icmpHeader = error.icmpHeader, - let request = outgoingRequests[icmpHeader.identifier] - else { return } - request.decreaseDemandAndUpdateTimeRemainingUntilDeadline() - - delegate?.pinger(self, request: request, didCompleteWithError: .invalidResponse) - scheduleNextRequestIfPositiveDemand(request) - } catch {} - } - - func packetSender(packetSender: PacketSender, request: Request, didCompleteWithError error: PacketSenderError) { - perform(handlePacketSenderSockerFailure, value: request, on: pingerQueue) - } - - private func handlePacketSenderSockerFailure(request: Request) { - request.decreaseDemandAndUpdateTimeRemainingUntilDeadline() - - delegate?.pinger(self, request: request, didCompleteWithError: .socketFailed) - scheduleNextRequestIfPositiveDemand(request) - } -} diff --git a/Sources/pingx/Model/Packets/Packet/Packet.swift b/Sources/pingx/Pinger/Model/AsyncPingerResult.swift similarity index 94% rename from Sources/pingx/Model/Packets/Packet/Packet.swift rename to Sources/pingx/Pinger/Model/AsyncPingerResult.swift index 7d12ddb..dd9de45 100644 --- a/Sources/pingx/Model/Packets/Packet/Packet.swift +++ b/Sources/pingx/Pinger/Model/AsyncPingerResult.swift @@ -22,8 +22,4 @@ // SOFTWARE. // -import Foundation - -protocol Packet { - var data: Data { get } -} +typealias AsyncPingerResult = Result diff --git a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift new file mode 100644 index 0000000..648741a --- /dev/null +++ b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift @@ -0,0 +1,189 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +// sourcery: AutoMockable +public protocol AsyncPingerProtocol: AnyObject, Sendable { + func ping(request: Request) -> AnyPingSequence +} + +public final class AsyncPinger: AsyncPingerProtocol, @unchecked Sendable { + + // MARK: Properties + + @Atomic private var pingxSocket: (any PingxSocketProtocol)! + @Atomic private var completions = [Request.Identifier: (AsyncPingerResult) -> Void]() + private let configuration: PingConfiguration + private let icmpHeaderFactory: ICMPHeaderFactoryProtocol + private let icmpPacketExtractor: ICMPPacketExtractorProtocol + private let socketFactory: SocketFactoryProtocol + + // MARK: Initializer + + init( + configuration: PingConfiguration, + icmpHeaderFactory: ICMPHeaderFactoryProtocol, + icmpPacketExtractor: ICMPPacketExtractorProtocol, + socketFactory: SocketFactoryProtocol + ) { + self.configuration = configuration + self.icmpHeaderFactory = icmpHeaderFactory + self.icmpPacketExtractor = icmpPacketExtractor + self.socketFactory = socketFactory + } + + public convenience init( + configuration: PingConfiguration = .default + ) { + self.init( + configuration: configuration, + icmpHeaderFactory: ICMPHeaderFactory(), + icmpPacketExtractor: ICMPPacketExtractor(), + socketFactory: SocketFactory() + ) + } + + public func ping(request: Request) -> AnyPingSequence { + AnyPingSequence( + sequence: PingSequence( + configuration: configuration, + pinger: self, + request: request + ) + ) + } +} + +// MARK: - Internal API + +extension AsyncPinger { + func ping( + request: Request, + completion: @escaping (AsyncPingerResult) -> Void + ) { + completions[request.identifier] = completion + + do { + try checkSocketCreation() + } catch { + invokeCompletion(identifier: request.identifier, result: .failure(.socketCreationError)) + return + } + + guard let icmpHeader = try? icmpHeaderFactory.make(from: request) else { + invokeCompletion(identifier: request.identifier, result: .failure(.unableToCreatePacket)) + return + } + + let cfSocketError = pingxSocket.send( + address: request.destination.socketAddress as CFData, + data: icmpHeader.data as CFData, + timeout: request.timeoutInterval.milliseconds + ) + + if let error = cfSocketError.mapToPingerError() { + invokeCompletion(identifier: request.identifier, result: .failure(error)) + } + } + + func cancel(requestId: Request.Identifier) { + invokeCompletion(identifier: requestId, result: .failure(.cancelled)) + } + + func removeCompletion(for requestId: Request.Identifier) { + completions.removeValue(forKey: requestId) + } +} + +// MARK: - Private API + +private extension AsyncPinger { + func checkSocketCreation() throws { + guard pingxSocket == nil else { return } + + let command: CommandBlock = CommandBlock { [weak self] data in + guard let self else { return } + + let result: AsyncPingerResult = { [icmpPacketExtractor] in + do { + let icmpPacket = try icmpPacketExtractor.extract(from: data) + return .success(icmpPacket) + } catch let error as ICMPResponseValidationError { + return .failure(.responseStructureInconsistent(error)) + } catch { + return .failure(.unknown) + } + }() + + if let identifier = result.identifier { + invokeCompletion(identifier: identifier, result: result) + } + } + + pingxSocket = try socketFactory.make(command: command) + } + + func invokeCompletion(identifier: Request.Identifier, result: AsyncPingerResult) { + let completion = completions.removeValue(forKey: identifier) + completion?(result) + } +} + +private extension CFSocketError { + func mapToPingerError() -> AsyncPingerError? { + switch self { + case .error: + return .unknown + case .timeout: + return .timeout + default: + return nil + } + } +} + +private extension Result { + var identifier: Request.Identifier? { + switch self { + case .success(let icmpPacket): + return icmpPacket.icmpHeader.toRequestIdentifier() + case .failure(.responseStructureInconsistent(let validationError)): + return validationError.icmpHeader.map { icmpHeader in + icmpHeader.toRequestIdentifier() + } + case .failure: + return nil + } + } +} + +private extension ICMPHeader { + func toRequestIdentifier() -> Request.Identifier { + Request.Identifier( + id: identifier, + uniqueToken: UUID(uuid: payload.rawUniqueToken) + ) + } +} diff --git a/Sources/pingx/Pinger/Pingers/Pinger.swift b/Sources/pingx/Pinger/Pingers/Pinger.swift new file mode 100644 index 0000000..f9cce58 --- /dev/null +++ b/Sources/pingx/Pinger/Pingers/Pinger.swift @@ -0,0 +1,79 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +public protocol PingerProtocol: AnyObject { + func ping(request: Request, completion: @escaping (PingResult) -> Void) + func cancel(requestId: Request.Identifier) +} + +public final class Pinger: PingerProtocol { + @Atomic private var activeTasks: [Request.Identifier: Task] = [:] + private let asyncPinger: AsyncPingerProtocol + + init(asyncPinger: AsyncPingerProtocol) { + self.asyncPinger = asyncPinger + } + + public convenience init( + configuration: PingConfiguration = .default + ) { + self.init( + asyncPinger: AsyncPinger( + configuration: configuration + ) + ) + } + + deinit { + cancelAllActiveRequests() + } + + private func cancelAllActiveRequests() { + activeTasks.keys.forEach { cancel(requestId: $0) } + } + + public func ping( + request: Request, + completion: @escaping (PingResult) -> Void + ) { + cancel(requestId: request.identifier) + + let task = Task { [weak self] in + let sequence = self?.asyncPinger.ping(request: request) + + while let result = try? await sequence?.next() as? PingResult, !Task.isCancelled { + completion(result) + } + + self?.cancel(requestId: request.identifier) + } + + activeTasks[request.identifier] = task + } + + public func cancel(requestId: Request.Identifier) { + let task = activeTasks.removeValue(forKey: requestId) + task?.cancel() + } +} diff --git a/Sources/pingx/Pinger/Sequence/AnyPingSequence.swift b/Sources/pingx/Pinger/Sequence/AnyPingSequence.swift new file mode 100644 index 0000000..902ffc8 --- /dev/null +++ b/Sources/pingx/Pinger/Sequence/AnyPingSequence.swift @@ -0,0 +1,57 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +public struct AnyPingSequence: PingSequenceProtocol { + public struct AsyncIterator: AsyncIteratorProtocol { + private let _next: () async throws -> PingResult? + + init(_next: @escaping () async throws -> PingResult?) { + self._next = _next + } + + mutating public func next() async throws -> PingResult? { + try await _next() + } + } + + private let _makeAsyncIterator: () -> AsyncIterator + private let _next: () async throws -> PingResult? + + init(sequence: some PingSequenceProtocol) { + var iterator = sequence.makeAsyncIterator() + + self._makeAsyncIterator = { + AsyncIterator { try await iterator.next() } + } + self._next = { try await iterator.next() } + } + + public func next() async throws -> PingResult? { + try await _next() + } + + public func makeAsyncIterator() -> AsyncIterator { + _makeAsyncIterator() + } +} diff --git a/Sources/pingx/Pinger/Sequence/PingSequence.swift b/Sources/pingx/Pinger/Sequence/PingSequence.swift new file mode 100644 index 0000000..e9e3de4 --- /dev/null +++ b/Sources/pingx/Pinger/Sequence/PingSequence.swift @@ -0,0 +1,120 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +public protocol PingSequenceProtocol: AsyncSequence, AsyncIteratorProtocol where Element == PingResult {} + +struct PingSequence: PingSequenceProtocol { + private let configuration: PingConfiguration + private let pinger: AsyncPinger + private var request: Request + private var shouldDelayNextPing = false + + init( + configuration: PingConfiguration, + pinger: AsyncPinger, + request: Request + ) { + self.configuration = configuration + self.pinger = pinger + self.request = request + } + + mutating func next() async throws -> PingResult? { + guard request.demand != .none else { return nil } + + if shouldDelayNextPing { + try await Task.sleep(nanoseconds: UInt64(configuration.intervalBetweenRequests.nanoseconds)) + } else { + shouldDelayNextPing = true + } + + guard let result = await performPingWithTimeout() else { return nil } + + try Task.checkCancellation() + + if case .cancelled = result.error { + request.setDemand(.none) + return nil + } + + request.decreaseDemand() + request.incrementSequenceNumber() + + return result.mapToPingResult() + } + + private func performPingWithTimeout() async -> AsyncPingerResult? { + await withCheckedContinuation { [weak pinger, request] continuation in + let safeContinuation = SafeCheckedContinuation(continuation: continuation) + + let timeoutTask = Task { + try? await Task.sleep(nanoseconds: UInt64(request.timeoutInterval.nanoseconds)) + + guard !Task.isCancelled else { return } + + pinger?.removeCompletion(for: request.identifier) + safeContinuation.resume(returning: .failure(.timeout)) + } + + pinger?.ping(request: request) { result in + timeoutTask.cancel() + safeContinuation.resume(returning: result) + } + } + } + + func makeAsyncIterator() -> PingSequence { self } +} + +private extension AsyncPingerResult { + func mapToPingResult() -> PingResult { + map { icmpPacket in + Response( + destination: icmpPacket.ipHeader.sourceAddress, + duration: .seconds(CFAbsoluteTimeGetCurrent() - icmpPacket.icmpHeader.payload.timestamp), + sequenceNumber: icmpPacket.icmpHeader.sequenceNumber + ) + } + .mapError { $0.mapToPingError() } + } +} + +private extension AsyncPingerError { + func mapToPingError() -> PingError { + switch self { + case .cancelled: + return .cancelled + case .timeout: + return .timeout + case .socketCreationError: + return .socketFailed + case .responseStructureInconsistent: + return .responseStructureInconsistent + case .unableToCreatePacket, .unknown: + return .internalError(self) + } + } +} diff --git a/Tests/Templates/AutoMockable.stencil b/Tests/Templates/AutoMockable.stencil index 25291ce..4ef8ea0 100644 --- a/Tests/Templates/AutoMockable.stencil +++ b/Tests/Templates/AutoMockable.stencil @@ -354,14 +354,22 @@ {% for type in types.protocols where type.based.AutoMockable or type|annotated:"AutoMockable" %}{% if type.name != "AutoMockable" %} +{% if type.name|hasSuffix:"Protocol" %} +// sourcery:file:{{ type.name|replace:"Protocol","" }}+AutoMockable +{% else %} // sourcery:file:{{ type.name }}+AutoMockable +{% endif %} // swiftlint:disable all import Foundation @testable import pingx -class {{ type.name }}Mock: {{ type.name }} { +{% if type.name|hasSuffix:"Protocol" %} +final class {{ type.name|replace:"Protocol","" }}Mock: {{ type.name }} { +{% else %} +final class {{ type.name }}Mock: {{ type.name }} { +{% endif %} {% for variable in type.allVariables|!definedInExtension %} {% if variable.isOptional %}{% call mockOptionalVariable variable %}{% elif variable.isArray or variable.isDictionary %}{% call mockNonOptionalArrayOrDictionaryVariable variable %}{% else %}{% call mockNonOptionalVariable variable %}{% endif %} {% endfor %} diff --git a/Tests/pingxTests/Converter/IPv4AddressConverterTests.swift b/Tests/pingxTests/Converter/IPv4AddressConverterTests.swift index 1c911c8..12d5d8c 100644 --- a/Tests/pingxTests/Converter/IPv4AddressConverterTests.swift +++ b/Tests/pingxTests/Converter/IPv4AddressConverterTests.swift @@ -28,7 +28,7 @@ import Testing @Suite struct IPv4AddressConverterTests { @Test( - "Convert a string value to an IPv4 address", + "Converts a string value to an IPv4 address", arguments: [ (address: "0.0.0.0", expectedResult: IPv4Address(address: (0, 0, 0, 0))), (address: "255.0.1.255", expectedResult: IPv4Address(address: (255, 0, 1, 255))), @@ -42,7 +42,7 @@ struct IPv4AddressConverterTests { } @Test( - "Convert a string value to an IPv4 address when the address is invalid", + "Converts a string value to an IPv4 address when the address is invalid", arguments: [ "", "0.0.0", @@ -50,7 +50,10 @@ struct IPv4AddressConverterTests { "255.255.255.255.1", "abc", "abc.abc.abc.abc", - "abc1.ax3.4.5" + "abc1.ax3.4.5", + "123123", + "1=2=3=4", + "1-2-3-4" ] ) func convert_whenAddressIsInvalid_throwsInvalidAddressError(address: String) { @@ -60,7 +63,7 @@ struct IPv4AddressConverterTests { } @Test( - "Convert a string value to an IPv4 address when an octet is out of range", + "Converts a string value to an IPv4 address when an octet is out of range", arguments: [ "-1.0.0.1", "1.256.999.1", diff --git a/Tests/pingxTests/Extenstions/Assertion+Extensions.swift b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift new file mode 100644 index 0000000..b20d314 --- /dev/null +++ b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift @@ -0,0 +1,120 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Testing +import XCTest + +func expectTo( + expression: @escaping () -> Bool, + timeout: TimeInterval = 1.0, + description: String = "", + sourceLocation: SourceLocation = #_sourceLocation +) async { + let expectation = XCTestExpectation(description: description) + + let task = Task { + while !expression() { + try await Task.sleep(nanoseconds: 20_000_000) // 20ms + } + + expectation.fulfill() + } + + let result = await XCTWaiter.fulfillment(of: [expectation], timeout: timeout) + task.cancel() + + #expect( + result == .completed, + .__block(description), + sourceLocation: sourceLocation + ) +} + +func expectNotTo( + expression: @escaping () -> Bool, + timeout: TimeInterval = 1.0, + description: String = "", + sourceLocation: SourceLocation = #_sourceLocation +) async { + let expectation = XCTestExpectation(description: description) + expectation.isInverted = true + + let task = Task { + while !expression() { + try await Task.sleep(nanoseconds: 20_000_000) // 20ms + } + + expectation.fulfill() + } + + let result = await XCTWaiter.fulfillment(of: [expectation], timeout: timeout) + task.cancel() + + #expect( + result == .completed, + .__block(description), + sourceLocation: sourceLocation + ) +} + +func expectToEventuallyBeCalled( + actualCallsCount: @autoclosure @escaping () -> Int, + expectedCallsCount: Int = 1, + timeout: TimeInterval = 1.0, + sourceLocation: SourceLocation = #_sourceLocation +) async { + await expectTo( + expression: { actualCallsCount() == expectedCallsCount }, + timeout: timeout, + description: "Wait for actualCallsCount to reach \(expectedCallsCount), got: \((actualCallsCount()))", + sourceLocation: sourceLocation + ) +} + +func expectNotToEventuallyBeCalled( + actualCallsCount: @autoclosure @escaping () -> Int, + expectedCallsCount: Int = 1, + timeout: TimeInterval = 0.1, + sourceLocation: SourceLocation = #_sourceLocation +) async { + await expectNotTo( + expression: { actualCallsCount() >= expectedCallsCount }, + timeout: timeout, + description: "Wait for actualCallsCount to not reach \(expectedCallsCount), got: \((actualCallsCount()))", + sourceLocation: sourceLocation + ) +} + +func expectNotToEventuallyBeNil( + actualValue: @autoclosure @escaping () -> T?, + timeout: TimeInterval = 1.0, + sourceLocation: SourceLocation = #_sourceLocation +) async { + await expectTo( + expression: { actualValue() != nil }, + timeout: timeout, + description: "Wait for actualValue not to be nil", + sourceLocation: sourceLocation + ) +} diff --git a/Tests/pingxTests/Extenstions/ICMPResponseValidationError+Equatable.swift b/Tests/pingxTests/Extenstions/PingResult+Extensions.swift similarity index 70% rename from Tests/pingxTests/Extenstions/ICMPResponseValidationError+Equatable.swift rename to Tests/pingxTests/Extenstions/PingResult+Extensions.swift index eb6f05c..9073af0 100644 --- a/Tests/pingxTests/Extenstions/ICMPResponseValidationError+Equatable.swift +++ b/Tests/pingxTests/Extenstions/PingResult+Extensions.swift @@ -24,21 +24,17 @@ @testable import pingx -// MARK: - Equatable - -extension ICMPResponseValidationError: Equatable { - public static func == (lhs: ICMPResponseValidationError, rhs: ICMPResponseValidationError) -> Bool { - switch (lhs, rhs) { - case (.checksumMismatch, .checksumMismatch), - (.invalidPayload, .invalidPayload), - (.invalidType, .invalidType), - (.invalidCode, .invalidCode), - (.missedIpHeader, .missedIpHeader), - (.missedIcmpHeader, .missedIcmpHeader): - return true +extension PingResult { + func equals(_ rhs: PingResult) -> Bool { + switch (self, rhs) { + case (.success(let lValue), .success(let rValue)): + return lValue.destination == rValue.destination && + lValue.sequenceNumber == rValue.sequenceNumber + case (.failure(let lError), .failure(let rError)): + return lError.errorCode == rError.errorCode && + lError.underlyingError?.errorCode == rError.underlyingError?.errorCode default: return false - } } } diff --git a/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift b/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift new file mode 100644 index 0000000..a92997d --- /dev/null +++ b/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift @@ -0,0 +1,71 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +@testable import pingx + +/// Collects values from the given sequence. +/// - Parameters: +/// - sequence: Observable sequence +/// - count: Number of values to collect +/// - timeout: Time limit for collecting values (in milliseconds) +/// - Returns: An array of collected elements +@discardableResult func collectValuesFromPingSequence( + sequence: some PingSequenceProtocol, + count: Int = .max, + timeout: Interval = .milliseconds(50) +) async throws -> [PingResult] { + try await withThrowingTaskGroup( + of: Void.self, + returning: [PingResult].self + ) { taskGroup in + var values: [PingResult] = [] + + taskGroup.addTask { + for try await value in sequence where values.count < count { + values.append(value) + } + } + + taskGroup.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout.nanoseconds)) + } + + defer { taskGroup.cancelAll() } + + _ = try await taskGroup.next() + return values + } +} + +func observerPingSequenceWithoutReturningResult( + sequence: some PingSequenceProtocol, + timeout: Interval = .milliseconds(50) +) async throws { + try await collectValuesFromPingSequence( + sequence: sequence, + timeout: timeout + ) +} diff --git a/Tests/pingxTests/Extenstions/XCTestCase+Extensions.swift b/Tests/pingxTests/Extenstions/XCTestCase+Extensions.swift deleted file mode 100644 index 084e706..0000000 --- a/Tests/pingxTests/Extenstions/XCTestCase+Extensions.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// The MIT License (MIT) -// -// Copyright © 2025 Ilya Baryka. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import XCTest -@testable import pingx - -extension XCTestCase { - func expectFatalError(expectedMessage: String, testcase: @escaping () -> Void) { - let expectation = expectation(description: "expectingFatalError") - var assertionMessage = String() - - FatalError.trigger = { (message, _, _) in - assertionMessage = message() - DispatchQueue.main.async { - expectation.fulfill() - } - Thread.exit() - Swift.fatalError("Never be executed") - } - - Thread(block: testcase).start() - - waitForExpectations(timeout: 0.1) { _ in - XCTAssertEqual(expectedMessage, assertionMessage) - FatalError.trigger = Swift.fatalError - } - } - - func XCTAssertEventuallyEqual( - _ expression1: @autoclosure @escaping () -> T, - _ expression2: T, - timeout: TimeInterval = 1.0, - file: StaticString = #file, - line: UInt = #line - ) { - let expectation = XCTestExpectation(description: "Eventually equal") - - let startTime = Date() - let timeoutDate = startTime.addingTimeInterval(timeout) - - DispatchQueue.global().async { - while Date() < timeoutDate { - if expression1() == expression2 { - expectation.fulfill() - break - } - usleep(100_000) - } - } - - let result = XCTWaiter.wait(for: [expectation], timeout: timeout) - - if result != .completed { - XCTFail("Expected \(expression1()) to eventually equal \(expression2)", file: file, line: line) - } - } -} diff --git a/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift new file mode 100644 index 0000000..7740ac7 --- /dev/null +++ b/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift @@ -0,0 +1,52 @@ +// Generated using Sourcery 2.2.5 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT +// swiftlint:disable all + +import Foundation + +@testable import pingx + +final class AsyncPingerMock: AsyncPingerProtocol { + + // MARK: - ping + + public var pingCallsCount = 0 + public var pingCalled: Bool { + return pingCallsCount > 0 + } + public var pingReceivedRequest: (Request)? + public var pingReceivedInvocations: [(Request)] = [] + public var pingReturnValue: AnyPingSequence! + public var pingClosure: ((Request) -> AnyPingSequence)? + + public func ping(request: Request) -> AnyPingSequence { + pingCallsCount += 1 + pingReceivedRequest = request + pingReceivedInvocations.append(request) + if let pingClosure = pingClosure { + return pingClosure(request) + } else { + return pingReturnValue + } + } + + // MARK: - cancel + + public var cancelCallsCount = 0 + public var cancelCalled: Bool { + return cancelCallsCount > 0 + } + public var cancelReceivedRequestId: (Request.Identifier)? + public var cancelReceivedInvocations: [(Request.Identifier)] = [] + public var cancelClosure: ((Request.Identifier) -> Void)? + + public func cancel(requestId: Request.Identifier) { + cancelCallsCount += 1 + cancelReceivedRequestId = requestId + cancelReceivedInvocations.append(requestId) + cancelClosure?(requestId) + } + +} + +// swiftlint:enable all diff --git a/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift new file mode 100644 index 0000000..d5c094c --- /dev/null +++ b/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift @@ -0,0 +1,39 @@ +// Generated using Sourcery 2.2.5 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT +// swiftlint:disable all + +import Foundation + +@testable import pingx + +final class ICMPHeaderFactoryMock: ICMPHeaderFactoryProtocol { + + // MARK: - make + + var makeThrowableError: (any Error)? + var makeCallsCount = 0 + var makeCalled: Bool { + return makeCallsCount > 0 + } + var makeReceivedRequest: (Request)? + var makeReceivedInvocations: [(Request)] = [] + var makeReturnValue: ICMPHeader! + var makeClosure: ((Request) throws -> ICMPHeader)? + + func make(from request: Request) throws -> ICMPHeader { + makeCallsCount += 1 + makeReceivedRequest = request + makeReceivedInvocations.append(request) + if let error = makeThrowableError { + throw error + } + if let makeClosure = makeClosure { + return try makeClosure(request) + } else { + return makeReturnValue + } + } + +} + +// swiftlint:enable all diff --git a/Tests/pingxTests/Mocks/Generated/ICMPPacketExtractor+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/ICMPPacketExtractor+AutoMockable.generated.swift new file mode 100644 index 0000000..0be5b84 --- /dev/null +++ b/Tests/pingxTests/Mocks/Generated/ICMPPacketExtractor+AutoMockable.generated.swift @@ -0,0 +1,39 @@ +// Generated using Sourcery 2.2.5 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT +// swiftlint:disable all + +import Foundation + +@testable import pingx + +final class ICMPPacketExtractorMock: ICMPPacketExtractorProtocol { + + // MARK: - extract + + var extractThrowableError: (any Error)? + var extractCallsCount = 0 + var extractCalled: Bool { + return extractCallsCount > 0 + } + var extractReceivedData: (Data)? + var extractReceivedInvocations: [(Data)] = [] + var extractReturnValue: ICMPPacket! + var extractClosure: ((Data) throws -> ICMPPacket)? + + func extract(from data: Data) throws -> ICMPPacket { + extractCallsCount += 1 + extractReceivedData = data + extractReceivedInvocations.append(data) + if let error = extractThrowableError { + throw error + } + if let extractClosure = extractClosure { + return try extractClosure(data) + } else { + return extractReturnValue + } + } + +} + +// swiftlint:enable all diff --git a/Tests/pingxTests/Mocks/Generated/PacketFactory+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/PacketFactory+AutoMockable.generated.swift deleted file mode 100644 index 120fa73..0000000 --- a/Tests/pingxTests/Mocks/Generated/PacketFactory+AutoMockable.generated.swift +++ /dev/null @@ -1,39 +0,0 @@ -// Generated using Sourcery 2.2.5 — https://github.com/krzysztofzablocki/Sourcery -// DO NOT EDIT -// swiftlint:disable all - -import Foundation - -@testable import pingx - -class PacketFactoryMock: PacketFactory { - - // MARK: - create - - var createThrowableError: (any Error)? - var createCallsCount = 0 - var createCalled: Bool { - return createCallsCount > 0 - } - var createReceivedArguments: (identifier: UInt16, type: PacketType)? - var createReceivedInvocations: [(identifier: UInt16, type: PacketType)] = [] - var createReturnValue: Packet! - var createClosure: ((UInt16, PacketType) throws -> Packet)? - - func create(identifier: UInt16, type: PacketType) throws -> Packet { - createCallsCount += 1 - createReceivedArguments = (identifier: identifier, type: type) - createReceivedInvocations.append((identifier: identifier, type: type)) - if let error = createThrowableError { - throw error - } - if let createClosure = createClosure { - return try createClosure(identifier, type) - } else { - return createReturnValue - } - } - -} - -// swiftlint:enable all diff --git a/Tests/pingxTests/Mocks/Generated/PacketSender+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/PacketSender+AutoMockable.generated.swift deleted file mode 100644 index 9e3b0b8..0000000 --- a/Tests/pingxTests/Mocks/Generated/PacketSender+AutoMockable.generated.swift +++ /dev/null @@ -1,44 +0,0 @@ -// Generated using Sourcery 2.2.5 — https://github.com/krzysztofzablocki/Sourcery -// DO NOT EDIT -// swiftlint:disable all - -import Foundation - -@testable import pingx - -class PacketSenderMock: PacketSender { - weak var delegate: PacketSenderDelegate? - - // MARK: - send - - var sendCallsCount = 0 - var sendCalled: Bool { - return sendCallsCount > 0 - } - var sendReceivedRequest: (Request)? - var sendReceivedInvocations: [(Request)] = [] - var sendClosure: ((Request) -> Void)? - - func send(_ request: Request) { - sendCallsCount += 1 - sendReceivedRequest = request - sendReceivedInvocations.append(request) - sendClosure?(request) - } - - // MARK: - invalidate - - var invalidateCallsCount = 0 - var invalidateCalled: Bool { - return invalidateCallsCount > 0 - } - var invalidateClosure: (() -> Void)? - - func invalidate() { - invalidateCallsCount += 1 - invalidateClosure?() - } - -} - -// swiftlint:enable all diff --git a/Tests/pingxTests/Mocks/Generated/PingxSocket+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/PingxSocket+AutoMockable.generated.swift index 05ed3c3..54d875d 100644 --- a/Tests/pingxTests/Mocks/Generated/PingxSocket+AutoMockable.generated.swift +++ b/Tests/pingxTests/Mocks/Generated/PingxSocket+AutoMockable.generated.swift @@ -6,7 +6,7 @@ import Foundation @testable import pingx -class PingxSocketMock: PingxSocket { +final class PingxSocketMock: PingxSocketProtocol { // MARK: - send @@ -30,19 +30,6 @@ class PingxSocketMock: PingxSocket { } } - // MARK: - invalidate - - var invalidateCallsCount = 0 - var invalidateCalled: Bool { - return invalidateCallsCount > 0 - } - var invalidateClosure: (() -> Void)? - - func invalidate() { - invalidateCallsCount += 1 - invalidateClosure?() - } - } // swiftlint:enable all diff --git a/Tests/pingxTests/Mocks/Generated/PingxTimer+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/PingxTimer+AutoMockable.generated.swift deleted file mode 100644 index c777af8..0000000 --- a/Tests/pingxTests/Mocks/Generated/PingxTimer+AutoMockable.generated.swift +++ /dev/null @@ -1,52 +0,0 @@ -// Generated using Sourcery 2.2.5 — https://github.com/krzysztofzablocki/Sourcery -// DO NOT EDIT -// swiftlint:disable all - -import Foundation - -@testable import pingx - -class PingxTimerMock: PingxTimer { - - // MARK: - start - - var startCallsCount = 0 - var startCalled: Bool { - return startCallsCount > 0 - } - var startClosure: (() -> Void)? - - func start() { - startCallsCount += 1 - startClosure?() - } - - // MARK: - stop - - var stopCallsCount = 0 - var stopCalled: Bool { - return stopCallsCount > 0 - } - var stopClosure: (() -> Void)? - - func stop() { - stopCallsCount += 1 - stopClosure?() - } - - // MARK: - fire - - var fireCallsCount = 0 - var fireCalled: Bool { - return fireCallsCount > 0 - } - var fireClosure: (() -> Void)? - - func fire() { - fireCallsCount += 1 - fireClosure?() - } - -} - -// swiftlint:enable all diff --git a/Tests/pingxTests/Mocks/Generated/SocketFactory+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/SocketFactory+AutoMockable.generated.swift index aa62b67..d55b2a8 100644 --- a/Tests/pingxTests/Mocks/Generated/SocketFactory+AutoMockable.generated.swift +++ b/Tests/pingxTests/Mocks/Generated/SocketFactory+AutoMockable.generated.swift @@ -6,31 +6,31 @@ import Foundation @testable import pingx -class SocketFactoryMock: SocketFactory { +final class SocketFactoryMock: SocketFactoryProtocol { - // MARK: - create + // MARK: - make - var createThrowableError: (any Error)? - var createCallsCount = 0 - var createCalled: Bool { - return createCallsCount > 0 + var makeThrowableError: (any Error)? + var makeCallsCount = 0 + var makeCalled: Bool { + return makeCallsCount > 0 } - var createReceivedCommand: (CommandBlock)? - var createReceivedInvocations: [(CommandBlock)] = [] - var createReturnValue: (any PingxSocket)! - var createClosure: ((CommandBlock) throws -> any PingxSocket)? - - func create(command: CommandBlock) throws -> any PingxSocket { - createCallsCount += 1 - createReceivedCommand = command - createReceivedInvocations.append(command) - if let error = createThrowableError { + var makeReceivedCommand: (CommandBlock)? + var makeReceivedInvocations: [(CommandBlock)] = [] + var makeReturnValue: (any PingxSocketProtocol)! + var makeClosure: ((CommandBlock) throws -> any PingxSocketProtocol)? + + func make(command: CommandBlock) throws -> any PingxSocketProtocol { + makeCallsCount += 1 + makeReceivedCommand = command + makeReceivedInvocations.append(command) + if let error = makeThrowableError { throw error } - if let createClosure = createClosure { - return try createClosure(command) + if let makeClosure = makeClosure { + return try makeClosure(command) } else { - return createReturnValue + return makeReturnValue } } diff --git a/Tests/pingxTests/Mocks/Generated/TimerFactory+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/TimerFactory+AutoMockable.generated.swift deleted file mode 100644 index 72b6371..0000000 --- a/Tests/pingxTests/Mocks/Generated/TimerFactory+AutoMockable.generated.swift +++ /dev/null @@ -1,35 +0,0 @@ -// Generated using Sourcery 2.2.5 — https://github.com/krzysztofzablocki/Sourcery -// DO NOT EDIT -// swiftlint:disable all - -import Foundation - -@testable import pingx - -class TimerFactoryMock: TimerFactory { - - // MARK: - createDispatchSourceTimer - - var createDispatchSourceTimerCallsCount = 0 - var createDispatchSourceTimerCalled: Bool { - return createDispatchSourceTimerCallsCount > 0 - } - var createDispatchSourceTimerReceivedArguments: (timeInterval: TimeInterval, eventQueue: DispatchQueue, eventHandler: () -> Void)? - var createDispatchSourceTimerReceivedInvocations: [(timeInterval: TimeInterval, eventQueue: DispatchQueue, eventHandler: () -> Void)] = [] - var createDispatchSourceTimerReturnValue: PingxTimer! - var createDispatchSourceTimerClosure: ((TimeInterval, DispatchQueue, @escaping () -> Void) -> PingxTimer)? - - func createDispatchSourceTimer(timeInterval: TimeInterval, eventQueue: DispatchQueue, eventHandler: @escaping () -> Void) -> PingxTimer { - createDispatchSourceTimerCallsCount += 1 - createDispatchSourceTimerReceivedArguments = (timeInterval: timeInterval, eventQueue: eventQueue, eventHandler: eventHandler) - createDispatchSourceTimerReceivedInvocations.append((timeInterval: timeInterval, eventQueue: eventQueue, eventHandler: eventHandler)) - if let createDispatchSourceTimerClosure = createDispatchSourceTimerClosure { - return createDispatchSourceTimerClosure(timeInterval, eventQueue, eventHandler) - } else { - return createDispatchSourceTimerReturnValue - } - } - -} - -// swiftlint:enable all diff --git a/Tests/pingxTests/Mocks/MockFunc.swift b/Tests/pingxTests/Mocks/MockFunc.swift new file mode 100644 index 0000000..4557d6e --- /dev/null +++ b/Tests/pingxTests/Mocks/MockFunc.swift @@ -0,0 +1,61 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +final class MockFunc { + private(set) var parameters: [Input] = [] + private(set) var result: (Input) -> Output = { _ in fatalError() } + + init() {} + + init(result: @escaping (Input) -> Output) { + self.result = result + } + + var count: Int { + return parameters.count + } + + var called: Bool { + return !parameters.isEmpty + } + + var output: Output { + return result(input) + } + + var input: Input { + return parameters[count - 1] + } + + func call(with input: Input) { + parameters.append(input) + } + + func callAndReturn(_ input: Input) -> Output { + call(with: input) + return output + } +} diff --git a/Tests/pingxTests/Mocks/MockPingSequence.swift b/Tests/pingxTests/Mocks/MockPingSequence.swift new file mode 100644 index 0000000..47a80fe --- /dev/null +++ b/Tests/pingxTests/Mocks/MockPingSequence.swift @@ -0,0 +1,54 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Testing + +@testable import pingx + +actor MockPingSequence: PingSequenceProtocol { + private var continuation: CheckedContinuation? + private(set) var nextCallsCount = 0 + + func next() async throws -> PingResult? { + nextCallsCount += 1 + + return await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + nonisolated func makeAsyncIterator() -> MockPingSequence { self } +} + +extension MockPingSequence { + func send(_ result: PingResult) async { + continuation?.resume(returning: result) + continuation = nil + } + + func finish() async { + continuation?.resume(returning: nil) + continuation = nil + } +} diff --git a/Tests/pingxTests/Mocks/PacketSenderDelegate/PacketSenderDelegateMock.swift b/Tests/pingxTests/Mocks/PacketSenderDelegate/PacketSenderDelegateMock.swift deleted file mode 100644 index bcec49b..0000000 --- a/Tests/pingxTests/Mocks/PacketSenderDelegate/PacketSenderDelegateMock.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// The MIT License (MIT) -// -// Copyright © 2025 Ilya Baryka. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import Foundation -@testable import pingx - -final class PacketSenderDelegateMock: PacketSenderDelegate { - typealias ResponseInvocation = (packetSender: PacketSender, data: Data) - typealias ErrorInvocation = (packetSender: PacketSender, request: Request, error: PacketSenderError) - - private(set) var didReceiveDataCalledCount: Int = 0 - private(set) var didReceiveDataInvocations: [ResponseInvocation] = [] - private(set) var didCompleteWithErrorCalledCount: Int = 0 - private(set) var didCompleteWithErrorInvocations: [ErrorInvocation] = [] - - func packetSender(packetSender: PacketSender, didReceive data: Data) { - didReceiveDataCalledCount += 1 - didReceiveDataInvocations.append((packetSender, data)) - } - - func packetSender(packetSender: PacketSender, request: Request, didCompleteWithError error: PacketSenderError) { - didCompleteWithErrorCalledCount += 1 - didCompleteWithErrorInvocations.append((packetSender, request, error)) - } -} diff --git a/Tests/pingxTests/Mocks/PingerDelegate/PingerDelegateMock.swift b/Tests/pingxTests/Mocks/PingerDelegate/PingerDelegateMock.swift deleted file mode 100644 index ff08b2d..0000000 --- a/Tests/pingxTests/Mocks/PingerDelegate/PingerDelegateMock.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// The MIT License (MIT) -// -// Copyright © 2025 Ilya Baryka. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import XCTest -@testable import pingx - -final class PingerDelegateMock: PingerDelegate { - typealias ResponseInvocation = (pinger: Pinger, request: Request, response: Response) - typealias ErrorInvocation = (pinger: Pinger, request: Request, error: PingerError) - - private(set) var pingerDidReceiveResponseCalledCount: Int = 0 - private(set) var pingerDidReceiveResponseInvocations: [ResponseInvocation] = [] - private(set) var pingerDidCompleteWithErrorCalledCount: Int = 0 - private(set) var pingerDidCompleteWithErrorInvocations: [ErrorInvocation] = [] - - func pinger(_ pinger: Pinger, request: Request, didReceive response: Response) { - pingerDidReceiveResponseCalledCount += 1 - pingerDidReceiveResponseInvocations.append((pinger, request, response)) - } - - func pinger(_ pinger: Pinger, request: Request, didCompleteWithError error: PingerError) { - pingerDidCompleteWithErrorCalledCount += 1 - pingerDidCompleteWithErrorInvocations.append((pinger, request, error)) - } -} diff --git a/Tests/pingxTests/Mocks/ThreadSafeMockFunc.swift b/Tests/pingxTests/Mocks/ThreadSafeMockFunc.swift new file mode 100644 index 0000000..d0113f8 --- /dev/null +++ b/Tests/pingxTests/Mocks/ThreadSafeMockFunc.swift @@ -0,0 +1,63 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +@testable import pingx + +final class ThreadSafeMockFunc { + @Atomic private(set) var parameters: [Input] = [] + @Atomic private(set) var result: (Input) -> Output = { _ in fatalError() } + + init() {} + + init(result: @escaping (Input) -> Output) { + self.result = result + } + + var count: Int { + return parameters.count + } + + var called: Bool { + return !parameters.isEmpty + } + + var output: Output { + return result(input) + } + + var input: Input { + return parameters[count - 1] + } + + func call(with input: Input) { + parameters.append(input) + } + + func callAndReturn(_ input: Input) -> Output { + call(with: input) + return output + } +} diff --git a/Tests/pingxTests/PacketSender/PacketSenderTests.swift b/Tests/pingxTests/PacketSender/PacketSenderTests.swift deleted file mode 100644 index fe73ec5..0000000 --- a/Tests/pingxTests/PacketSender/PacketSenderTests.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// The MIT License (MIT) -// -// Copyright © 2025 Ilya Baryka. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import XCTest -@testable import pingx - -final class PacketSenderTests: XCTestCase { - - // MARK: Properties - - private var socket: PingxSocketMock! - private var socketFactory: SocketFactoryMock! - private var packetFactory: PacketFactoryMock! - private var packetSenderDelegate: PacketSenderDelegateMock! - private var packetSender: PacketSenderImpl! - - // MARK: Override - - override func setUp() { - super.setUp() - - socket = PingxSocketMock() - socket.sendReturnValue = .success - - socketFactory = SocketFactoryMock() - socketFactory.createReturnValue = socket - - packetFactory = PacketFactoryMock() - packetFactory.createReturnValue = PacketMock() - - packetSenderDelegate = PacketSenderDelegateMock() - packetSender = PacketSenderImpl(socketFactory: socketFactory, packetFactory: packetFactory) - packetSender.delegate = packetSenderDelegate - } - - override func tearDown() { - super.tearDown() - - socket = nil - socketFactory = nil - packetFactory = nil - packetSenderDelegate = nil - packetSender = nil - } - - // MARK: Tests - - func test_send_whenResponseReceived_notifiesDelegate() { - let request = Request(destination: Constants.ipv4) - let data = Data() - - packetSender.send(request) - socketFactory.createReceivedInvocations.last?.closure(data) - - XCTAssertEqual(socketFactory.createCallsCount, 1) - XCTAssertEqual(packetFactory.createReceivedInvocations[0].identifier, request.id) - XCTAssertEqual(packetFactory.createReceivedInvocations[0].type, .icmp) - XCTAssertEqual(socket.sendCallsCount, 1) - XCTAssertEqual(packetSenderDelegate.didReceiveDataCalledCount, 1) - XCTAssertTrue(packetSenderDelegate.didReceiveDataInvocations[0].packetSender === packetSender) - XCTAssertEqual(packetSenderDelegate.didReceiveDataInvocations[0].data, data) - } - - func test_send_whenSocketAlreadyCreated_doesNotCreateSocket() { - let request = Request(destination: Constants.ipv4) - - packetSender.send(request) - packetSender.send(request) - - XCTAssertEqual(socketFactory.createCallsCount, 1) - } - - func test_send_whenSocketCreationFailed_throwsError() throws { - let request = Request(destination: Constants.ipv4) - - socketFactory.createThrowableError = PacketSenderError.socketCreationError - packetSender.send(request) - - XCTAssertEqual(packetSenderDelegate.didCompleteWithErrorCalledCount, 1) - XCTAssertTrue(packetSenderDelegate.didCompleteWithErrorInvocations[0].packetSender === packetSender) - XCTAssertEqual(packetSenderDelegate.didCompleteWithErrorInvocations[0].request, request) - XCTAssertEqual(packetSenderDelegate.didCompleteWithErrorInvocations[0].error, .socketCreationError) - XCTAssertEqual(socket.sendCallsCount, 0) - } - - func test_send_whenSocketFailed_throwsError() { - let request = Request(destination: Constants.ipv4) - let errors: [CFSocketError] = [.error, .timeout] - let expectedPacketSenderErrors: [PacketSenderError] = [.error, .timeout] - - for index in errors.indices { - socket.sendReturnValue = errors[index] - packetSender.send(request) - - XCTAssertEqual( - packetSenderDelegate.didCompleteWithErrorInvocations[index].error, - expectedPacketSenderErrors[index] - ) - } - } - - func test_send_packetCreationFailed_throwsError() throws { - let request = Request(destination: Constants.ipv4) - - packetFactory.createThrowableError = ICMPChecksum.ChecksumError.outOfBounds - packetSender.send(request) - - XCTAssertEqual(packetSenderDelegate.didCompleteWithErrorCalledCount, 1) - XCTAssertTrue(packetSenderDelegate.didCompleteWithErrorInvocations[0].packetSender === packetSender) - XCTAssertEqual(packetSenderDelegate.didCompleteWithErrorInvocations[0].request, request) - XCTAssertEqual(packetSenderDelegate.didCompleteWithErrorInvocations[0].error, .unableToCreatePacket) - XCTAssertEqual(socket.sendCallsCount, 0) - } - - func test_deinit_invalidate() throws { - let request = Request(destination: Constants.ipv4) - packetSender.send(request) - - packetSender = nil - - XCTAssertEqual(socket.invalidateCallsCount, 1) - } -} - -// MARK: - Constants - -private extension PacketSenderTests { - enum Constants { - static var ipv4: IPv4Address { .init(address: (8, 8, 8, 8)) } - } -} diff --git a/Tests/pingxTests/Pinger/PingerTests.swift b/Tests/pingxTests/Pinger/PingerTests.swift deleted file mode 100644 index d81e2e8..0000000 --- a/Tests/pingxTests/Pinger/PingerTests.swift +++ /dev/null @@ -1,340 +0,0 @@ -// -// The MIT License (MIT) -// -// Copyright © 2025 Ilya Baryka. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import XCTest -@testable import pingx - -final class PingerTests: XCTestCase { - - // MARK: Constants - - private enum Constants { - static var ipv4: IPv4Address { .init(address: (8, 8, 8, 8)) } - } - - // MARK: Properties - - private var timer: PingxTimerMock! - private var timerFactory: TimerFactoryMock! - private var packetSender: PacketSenderMock! - private var pingerDelegate: PingerDelegateMock! - private var pinger: ContinuousPinger! - - // MARK: Override - - override func setUp() { - super.setUp() - - packetSender = PacketSenderMock() - pingerDelegate = PingerDelegateMock() - - timer = PingxTimerMock() - timerFactory = TimerFactoryMock() - timerFactory.createDispatchSourceTimerReturnValue = timer - - pinger = ContinuousPinger( - configuration: PingerConfiguration(interval: .seconds(.zero)), - packetSender: packetSender, - timerFactory: timerFactory - ) - pinger.delegate = pingerDelegate - } - - override func tearDown() { - super.tearDown() - - timer = nil - timerFactory = nil - pingerDelegate = nil - packetSender = nil - pinger = nil - } - - // MARK: Tests - - func test_start_whenRequestIsNotOutgoingAndDemandIsGreaterThanZero_sendsPingRequest() { - let request = Request(destination: Constants.ipv4, demand: .max(1)) - - pinger.ping(request: request) - - XCTAssertEventuallyEqual(self.packetSender.sendReceivedInvocations, [request]) - XCTAssertEventuallyEqual(self.timerFactory.createDispatchSourceTimerCallsCount, 1) - } - - func test_start_whenRequestIsOutgoingAndDemandIsGreaterThanZero_returnsIsOutgoingError() { - let request = Request(destination: Constants.ipv4, demand: .max(1)) - - pinger.ping(request: request) - pinger.ping(request: request) - - XCTAssertEventuallyEqual(self.packetSender.sendReceivedInvocations, [request]) - XCTAssertEqual(timerFactory.createDispatchSourceTimerCallsCount, 1) - XCTAssertEqual(pingerDelegate.pingerDidCompleteWithErrorCalledCount, 1) - XCTAssertTrue(pingerDelegate.pingerDidCompleteWithErrorInvocations[0].pinger === pinger) - XCTAssertEqual(pingerDelegate.pingerDidCompleteWithErrorInvocations[0].error, .pingInProgress) - XCTAssertEqual(pingerDelegate.pingerDidCompleteWithErrorInvocations[0].request, request) - } - - func test_start_whenDemandIsZero_doesNotSendRequest() { - let request = Request(destination: Constants.ipv4, demand: .max(0)) - - pinger.ping(request: request) - - XCTAssertEqual(packetSender.sendCallsCount, 0) - XCTAssertEqual(timerFactory.createDispatchSourceTimerCallsCount, 0) - } - - func test_stop_invalidatesTimer() { - let request = Request(destination: Constants.ipv4) - - pinger.ping(request: request) - pinger.stop(request: request) - - XCTAssertEventuallyEqual(self.timer.stopCallsCount, 1) - } - - func test_stopByRequestId_invalidatesTimer() { - let request = Request(destination: Constants.ipv4) - - pinger.ping(request: request) - pinger.stop(requestId: request.id) - - XCTAssertEventuallyEqual(self.timer.stopCallsCount, 1) - } - - func test_stop_whenResponseReceived_doesNotNotifyDelegate() throws { - let request = Request(destination: Constants.ipv4) - - pinger.ping(request: request) - pinger.stop(request: request) - try simulateValidResponse(for: request) - - XCTAssertEqual(pingerDelegate.pingerDidReceiveResponseCalledCount, 0) - XCTAssertEqual(pingerDelegate.pingerDidCompleteWithErrorCalledCount, 0) - } - - func test_deinit_invalidatesTimer() { - let request = Request(destination: Constants.ipv4) - pinger.ping(request: request) - - pinger = nil - - XCTAssertEventuallyEqual(self.timer.stopCallsCount, 1) - } -} - -// MARK: - Demand Tests - -extension PingerTests { - func test_demand_whenZero_throwsError() { - let request = Request(destination: Constants.ipv4, demand: .none) - - pinger.ping(request: request) - - XCTAssertEventuallyEqual(self.pingerDelegate.pingerDidReceiveResponseCalledCount, 0) - XCTAssertEventuallyEqual(self.pingerDelegate.pingerDidCompleteWithErrorCalledCount, 1) - XCTAssertEqual(pingerDelegate.pingerDidCompleteWithErrorInvocations[0].error, .invalidDemand) - } - - func test_demand_whenLimited_sendsRequestMultipleTimes() throws { - let demandValue = 2 - let request = Request(destination: Constants.ipv4, demand: .max(demandValue)) - - pinger.ping(request: request) - - for index in 0...demandValue { - try simulateValidResponse(for: request) - - let expectedDemandValue = demandValue - index - 1 - let expectedDemand: Request.Demand = expectedDemandValue > 0 ? .max(expectedDemandValue) : .none - XCTAssertEventuallyEqual(request.demand, expectedDemand) - } - - XCTAssertEventuallyEqual(self.pingerDelegate.pingerDidReceiveResponseCalledCount, 2) - XCTAssertEventuallyEqual(self.packetSender.sendCallsCount, 2) - XCTAssertEqual(pingerDelegate.pingerDidCompleteWithErrorCalledCount, 0) - } -} - -// MARK: - Error Tests - -extension PingerTests { - func test_error_whenRequestIsTimedOut() { - let timeoutInterval: TimeInterval = 5 - let request = Request(destination: Constants.ipv4, timeoutInterval: timeoutInterval) - - pinger.ping(request: request) - - XCTAssertEventuallyEqual(self.packetSender.sendCallsCount, 1) - - for _ in 0.. 0 ? .max(expectedDemandValue) : .none - XCTAssertEventuallyEqual(request.demand, expectedDemand) - } - - XCTAssertEventuallyEqual(self.packetSender.sendCallsCount, 2) - } - - func test_error_whenValidationFailed_notifiesDelegateAboutInvalidResponse() { - let request = Request(destination: Constants.ipv4, demand: .unlimited) - let validationErrors: [ICMPResponseValidationError] = [ - .checksumMismatch( - .sample(type: .echoReply, identifier: request.id) - ), - .invalidCode( - .sample(type: .echoReply, code: UInt8.random(in: 200...255), identifier: request.id) - ), - .invalidPayload( - .sample( - type: .echoReply, - identifier: request.id, - payload: Payload(identifier: (0, 0, 0, 0, 0, 0, 0, 0)) - ) - ), - .invalidType( - .sample(type: .routerSolicitation, identifier: request.id) - ) - ] - - pinger.ping(request: request) - - for (index, error) in validationErrors.enumerated() { - simulateErrorResponse(for: request, error: error) - - XCTAssertEventuallyEqual(self.pingerDelegate.pingerDidCompleteWithErrorCalledCount, index + 1) - XCTAssertTrue(pingerDelegate.pingerDidCompleteWithErrorInvocations[index].pinger === pinger) - XCTAssertEqual(pingerDelegate.pingerDidCompleteWithErrorInvocations[index].request, request) - XCTAssertEqual(pingerDelegate.pingerDidCompleteWithErrorInvocations[index].error, .invalidResponse) - } - } - - func test_packetSenderSocketFailure_notifiesDelegate() { - let request = Request(destination: Constants.ipv4, demand: .unlimited) - - pinger.ping(request: request) - pinger.packetSender(packetSender: packetSender, request: request, didCompleteWithError: .socketCreationError) - - XCTAssertEventuallyEqual(self.pingerDelegate.pingerDidCompleteWithErrorCalledCount, 1) - XCTAssertTrue(pingerDelegate.pingerDidCompleteWithErrorInvocations[0].pinger === pinger) - XCTAssertEqual(pingerDelegate.pingerDidCompleteWithErrorInvocations[0].request, request) - XCTAssertEqual(pingerDelegate.pingerDidCompleteWithErrorInvocations[0].error, .socketFailed) - } - - func test_packetSenderSocketFailure_resendsRequest() { - let request = Request(destination: Constants.ipv4, demand: .unlimited) - - pinger.ping(request: request) - pinger.packetSender(packetSender: packetSender, request: request, didCompleteWithError: .socketCreationError) - - XCTAssertEventuallyEqual(self.packetSender.sendCallsCount, 2) - } -} - -private extension PingerTests { - func simulateValidResponse(for request: Request) throws { - let ipHeader = IPHeader( - totalLength: .zero, - headerChecksum: .zero, - sourceAddress: request.destination, - destinationAddress: .init(address: (127, 0, 0, 1)) - ) - var icmpHeader = ICMPHeader( - type: .echoReply, - code: .zero, - identifier: request.id, - sequenceNumber: .zero, - payload: Payload() - ) - - guard let checksum = try? ICMPChecksum()(icmpHeader: icmpHeader) else { - throw NSError(domain: "Checksum calculation failed", code: .zero) - } - icmpHeader.setChecksum(checksum) - - var icmpPacket = ICMPPacket(ipHeader: ipHeader, icmpHeader: icmpHeader) - let data = Data(bytes: &icmpPacket, count: MemoryLayout.size) - - pinger.packetSender(packetSender: packetSender, didReceive: data) - } - - func simulateErrorResponse(for request: Request, error: ICMPResponseValidationError) { - let ipHeader = IPHeader( - totalLength: .zero, - headerChecksum: .min, - sourceAddress: request.destination, - destinationAddress: request.destination - ) - let data: Data - - switch error { - case .checksumMismatch(let icmpHeader): - var icmp = ICMPPacket(ipHeader: ipHeader, icmpHeader: icmpHeader) - data = Data(bytes: &icmp, count: MemoryLayout.size) - case .invalidCode(let icmpHeader): - var icmp = ICMPPacket(ipHeader: ipHeader, icmpHeader: icmpHeader) - data = Data(bytes: &icmp, count: MemoryLayout.size) - case .invalidPayload(let icmpHeader): - var icmp = ICMPPacket(ipHeader: ipHeader, icmpHeader: icmpHeader) - data = Data(bytes: &icmp, count: MemoryLayout.size) - case .invalidType(let icmpHeader): - var icmp = ICMPPacket(ipHeader: ipHeader, icmpHeader: icmpHeader) - data = Data(bytes: &icmp, count: MemoryLayout.size) - case .missedIpHeader, .missedIcmpHeader: - data = Data() - } - - pinger.packetSender(packetSender: packetSender, didReceive: data) - } -} diff --git a/Tests/pingxTests/Request/DemandTests.swift b/Tests/pingxTests/Request/DemandTests.swift deleted file mode 100644 index 06b8f7e..0000000 --- a/Tests/pingxTests/Request/DemandTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// The MIT License (MIT) -// -// Copyright © 2025 Ilya Baryka. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import XCTest -@testable import pingx - -final class DemandTests: XCTestCase { - - // MARK: Tests - - func testDemand_initialize() { - var demand: Request.Demand - - demand = .none - XCTAssertEqual(demand.max, .zero) - - demand = .unlimited - XCTAssertNil(demand.max) - - demand = .max(3) - XCTAssertEqual(demand.max, 3) - - expectFatalError(expectedMessage: "The value cannot be lower than 0.") { - demand = .max(-1) - } - } - - func testDemand_subtraction() { - let demandsA: [Request.Demand] = [.unlimited, .unlimited, .unlimited, .max(3), .max(2), .max(2), .max(3)] - let demandsB: [Request.Demand] = [.unlimited, .none, .max(3), .max(2), .max(3), .max(2), .unlimited] - let demandsC: [Request.Demand] = [.unlimited, .unlimited, .unlimited, .max(1), .none, .none, .none] - - for (index, (demandA, demandB)) in zip(demandsA, demandsB).enumerated() { - XCTAssertEqual(demandA - demandB, demandsC[index]) - } - } - - func testDemand_addition() { - let demandsA: [Request.Demand] = [.unlimited, .none, .unlimited, .max(1), .max(1), .none, .none] - let demandsB: [Request.Demand] = [.unlimited, .unlimited, .none, .max(2), .none, .max(2), .none] - let demandsC: [Request.Demand] = [.unlimited, .unlimited, .unlimited, .max(3), .max(1), .max(2), .none] - - for (index, (demandA, demandB)) in zip(demandsA, demandsB).enumerated() { - XCTAssertEqual(demandA + demandB, demandsC[index]) - } - } -} diff --git a/Sources/pingx/Model/PacketType/PacketType.swift b/Tests/pingxTests/Samples/Error+Sample.swift similarity index 96% rename from Sources/pingx/Model/PacketType/PacketType.swift rename to Tests/pingxTests/Samples/Error+Sample.swift index 5aae450..b22f4e5 100644 --- a/Sources/pingx/Model/PacketType/PacketType.swift +++ b/Tests/pingxTests/Samples/Error+Sample.swift @@ -22,6 +22,4 @@ // SOFTWARE. // -public enum PacketType { - case icmp -} +struct AnyError: Error {} diff --git a/Tests/pingxTests/Samples/ICMPHeader+Sample.swift b/Tests/pingxTests/Samples/ICMPHeader+Sample.swift index 1e8c488..eb2d8c4 100644 --- a/Tests/pingxTests/Samples/ICMPHeader+Sample.swift +++ b/Tests/pingxTests/Samples/ICMPHeader+Sample.swift @@ -31,7 +31,7 @@ extension ICMPHeader { checksum: UInt16 = .zero, identifier: UInt16 = .zero, sequenceNumber: UInt16 = .zero, - payload: Payload = Payload() + payload: Payload = .sample() ) -> ICMPHeader { ICMPHeader( type: type, diff --git a/Sources/pingx/PacketSender/Delegate/PacketSenderDelegate.swift b/Tests/pingxTests/Samples/ICMPPacket+Sample.swift similarity index 80% rename from Sources/pingx/PacketSender/Delegate/PacketSenderDelegate.swift rename to Tests/pingxTests/Samples/ICMPPacket+Sample.swift index 548de0e..675fbd4 100644 --- a/Sources/pingx/PacketSender/Delegate/PacketSenderDelegate.swift +++ b/Tests/pingxTests/Samples/ICMPPacket+Sample.swift @@ -22,9 +22,16 @@ // SOFTWARE. // -import Foundation +@testable import pingx -protocol PacketSenderDelegate: AnyObject { - func packetSender(packetSender: PacketSender, didReceive data: Data) - func packetSender(packetSender: PacketSender, request: Request, didCompleteWithError error: PacketSenderError) +extension ICMPPacket { + static func sample( + ipHeader: IPHeader = .sample(), + icmpHeader: ICMPHeader = .sample() + ) -> ICMPPacket { + ICMPPacket( + ipHeader: ipHeader, + icmpHeader: icmpHeader + ) + } } diff --git a/Tests/pingxTests/Samples/IPHeader+Sample.swift b/Tests/pingxTests/Samples/IPHeader+Sample.swift new file mode 100644 index 0000000..e2b720a --- /dev/null +++ b/Tests/pingxTests/Samples/IPHeader+Sample.swift @@ -0,0 +1,53 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +@testable import pingx + +extension IPHeader { + static func sample( + versionAndHeaderLength: UInt8 = 4, + serviceType: UInt8 = .zero, + totalLength: UInt16 = .zero, + identifier: UInt16 = .zero, + flagsAndFragmentOffset: UInt16 = .zero, + timeToLive: UInt8 = 64, + protocol: UInt8 = 1, + headerChecksum: UInt16 = .zero, + sourceAddress: IPv4Address = .sample(), + destinationAddress: IPv4Address = .sample() + ) -> IPHeader { + IPHeader( + versionAndHeaderLength: versionAndHeaderLength, + serviceType: serviceType, + totalLength: totalLength, + identifier: identifier, + flagsAndFragmentOffset: flagsAndFragmentOffset, + timeToLive: timeToLive, + protocol: `protocol`, + headerChecksum: headerChecksum, + sourceAddress: sourceAddress, + destinationAddress: destinationAddress + ) + } +} diff --git a/Tests/pingxTests/Mocks/Packet/PacketMock.swift b/Tests/pingxTests/Samples/IPv4Address+Sample.swift similarity index 85% rename from Tests/pingxTests/Mocks/Packet/PacketMock.swift rename to Tests/pingxTests/Samples/IPv4Address+Sample.swift index fb7de3d..1a813d6 100644 --- a/Tests/pingxTests/Mocks/Packet/PacketMock.swift +++ b/Tests/pingxTests/Samples/IPv4Address+Sample.swift @@ -23,8 +23,15 @@ // import Foundation + @testable import pingx -struct PacketMock: Packet { - let data = Data() +extension IPv4Address { + static func sample( + address: (UInt8, UInt8, UInt8, UInt8) = (8, 8, 8, 8) + ) -> IPv4Address { + IPv4Address( + address: address + ) + } } diff --git a/Tests/pingxTests/Samples/Payload+Sample.swift b/Tests/pingxTests/Samples/Payload+Sample.swift new file mode 100644 index 0000000..01d8650 --- /dev/null +++ b/Tests/pingxTests/Samples/Payload+Sample.swift @@ -0,0 +1,44 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +@testable import pingx + +extension Payload { + static func sample( + rawUniqueToken: uuid_t = ( + 0x89, 0xA7, 0xD4, 0x8B, + 0x38, 0x23, 0x4F, 0x1F, + 0x9B, 0x1A, 0xA6, 0x1B, + 0x3E, 0xD8, 0xE2, 0xA9 + ), + timestamp: CFAbsoluteTime = .zero + ) -> Payload { + Payload( + rawUniqueToken: rawUniqueToken, + timestamp: timestamp + ) + } +} diff --git a/Sources/pingx/Extensions/DispatchQueue+Extensions.swift b/Tests/pingxTests/Samples/PingxIdentifier+Sample.swift similarity index 72% rename from Sources/pingx/Extensions/DispatchQueue+Extensions.swift rename to Tests/pingxTests/Samples/PingxIdentifier+Sample.swift index 20d1218..4b81139 100644 --- a/Sources/pingx/Extensions/DispatchQueue+Extensions.swift +++ b/Tests/pingxTests/Samples/PingxIdentifier+Sample.swift @@ -24,23 +24,23 @@ import Foundation -func performAfter( - deadline: DispatchTime, - _ function: @escaping (T) -> Void, - value: T, - on queue: DispatchQueue -) { - queue.asyncAfter(deadline: deadline) { - function(value) - } -} +@testable import pingx -func perform(_ function: @escaping (T) -> Void, value: T, on queue: DispatchQueue) { - queue.async { - function(value) +extension PingxIdentifier { + static func sample( + id: UInt16 = .zero, + uniqueToken: UUID = UUID( + uuid: ( + 0x89, 0xA7, 0xD4, 0x8B, + 0x38, 0x23, 0x4F, 0x1F, + 0x9B, 0x1A, 0xA6, 0x1B, + 0x3E, 0xD8, 0xE2, 0xA9 + ) + ) + ) -> PingxIdentifier { + PingxIdentifier( + id: id, + uniqueToken: uniqueToken + ) } } - -func perform(_ function: @escaping () -> Void, on queue: DispatchQueue) { - queue.async(execute: function) -} diff --git a/Tests/pingxTests/Samples/Request+Sample.swift b/Tests/pingxTests/Samples/Request+Sample.swift new file mode 100644 index 0000000..b6ee16a --- /dev/null +++ b/Tests/pingxTests/Samples/Request+Sample.swift @@ -0,0 +1,45 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +@testable import pingx + +extension Request { + static func sample( + id: Request.Identifier = .sample(), + destination: IPv4Address = .sample(), + timeoutInterval: Interval = .seconds(1), + demand: Demand = .max(1), + sequenceNumber: UInt16 = .zero + ) -> Request { + Request( + id: id, + destination: destination, + timeoutInterval: timeoutInterval, + demand: demand, + sequenceNumber: sequenceNumber + ) + } +} diff --git a/Sources/pingx/Factory/TimerFactory/Impl/TimerFactoryImpl.swift b/Tests/pingxTests/Samples/Response+Sample.swift similarity index 76% rename from Sources/pingx/Factory/TimerFactory/Impl/TimerFactoryImpl.swift rename to Tests/pingxTests/Samples/Response+Sample.swift index db15d4b..e9b86ff 100644 --- a/Sources/pingx/Factory/TimerFactory/Impl/TimerFactoryImpl.swift +++ b/Tests/pingxTests/Samples/Response+Sample.swift @@ -24,16 +24,18 @@ import Foundation -final class TimerFactoryImpl: TimerFactory { - func createDispatchSourceTimer( - timeInterval: TimeInterval, - eventQueue: DispatchQueue, - eventHandler: @escaping () -> Void - ) -> PingxTimer { - PingxDispatchSourceTimer( - timeInterval: timeInterval, - eventQueue: eventQueue, - eventHandler: eventHandler +@testable import pingx + +extension Response { + static func sample( + destination: IPv4Address = .sample(), + duration: Interval = .milliseconds(.zero), + sequenceNumber: UInt16 = .zero + ) -> Response { + Response( + destination: destination, + duration: duration, + sequenceNumber: sequenceNumber ) } } diff --git a/Tests/pingxTests/ICMPChecksum/ICMPChecksumTests.swift b/Tests/pingxTests/Tests/ICMPChecksum/ICMPChecksumTests.swift similarity index 91% rename from Tests/pingxTests/ICMPChecksum/ICMPChecksumTests.swift rename to Tests/pingxTests/Tests/ICMPChecksum/ICMPChecksumTests.swift index f70afd6..10a8a92 100644 --- a/Tests/pingxTests/ICMPChecksum/ICMPChecksumTests.swift +++ b/Tests/pingxTests/Tests/ICMPChecksum/ICMPChecksumTests.swift @@ -29,9 +29,7 @@ import Testing struct ICMPChecksumTests { @Test( "Calculate checksum", - arguments: [ - (icmpHeader: ICMPHeader.sample(payload: Payload(timestamp: .zero)), expectedChecksum: UInt16(11945)) - ] + arguments: [(icmpHeader: ICMPHeader.sample(), expectedChecksum: UInt16(53687))] ) func calculate(icmpHeader: ICMPHeader, expectedChecksum: UInt16) { let actualChecksum = try? ICMPChecksum()(icmpHeader: icmpHeader) diff --git a/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift b/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift new file mode 100644 index 0000000..6a874e2 --- /dev/null +++ b/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift @@ -0,0 +1,172 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import Testing +import XCTest + +@testable import pingx + +@Suite +struct ICMPPackageExtractorTests { + private let extractor: ICMPPacketExtractor + + init () { + self.extractor = ICMPPacketExtractor() + } + + @Test("When data is valid, it returns icmp packet") + func extract_whenDataIsValid_returnsIcmpPacket() throws { + let request = Request.sample() + let data = try makeCorrectData(for: request) + + let icmpPacket = try extractor.extract(from: data) + + #expect(icmpPacket.icmpHeader.identifier == request.identifier.id) + #expect(icmpPacket.ipHeader.sourceAddress == request.destination) + } + + @Test("When icmp type is not echo reply, it throws invalidType error") + func extract_whenIcmpTypeIsNotEchoReply_throwsInvalidTypeError() async throws { + let icmpHeader = ICMPHeader.sample(type: .addressMaskReply) + let data = makeErrorData(icmpHeader: icmpHeader) + + #expect(throws: ICMPResponseValidationError.invalidType(icmpHeader)) { + try extractor.extract(from: data) + } + } + + @Test("When code is not zero, it throws invalidType error") + func extract_whenCodeIsNotZero_throwsInvalidCodeError() async throws { + let icmpHeader = ICMPHeader.sample(code: .max) + let data = makeErrorData(icmpHeader: icmpHeader) + + #expect(throws: ICMPResponseValidationError.invalidCode(icmpHeader)) { + try extractor.extract(from: data) + } + } + + @Test("When checksum is wrong, it throws checksumMismatch error") + func extract_whenChecksumIsWrong_throwsChecksumMismatchError() async throws { + let icmpHeader = ICMPHeader.sample(checksum: .zero) + let data = makeErrorData(icmpHeader: icmpHeader) + + #expect(throws: ICMPResponseValidationError.checksumMismatch(icmpHeader)) { + try extractor.extract(from: data) + } + } + + @Test("When ip header is missing, it throws missedIpHeader error") + func extract_whenIpHeaderIsMissing_throwsMissedIpHeaderError() async throws { + let data = makeErrorData(shouldAddIpHeader: false) + + #expect(throws: ICMPResponseValidationError.missedIpHeader) { + try extractor.extract(from: data) + } + } + + @Test("When icmp header is missing, it throws missedIcmpHeader error") + func extract_whenIcmpHeaderIsMissing_throwsMissedIcmpHeaderError() async throws { + let data = makeErrorData(icmpHeader: nil) + + #expect(throws: ICMPResponseValidationError.missedIcmpHeader) { + try extractor.extract(from: data) + } + } +} + +private extension ICMPPackageExtractorTests { + func makeCorrectData( + for request: Request + ) throws -> Data { + let ipHeader = IPHeader.sample( + totalLength: .zero, + headerChecksum: .zero, + sourceAddress: request.destination, + destinationAddress: .init(address: (127, 0, 0, 1)) + ) + var icmpHeader = ICMPHeader.sample( + type: .echoReply, + code: .zero, + identifier: request.identifier.id, + sequenceNumber: .zero, + payload: .sample( + rawUniqueToken: request.identifier.uniqueToken.uuid + ) + ) + + guard let checksum = try? ICMPChecksum()(icmpHeader: icmpHeader) else { + throw NSError(domain: "Checksum calculation failed", code: .zero) + } + icmpHeader.setChecksum(checksum) + + let icmpPacket = ICMPPacket(ipHeader: ipHeader, icmpHeader: icmpHeader) + let data = withUnsafeBytes(of: icmpPacket) { Data($0) } + + return data + } + + func makeErrorData( + for request: Request = .sample(), + icmpHeader: ICMPHeader? = nil, + shouldAddIpHeader: Bool = true + ) -> Data { + let ipHeader = IPHeader.sample( + totalLength: .zero, + headerChecksum: .min, + sourceAddress: request.destination, + destinationAddress: request.destination + ) + + if let icmpHeader { + let icmpPacket = ICMPPacket.sample(ipHeader: ipHeader, icmpHeader: icmpHeader) + return withUnsafeBytes(of: icmpPacket) { Data($0) } + } + + if shouldAddIpHeader { + var ipHeader = ipHeader + return Data(bytes: &ipHeader, count: MemoryLayout.size) + } + + return Data() + } +} + +extension ICMPResponseValidationError: Equatable { + public static func == (lhs: ICMPResponseValidationError, rhs: ICMPResponseValidationError) -> Bool { + switch (lhs, rhs) { + case (.checksumMismatch(let lValue), .checksumMismatch(let rValue)): + return lValue == rValue + case (.invalidType(let lValue), .invalidType(let rValue)): + return lValue == rValue + case (.invalidCode(let lValue), .invalidCode(let rValue)): + return lValue == rValue + case (.missedIpHeader, .missedIpHeader), + (.missedIcmpHeader, .missedIcmpHeader): + return true + default: + return false + } + } +} diff --git a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift new file mode 100644 index 0000000..9debe8b --- /dev/null +++ b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift @@ -0,0 +1,419 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import Testing +import XCTest + +@testable import pingx + +@Suite +struct AsyncPingerTests { + private let socket: PingxSocketMock + private let icmpHeaderFactory: ICMPHeaderFactoryMock + private let icmpPacketExtractor: ICMPPacketExtractorMock + private let socketFactory: SocketFactoryMock + private var pinger: AsyncPinger! + + init() { + self.socket = PingxSocketMock() + self.socket.sendReturnValue = .success + + self.icmpHeaderFactory = ICMPHeaderFactoryMock() + self.icmpHeaderFactory.makeReturnValue = ICMPHeader.sample() + + self.icmpPacketExtractor = ICMPPacketExtractorMock() + self.icmpPacketExtractor.extractReturnValue = ICMPPacket.sample() + + self.socketFactory = SocketFactoryMock() + self.socketFactory.makeReturnValue = socket + + self.pinger = makeAsyncPinger() + } + + private func makeAsyncPinger( + configuration: PingConfiguration = PingConfiguration(intervalBetweenRequests: .milliseconds(0)) + ) -> AsyncPinger { + AsyncPinger( + configuration: configuration, + icmpHeaderFactory: icmpHeaderFactory, + icmpPacketExtractor: icmpPacketExtractor, + socketFactory: socketFactory + ) + } + + @Test("When socket is not created, it creates socket") + func send_whenSocketIsNotCreated_createsSocket() async throws { + let request = Request.sample() + + let sequence = pinger.ping(request: request) + try await observerPingSequenceWithoutReturningResult(sequence: sequence) + + #expect(socketFactory.makeCallsCount == 1) + } + + @Test("When socket is created, it doesn't create socket again") + func send_whenSocketIsCreated_doesNotCreateSocket() async throws { + let request = Request.sample() + + let sequence1 = pinger.ping(request: request) + try await observerPingSequenceWithoutReturningResult(sequence: sequence1) + + let sequence2 = pinger.ping(request: request) + try await observerPingSequenceWithoutReturningResult(sequence: sequence2) + + #expect(socketFactory.makeCallsCount == 1) + } + + @Test("When socket creation failed, it emits pingError.socketFailed") + func send_whenSocketIsNotCreatedAndCreationFailed_emitsSocketCreationError() async throws { + let request = Request.sample() + socketFactory.makeThrowableError = AnyError() + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: [.failure(.socketFailed)] + ) + } + + @Test("When packet creation failed, it emits pingError.internalError") + func send_whenPacketCreationFailed_emitsPacketCreationError() async throws { + let request = Request.sample() + socketFactory.makeReturnValue = PingxSocketMock() + icmpHeaderFactory.makeThrowableError = AnyError() + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: [.failure(.internalError(AsyncPingerError.unableToCreatePacket))] + ) + #expect(icmpHeaderFactory.makeReceivedInvocations == [request]) + } + + @Test("When setup is completed, sends a packet using the created socket") + func send_whenSetUpIsCompleted_sendsPacket() async throws { + let request = Request.sample() + let icmpHeader = ICMPHeader.sample() + icmpHeaderFactory.makeReturnValue = icmpHeader + + let sequence = pinger.ping(request: request) + try await observerPingSequenceWithoutReturningResult(sequence: sequence) + + #expect(socket.sendCallsCount == 1) + #expect(socket.sendReceivedArguments?.address == request.destination.socketAddress as CFData) + #expect(socket.sendReceivedArguments?.data == icmpHeader.data as CFData) + #expect(socket.sendReceivedArguments?.timeout == request.timeoutInterval.milliseconds) + #expect(icmpHeaderFactory.makeReceivedInvocations == [request]) + } + + @Test("When request failed, it emits pingError.internalError") + func send_whenPacketSendingFailed_emitsError() async throws { + let request = Request.sample() + socket.sendReturnValue = .error + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: [.failure(.internalError(AsyncPingerError.unknown))] + ) + } + + @Test("When packet sending timed out, it emits pingError.timeout") + func send_whenPacketSendingTimedOut_emitsTimedOutError() async throws { + let request = Request.sample() + socket.sendReturnValue = .timeout + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: [.failure(.timeout)] + ) + } + + @Test("When response parsing is successful, it emits icmp packet") + func send_whenResponseParsingSucceeded_emitsIcmpPacket() async throws { + let request = Request.sample() + let icmpPacket = ICMPPacket.sample( + icmpHeader: .sample( + identifier: request.identifier.id + ) + ) + icmpPacketExtractor.extractReturnValue = icmpPacket + + let sequence = pinger.ping(request: request) + let response = Response.sample( + destination: request.destination, + sequenceNumber: request.sequenceNumber + ) + + try await checkThat( + sequence: sequence, + emits: [.success(response)], + after: { + await expectToEventuallyBeCalled(actualCallsCount: socket.sendCallsCount) + socketFactory.makeReceivedCommand?.closure(Data()) + } + ) + } + + @Test("When response parsing is successful but identifier is different, it doesn't emit icmp packet") + func send_whenResponseParsingSucceededButIdentifierIsDifferent_doesNotEmitIcmpPacket() async throws { + let request = Request.sample(id: .sample(id: 1)) + let icmpPacket = ICMPPacket.sample( + icmpHeader: .sample(identifier: 2) + ) + icmpPacketExtractor.extractReturnValue = icmpPacket + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: [], + after: { + await expectToEventuallyBeCalled(actualCallsCount: socket.sendCallsCount) + socketFactory.makeReceivedCommand?.closure(Data()) + } + ) + } + + @Test("When response parsing is failed and icmp header is missing, it doesn't emit") + func send_whenResponseParsingFailedAndIcmpHeaderIsMissing_doesNotEmit() async throws { + let request = Request.sample() + let error = ICMPResponseValidationError.missedIcmpHeader + icmpPacketExtractor.extractThrowableError = error + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: [], + after: { + await expectToEventuallyBeCalled(actualCallsCount: socket.sendCallsCount) + socketFactory.makeReceivedCommand?.closure(Data()) + } + ) + } + + @Test("When response parsing is failed and icmp header is present, it emits validation error") + func send_whenResponseParsingFailedButIcmpHeaderIsPresent_emitsValidationError() async throws { + let request = Request.sample() + let icmpHeader = ICMPHeader.sample(identifier: request.identifier.id) + let error = ICMPResponseValidationError.invalidCode(icmpHeader) + icmpPacketExtractor.extractThrowableError = error + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: [.failure(.responseStructureInconsistent)], + after: { + await expectToEventuallyBeCalled(actualCallsCount: socket.sendCallsCount) + socketFactory.makeReceivedCommand?.closure(Data()) + } + ) + } + + @Test("When response parsing is failed with unknown response, it doesn't emit") + func send_whenResponseParsingFailedWithUnknownError_doesNotEmit() async throws { + let request = Request.sample() + icmpPacketExtractor.extractThrowableError = AnyError() + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: [], + after: { + await expectToEventuallyBeCalled(actualCallsCount: socket.sendCallsCount) + socketFactory.makeReceivedCommand?.closure(Data()) + } + ) + } + + @Test("When request timed out, it emits pingerError.timeout") + func send_whenRequestIsTimedOut_emitsTimedOutError() async throws { + let request = Request.sample(timeoutInterval: .milliseconds(1)) + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: [.failure(.timeout)] + ) + } + + @Test("When request demand is zero, doesn't emit values") + func send_whenRequestDemandIsZero_doesNotEmitValues() async throws { + let request = Request.sample(demand: .none) + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: [] + ) + } + + @Test( + "When request demand is greater than one, the corresponding number of values is emitted", + arguments: [1, 2, 3, 4, 5] + ) + func send_whenRequestDemandIsGreaterThanOne_emitsCorrespondingNumberOfValues(demand: UInt) async throws { + let request = Request.sample(demand: .max(demand)) + + let sequence = pinger.ping(request: request) + let responses = (0.. Void)? = nil, + timeout: Interval = .seconds(1), + sourceLocation: SourceLocation = #_sourceLocation + ) async throws { + let collectingValuesTask = Task { + return try await collectValuesFromPingSequence(sequence: sequence, timeout: timeout) + } + + await operation?() + + let values = try await collectingValuesTask.value + let areValuesEqualToExpectedValues = values.elementsEqual(expectedValues, by: { $0.equals($1) }) + + try #require( + areValuesEqualToExpectedValues, + "Expected: \(expectedValues), Got: \(values)", + sourceLocation: sourceLocation + ) + } +} diff --git a/Tests/pingxTests/Tests/Pinger/PingerTests.swift b/Tests/pingxTests/Tests/Pinger/PingerTests.swift new file mode 100644 index 0000000..be54336 --- /dev/null +++ b/Tests/pingxTests/Tests/Pinger/PingerTests.swift @@ -0,0 +1,150 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Testing +import XCTest + +@testable import pingx + +@Suite +struct PingerTests { + private let pingSequence: MockPingSequence + private let asyncPinger: AsyncPingerMock + private var pinger: Pinger! + + init() { + self.pingSequence = MockPingSequence() + + self.asyncPinger = AsyncPingerMock() + self.asyncPinger.pingReturnValue = AnyPingSequence( + sequence: pingSequence + ) + + self.pinger = Pinger( + asyncPinger: asyncPinger + ) + } + + @Test("When ping is called, starts pinging using the async pinger") + func ping_callsAsyncPinger() async { + pinger.ping() + + await expectToEventuallyBeCalled( + actualCallsCount: asyncPinger.pingCallsCount, + expectedCallsCount: 1 + ) + } + + @Test("When the sequence emits events, calls completion with received events") + func ping_whenSequenceEmitsEvent_callsCompletionWithReceivedEvent() async { + let completion = ThreadSafeMockFunc() + let pingResults: [PingResult] = [ + .success(.sample()), + .failure(.timeout), + .success(.sample()) + ] + + pinger.ping(completion: completion.call) + for (index, pingResult) in pingResults.enumerated() { + await pingSequence.expectNextToEventuallyBeCalled(count: index + 1) + await pingSequence.send(pingResult) + } + + await expectToEventuallyBeCalled( + actualCallsCount: completion.count, + expectedCallsCount: 3 + ) + #expect(completion.parameters.elementsEqual(pingResults, by: { $0.equals($1) })) + } + + @Test("When sequence completes, doesn't emit new events") + func ping_whenSequenceCompletes_doesNotEmitNewEvents() async { + let request = Request.sample() + + pinger.ping(request: request) + await pingSequence.finish() + + await pingSequence.expectNextNotToEventuallyBeCalled(count: 2) + } + + @Test("When request is cancelled, does not emit events after cancellation") + func cancel_doesNotEmitEvents() async { + let completion = ThreadSafeMockFunc() + let request = Request.sample() + + pinger.ping(request: request, completion: completion.call) + await pingSequence.expectNextToEventuallyBeCalled() + + pinger.cancel(requestId: request.identifier) + await pingSequence.send(.success(.sample())) + + await expectNotToEventuallyBeCalled(actualCallsCount: completion.count) + } + + @Test("When pinger is deinitialized, cancels all active requests") + mutating func deinit_cancelsAllActiveRequests() async { + let completion = ThreadSafeMockFunc() + let request = Request.sample(demand: .unlimited) + + pinger.ping(request: request) + await pingSequence.expectNextToEventuallyBeCalled() + + pinger = nil + await pingSequence.send(.success(.sample())) + + await expectNotToEventuallyBeCalled(actualCallsCount: completion.count) + } +} + +private extension Pinger { + func ping(request: Request = .sample()) { + ping( + request: request, + completion: { _ in } + ) + } + + func ping(completion: @escaping (PingResult) -> Void) { + ping( + request: .sample(), + completion: completion + ) + } +} + +private extension MockPingSequence { + func expectNextToEventuallyBeCalled(count: Int = 1) async { + await expectToEventuallyBeCalled( + actualCallsCount: self.nextCallsCount, + expectedCallsCount: count + ) + } + + func expectNextNotToEventuallyBeCalled(count: Int = 1) async { + await expectNotToEventuallyBeCalled( + actualCallsCount: self.nextCallsCount, + expectedCallsCount: count + ) + } +} diff --git a/Tests/pingxTests/Tests/Request/DemandTests.swift b/Tests/pingxTests/Tests/Request/DemandTests.swift new file mode 100644 index 0000000..ceca360 --- /dev/null +++ b/Tests/pingxTests/Tests/Request/DemandTests.swift @@ -0,0 +1,78 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Testing + +@testable import pingx + +@Suite +struct DemandTests { + typealias Demand = Request.Demand + + @Test( + "Tests initialization of demand", + arguments: [ + (demand: Demand.none, expectedValue: UInt(0)), + (demand: Demand.unlimited, expectedValue: nil), + (demand: Demand.max(2), expectedValue: UInt(2)) + ] + ) + func demand_initialization(demand: Demand, expectedValue: UInt?) { + #expect(demand.max == expectedValue) + } + + @Test( + "Tests demand substraction", + arguments: [ + (lValue: Demand.unlimited, rValue: Demand.unlimited, result: Demand.unlimited), + (lValue: Demand.unlimited, rValue: Demand.max(3), result: Demand.unlimited), + (lValue: Demand.max(3), rValue: Demand.unlimited, result: Demand.none), + (lValue: Demand.max(3), rValue: Demand.max(3), result: Demand.none), + (lValue: Demand.max(3), rValue: Demand.max(2), result: Demand.max(1)), + (lValue: Demand.max(3), rValue: Demand.max(5), result: Demand.none), + (lValue: Demand.none, rValue: Demand.none, result: Demand.none), + (lValue: Demand.none, rValue: Demand.max(3), result: Demand.none), + (lValue: Demand.max(2), rValue: Demand.none, result: Demand.max(2)) + ] + ) + func demand_substraction(lValue: Demand, rValue: Demand, result: Demand) { + #expect((lValue - rValue) == result) + } + + @Test( + "Tests demand addition", + arguments: [ + (lValue: Demand.unlimited, rValue: Demand.unlimited, result: Demand.unlimited), + (lValue: Demand.unlimited, rValue: Demand.max(3), result: Demand.unlimited), + (lValue: Demand.max(3), rValue: Demand.unlimited, result: Demand.unlimited), + (lValue: Demand.max(3), rValue: Demand.max(3), result: Demand.max(6)), + (lValue: Demand.none, rValue: Demand.none, result: Demand.none), + (lValue: Demand.none, rValue: Demand.max(3), result: Demand.max(3)), + (lValue: Demand.max(.max - 100), rValue: Demand.max(.max - 50), result: Demand.max(.max)) + ] + ) + func demand_addition(lValue: Demand, rValue: Demand, result: Demand) { + #expect((lValue + rValue) == result) + } +} diff --git a/Tests/pingxTests/Tests/Request/RequestTests.swift b/Tests/pingxTests/Tests/Request/RequestTests.swift new file mode 100644 index 0000000..a97fc1b --- /dev/null +++ b/Tests/pingxTests/Tests/Request/RequestTests.swift @@ -0,0 +1,70 @@ +// +// The MIT License (MIT) +// +// Copyright © 2025 Ilya Baryka. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Testing + +@testable import pingx + +@Suite +struct RequestTests { + @Test( + "When demand decreased, it sets correct new demand", + arguments: [ + (initialDemand: Request.Demand.unlimited, expectedDemand: Request.Demand.unlimited), + (initialDemand: Request.Demand.max(5), expectedDemand: Request.Demand.max(4)), + (initialDemand: Request.Demand.max(1), expectedDemand: Request.Demand.none), + (initialDemand: Request.Demand.none, expectedDemand: Request.Demand.none) + ] + ) + func demand_whenDemandDecreased_setsCorrectNewDemand( + initialDemand: Request.Demand, + expectedDemand: Request.Demand + ) { + var request = Request.sample(demand: initialDemand) + + request.decreaseDemand() + + #expect(request.demand == expectedDemand) + } + + @Test( + "When sequence number increased, it sets correct sequence number", + arguments: [ + (initialSequenceNumber: UInt16.zero, expectedSequenceNumber: UInt16(1)), + (initialSequenceNumber: UInt16(100), expectedSequenceNumber: UInt16(101)), + (initialSequenceNumber: UInt16.max - 1, expectedSequenceNumber: UInt16.max), + (initialSequenceNumber: UInt16.max, expectedSequenceNumber: .zero) + ] + ) + func demand_whenSequenceNumberIncreased_setsCorrectNewSequenceNumber( + initialSequenceNumber: UInt16, + expectedSequenceNumber: UInt16 + ) { + var request = Request.sample(sequenceNumber: initialSequenceNumber) + + request.incrementSequenceNumber() + + #expect(request.sequenceNumber == expectedSequenceNumber) + } +} diff --git a/pingx.podspec b/pingx.podspec index 6f8a3a8..12f2286 100644 --- a/pingx.podspec +++ b/pingx.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'pingx' - s.version = '1.0.9' + s.version = '1.1.0' s.summary = 'pingx: iOS library for ping estimation using ICMP packets.' s.description = 'This ultralight and easy-to-use library is designed to help developers accurately measure and analyze network ping latency in their applications using ICMP packets.' s.homepage = 'https://github.com/shineRR/pingx'