From 20dea6be2c1cd5271e14aab737146865393e62b0 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 18 May 2025 15:19:37 +0200 Subject: [PATCH 01/25] [IMP-3] Create AsyncPinger --- Example/Podfile.lock | 4 +- Example/pingx.xcodeproj/project.pbxproj | 8 +- Example/pingx/AppDelegate.swift | 2 +- Example/pingx/Presenter.swift | 5 - Example/pingx/PresenterImpl.swift | 53 +-- LICENSE | 2 +- .../Extensions/DispatchQueue+Extensions.swift | 46 --- .../pingx/Extensions/String+Extensions.swift | 2 +- .../ICMPHeaderFactory.swift} | 22 +- .../PacketFactory/Api/PacketFactory.swift | 28 -- .../SocketFactory/Api/SocketFactory.swift | 30 -- ...tFactoryImpl.swift => SocketFactory.swift} | 15 +- .../TimerFactory/Api/TimerFactory.swift | 34 -- .../Model/CommandBlock/CommandBlock.swift | 4 +- Sources/pingx/Model/IP/IPv4/IPv4Address.swift | 1 - Sources/pingx/Model/IPHeader/IPHeader.swift | 37 +- .../pingx/Model/Packets/ICMP/ICMPHeader.swift | 13 +- .../pingx/Model/Packets/ICMP/ICMPPacket.swift | 2 +- .../pingx/Model/Packets/ICMP/ICMPType.swift | 2 +- Sources/pingx/Model/Payload/Payload.swift | 31 +- Sources/pingx/Model/Request/Request.swift | 61 ++-- ...ingxSocketImpl.swift => PingxSocket.swift} | 18 +- .../Timer/Impl/PingxDispatchSourceTimer.swift | 58 --- .../pingx/PacketSender/Api/PacketSender.swift | 36 -- .../Error/PacketSenderError.swift | 32 -- .../PacketSender/Impl/PacketSenderImpl.swift | 113 ------ .../Pinger/Delegate/PingerDelegate.swift | 32 -- .../Error/ICMPResponseValidationError.swift | 2 +- Sources/pingx/Pinger/Error/PingerError.swift | 13 +- .../ICMPPackageExtractor.swift} | 36 +- .../pingx/Pinger/Impl/ContinuousPinger.swift | 202 ----------- .../Model/PingerResult.swift} | 6 +- .../pingx/Pinger/Pingers/AsyncPinger.swift | 162 +++++++++ .../pingx/Pinger/Pingers/Pinger.swift | 45 ++- .../pingx/Pinger/Sequence/PingSequence.swift | 76 ++++ Tests/Templates/AutoMockable.stencil | 10 +- .../Converter/IPv4AddressConverterTests.swift | 6 +- .../Extenstions/Assertion+Extensions.swift | 36 +- .../Extenstions/PingSequence+Assertions.swift | 73 ++++ .../ICMPPackageExtractorTests.swift | 164 +++++++++ ...HeaderFactory+AutoMockable.generated.swift | 39 ++ ...cketExtractor+AutoMockable.generated.swift | 39 ++ ...PacketFactory+AutoMockable.generated.swift | 39 -- .../PacketSender+AutoMockable.generated.swift | 44 --- .../PingxSocket+AutoMockable.generated.swift | 15 +- .../PingxTimer+AutoMockable.generated.swift | 52 --- ...SocketFactory+AutoMockable.generated.swift | 38 +- .../TimerFactory+AutoMockable.generated.swift | 35 -- .../PacketSenderDelegateMock.swift | 46 --- .../PingerDelegate/PingerDelegateMock.swift | 46 --- .../PacketSender/PacketSenderTests.swift | 152 -------- .../pingxTests/Pinger/AsyncPingerTests.swift | 329 +++++++++++++++++ Tests/pingxTests/Pinger/PingerTests.swift | 340 ------------------ .../pingxTests/Samples/Error+Sample.swift | 4 +- .../Samples/ICMPPacket+Sample.swift | 15 +- .../pingxTests/Samples/IPHeader+Sample.swift | 53 +++ .../IPv4Address+Sample.swift} | 11 +- .../pingxTests/Samples/Payload+Sample.swift | 22 +- .../pingxTests/Samples/Request+Sample.swift | 24 +- pingx.podspec | 2 +- 60 files changed, 1244 insertions(+), 1623 deletions(-) delete mode 100644 Example/pingx/Presenter.swift delete mode 100644 Sources/pingx/Extensions/DispatchQueue+Extensions.swift rename Sources/pingx/Factory/{PacketFactory/Impl/PacketFactoryImpl.swift => ICMPHeaderFactory/ICMPHeaderFactory.swift} (77%) delete mode 100644 Sources/pingx/Factory/PacketFactory/Api/PacketFactory.swift delete mode 100644 Sources/pingx/Factory/SocketFactory/Api/SocketFactory.swift rename Sources/pingx/Factory/SocketFactory/{Impl/SocketFactoryImpl.swift => SocketFactory.swift} (87%) delete mode 100644 Sources/pingx/Factory/TimerFactory/Api/TimerFactory.swift rename Sources/pingx/Model/Socket/{Impl/PingxSocketImpl.swift => PingxSocket.swift} (85%) delete mode 100644 Sources/pingx/Model/Timer/Impl/PingxDispatchSourceTimer.swift delete mode 100644 Sources/pingx/PacketSender/Api/PacketSender.swift delete mode 100644 Sources/pingx/PacketSender/Error/PacketSenderError.swift delete mode 100644 Sources/pingx/PacketSender/Impl/PacketSenderImpl.swift delete mode 100644 Sources/pingx/Pinger/Delegate/PingerDelegate.swift rename Sources/pingx/Pinger/{Api/Pinger.swift => Helpers/ICMPPackageExtractor.swift} (82%) delete mode 100644 Sources/pingx/Pinger/Impl/ContinuousPinger.swift rename Sources/pingx/{Model/Timer/Api/PingxTimer.swift => Pinger/Model/PingerResult.swift} (93%) create mode 100644 Sources/pingx/Pinger/Pingers/AsyncPinger.swift rename Tests/pingxTests/Extenstions/ICMPResponseValidationError+Equatable.swift => Sources/pingx/Pinger/Pingers/Pinger.swift (58%) create mode 100644 Sources/pingx/Pinger/Sequence/PingSequence.swift rename Sources/pingx/Model/Socket/Api/PingxSocket.swift => Tests/pingxTests/Extenstions/Assertion+Extensions.swift (57%) create mode 100644 Tests/pingxTests/Extenstions/PingSequence+Assertions.swift create mode 100644 Tests/pingxTests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift create mode 100644 Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift create mode 100644 Tests/pingxTests/Mocks/Generated/ICMPPacketExtractor+AutoMockable.generated.swift delete mode 100644 Tests/pingxTests/Mocks/Generated/PacketFactory+AutoMockable.generated.swift delete mode 100644 Tests/pingxTests/Mocks/Generated/PacketSender+AutoMockable.generated.swift delete mode 100644 Tests/pingxTests/Mocks/Generated/PingxTimer+AutoMockable.generated.swift delete mode 100644 Tests/pingxTests/Mocks/Generated/TimerFactory+AutoMockable.generated.swift delete mode 100644 Tests/pingxTests/Mocks/PacketSenderDelegate/PacketSenderDelegateMock.swift delete mode 100644 Tests/pingxTests/Mocks/PingerDelegate/PingerDelegateMock.swift delete mode 100644 Tests/pingxTests/PacketSender/PacketSenderTests.swift create mode 100644 Tests/pingxTests/Pinger/AsyncPingerTests.swift delete mode 100644 Tests/pingxTests/Pinger/PingerTests.swift rename Sources/pingx/Model/PacketType/PacketType.swift => Tests/pingxTests/Samples/Error+Sample.swift (96%) rename Sources/pingx/PacketSender/Delegate/PacketSenderDelegate.swift => Tests/pingxTests/Samples/ICMPPacket+Sample.swift (80%) create mode 100644 Tests/pingxTests/Samples/IPHeader+Sample.swift rename Tests/pingxTests/{Mocks/Packet/PacketMock.swift => Samples/IPv4Address+Sample.swift} (85%) rename Sources/pingx/Pinger/Configuration/PingerConfiguration.swift => Tests/pingxTests/Samples/Payload+Sample.swift (80%) rename Sources/pingx/Factory/TimerFactory/Impl/TimerFactoryImpl.swift => Tests/pingxTests/Samples/Request+Sample.swift (75%) diff --git a/Example/Podfile.lock b/Example/Podfile.lock index 5a6e190..a7d1a97 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,7 +9,7 @@ EXTERNAL SOURCES: :path: "../" SPEC CHECKSUMS: - pingx: 2f1582e813a34ebcc3915fdaefb09e3dc93f3473 + pingx: dbb0887216ff095cfb8ffad2f7f10f07f71c4053 PODFILE CHECKSUM: 4c5247958d334b884adb4a4531fca1e4770e66c2 diff --git a/Example/pingx.xcodeproj/project.pbxproj b/Example/pingx.xcodeproj/project.pbxproj index 6ccbc4a..d129c53 100644 --- a/Example/pingx.xcodeproj/project.pbxproj +++ b/Example/pingx.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 146872512D23313B00373862 /* Presenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1468724D2D23313B00373862 /* Presenter.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 */; }; @@ -24,7 +23,6 @@ 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 = ""; }; @@ -72,7 +70,6 @@ 146872492D23313B00373862 /* Info.plist */, 1468724B2D23313B00373862 /* LaunchScreen.xib */, 1468724C2D23313B00373862 /* PingxView.swift */, - 1468724D2D23313B00373862 /* Presenter.swift */, 1468724E2D23313B00373862 /* PresenterImpl.swift */, 1468724F2D23313B00373862 /* ViewController.swift */, ); @@ -214,7 +211,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 146872512D23313B00373862 /* Presenter.swift in Sources */, 146872522D23313B00373862 /* AppDelegate.swift in Sources */, 146872532D23313B00373862 /* PresenterImpl.swift in Sources */, 146872542D23313B00373862 /* PingxView.swift in Sources */, @@ -256,7 +252,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 = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -292,7 +288,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 = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Example/pingx/AppDelegate.swift b/Example/pingx/AppDelegate.swift index e33f8d0..8592902 100644 --- a/Example/pingx/AppDelegate.swift +++ b/Example/pingx/AppDelegate.swift @@ -11,7 +11,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - let presenter = PresenterImpl() + let presenter = Presenter() let viewController = ViewController(presenter: presenter) window = UIWindow(frame: UIScreen.main.bounds) 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 index 14a4ce3..e13ec35 100644 --- a/Example/pingx/PresenterImpl.swift +++ b/Example/pingx/PresenterImpl.swift @@ -1,8 +1,14 @@ import pingx -// MARK: - PresenterImpl +// MARK: - Presenter + +final class Presenter { + + // MARK: Constants -final class PresenterImpl { + enum Constants { + static var destinationAddress: IPv4Address { IPv4Address(address: (8, 8, 8, 8)) } + } // MARK: Properties @@ -10,45 +16,14 @@ final class PresenterImpl { // MARK: Initializer - init(pinger: Pinger = ContinuousPinger()) { + init(pinger: Pinger = Pinger()) { 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)) } + func didTapSendButton() { + let request = Request(destination: Constants.destinationAddress, demand: .max(1)) + pinger.ping(request: request) { result in + print(result) + } } } 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/Sources/pingx/Extensions/DispatchQueue+Extensions.swift b/Sources/pingx/Extensions/DispatchQueue+Extensions.swift deleted file mode 100644 index 20d1218..0000000 --- a/Sources/pingx/Extensions/DispatchQueue+Extensions.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 - -func performAfter( - deadline: DispatchTime, - _ function: @escaping (T) -> Void, - value: T, - on queue: DispatchQueue -) { - queue.asyncAfter(deadline: deadline) { - function(value) - } -} - -func perform(_ function: @escaping (T) -> Void, value: T, on queue: DispatchQueue) { - queue.async { - function(value) - } -} - -func perform(_ function: @escaping () -> Void, on queue: DispatchQueue) { - queue.async(execute: function) -} diff --git a/Sources/pingx/Extensions/String+Extensions.swift b/Sources/pingx/Extensions/String+Extensions.swift index a5235a3..66cc59f 100644 --- a/Sources/pingx/Extensions/String+Extensions.swift +++ b/Sources/pingx/Extensions/String+Extensions.swift @@ -26,7 +26,7 @@ import Foundation // MARK: - String+Extensions -public extension String { +extension String { var socketAddress: Data { var socketAddress = sockaddr_in() diff --git a/Sources/pingx/Factory/PacketFactory/Impl/PacketFactoryImpl.swift b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift similarity index 77% rename from Sources/pingx/Factory/PacketFactory/Impl/PacketFactoryImpl.swift rename to Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift index a852999..433355c 100644 --- a/Sources/pingx/Factory/PacketFactory/Impl/PacketFactoryImpl.swift +++ b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift @@ -24,25 +24,15 @@ 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(type: ICMPType, identifier: UInt16) throws -> ICMPHeader } -// MARK: - Private API - -private extension PacketFactoryImpl { - func icmpPacket(identifier: UInt16) throws -> Packet { +struct ICMPHeaderFactory: ICMPHeaderFactoryProtocol { + func make(type: ICMPType, identifier: UInt16) throws -> ICMPHeader { var icmpHeader = ICMPHeader( - type: .echoRequest, + type: type, identifier: identifier, sequenceNumber: CFSwapInt16HostToBig(UInt16.random(in: 0.. Packet -} diff --git a/Sources/pingx/Factory/SocketFactory/Api/SocketFactory.swift b/Sources/pingx/Factory/SocketFactory/Api/SocketFactory.swift deleted file mode 100644 index b57cc9e..0000000 --- a/Sources/pingx/Factory/SocketFactory/Api/SocketFactory.swift +++ /dev/null @@ -1,30 +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 - -// sourcery: AutoMockable -protocol SocketFactory { - func create(command: CommandBlock) throws -> any PingxSocket -} diff --git a/Sources/pingx/Factory/SocketFactory/Impl/SocketFactoryImpl.swift b/Sources/pingx/Factory/SocketFactory/SocketFactory.swift similarity index 87% rename from Sources/pingx/Factory/SocketFactory/Impl/SocketFactoryImpl.swift rename to Sources/pingx/Factory/SocketFactory/SocketFactory.swift index 8075218..d0e94b7 100644 --- a/Sources/pingx/Factory/SocketFactory/Impl/SocketFactoryImpl.swift +++ b/Sources/pingx/Factory/SocketFactory/SocketFactory.swift @@ -24,10 +24,15 @@ import Foundation -final class SocketFactoryImpl: SocketFactory { +// sourcery: AutoMockable +protocol SocketFactoryProtocol { + func make(command: CommandBlock) throws -> any PingxSocket +} + +final class SocketFactory: SocketFactoryProtocol { typealias SocketCommand = CommandBlock - func create(command: SocketCommand) throws -> any PingxSocket { + func make(command: SocketCommand) throws -> any PingxSocket { let unmanaged = Unmanaged.passRetained(command) var context = CFSocketContext( version: .zero, @@ -55,7 +60,7 @@ final class SocketFactoryImpl: SocketFactory { &context ) - guard let socket = socket else { throw PacketSenderError.socketCreationError } + guard let socket = socket else { throw PingerError.socketCreationError } let native = CFSocketGetNative(socket) var value: Int32 = 1 @@ -66,14 +71,14 @@ final class SocketFactoryImpl: SocketFactory { &value, socklen_t(MemoryLayout.size(ofValue: value)) ) == .zero else { - throw PacketSenderError.socketCreationError + throw PingerError.socketCreationError } guard let socketSource = CFSocketCreateRunLoopSource( kCFAllocatorDefault, socket, .zero - ) else { throw PacketSenderError.socketCreationError } + ) else { throw PingerError.socketCreationError } CFRunLoopAddSource( CFRunLoopGetMain(), diff --git a/Sources/pingx/Factory/TimerFactory/Api/TimerFactory.swift b/Sources/pingx/Factory/TimerFactory/Api/TimerFactory.swift deleted file mode 100644 index d78cae0..0000000 --- a/Sources/pingx/Factory/TimerFactory/Api/TimerFactory.swift +++ /dev/null @@ -1,34 +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 - -// sourcery: AutoMockable -protocol TimerFactory { - func createDispatchSourceTimer( - timeInterval: TimeInterval, - eventQueue: DispatchQueue, - eventHandler: @escaping () -> Void - ) -> PingxTimer -} diff --git a/Sources/pingx/Model/CommandBlock/CommandBlock.swift b/Sources/pingx/Model/CommandBlock/CommandBlock.swift index dd22f40..9866b6a 100644 --- a/Sources/pingx/Model/CommandBlock/CommandBlock.swift +++ b/Sources/pingx/Model/CommandBlock/CommandBlock.swift @@ -22,11 +22,11 @@ // SOFTWARE. // -public final class CommandBlock { +final class CommandBlock { // MARK: Properties - public let closure: (T) -> Void + let closure: (T) -> Void // MARK: Initializer diff --git a/Sources/pingx/Model/IP/IPv4/IPv4Address.swift b/Sources/pingx/Model/IP/IPv4/IPv4Address.swift index b1b879f..548797f 100644 --- a/Sources/pingx/Model/IP/IPv4/IPv4Address.swift +++ b/Sources/pingx/Model/IP/IPv4/IPv4Address.swift @@ -57,7 +57,6 @@ extension IPv4Address: Hashable { hasher.combine(address.1) hasher.combine(address.2) hasher.combine(address.3) - _ = hasher.finalize() } } diff --git a/Sources/pingx/Model/IPHeader/IPHeader.swift b/Sources/pingx/Model/IPHeader/IPHeader.swift index 1e9a73a..c4e56db 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 @@ -62,4 +62,19 @@ public struct IPHeader { self.sourceAddress = sourceAddress self.destinationAddress = destinationAddress } + + // MARK: Static + + static func == (lhs: IPHeader, rhs: IPHeader) -> Bool { + lhs.versionAndHeaderLength == rhs.versionAndHeaderLength && + lhs.serviceType == rhs.serviceType && + lhs.totalLength == rhs.totalLength && + lhs.identifier == rhs.identifier && + lhs.flagsAndFragmentOffset == rhs.flagsAndFragmentOffset && + lhs.timeToLive == rhs.timeToLive && + lhs.`protocol` == rhs.`protocol` && + lhs.headerChecksum == rhs.headerChecksum && + lhs.sourceAddress == rhs.sourceAddress && + lhs.destinationAddress == rhs.destinationAddress + } } diff --git a/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift b/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift index 37bffd6..3f2d172 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 @@ -53,6 +53,17 @@ struct ICMPHeader { self.payload = payload } + // MARK: Static + + static func == (lhs: ICMPHeader, rhs: ICMPHeader) -> Bool { + lhs.type == rhs.type && + lhs.code == rhs.code && + lhs.checksum == rhs.checksum && + lhs.identifier == rhs.identifier && + lhs.sequenceNumber == rhs.sequenceNumber && + lhs.payload == rhs.payload + } + // MARK: Methods mutating func setChecksum(_ checksum: UInt16) { diff --git a/Sources/pingx/Model/Packets/ICMP/ICMPPacket.swift b/Sources/pingx/Model/Packets/ICMP/ICMPPacket.swift index f0e2d7b..a6f9518 100644 --- a/Sources/pingx/Model/Packets/ICMP/ICMPPacket.swift +++ b/Sources/pingx/Model/Packets/ICMP/ICMPPacket.swift @@ -22,7 +22,7 @@ // SOFTWARE. // -struct ICMPPacket { +struct ICMPPacket: Equatable { // MARK: Properties diff --git a/Sources/pingx/Model/Packets/ICMP/ICMPType.swift b/Sources/pingx/Model/Packets/ICMP/ICMPType.swift index b6ef32a..cb74ace 100644 --- a/Sources/pingx/Model/Packets/ICMP/ICMPType.swift +++ b/Sources/pingx/Model/Packets/ICMP/ICMPType.swift @@ -22,7 +22,7 @@ // SOFTWARE. // -enum ICMPType: UInt8 { +enum ICMPType: UInt8, Hashable { /// Echo reply (used to ping) case echoReply = 0 diff --git a/Sources/pingx/Model/Payload/Payload.swift b/Sources/pingx/Model/Payload/Payload.swift index e917f38..18c33f3 100644 --- a/Sources/pingx/Model/Payload/Payload.swift +++ b/Sources/pingx/Model/Payload/Payload.swift @@ -24,25 +24,48 @@ import Foundation -struct Payload { +struct Payload: Equatable { // MARK: Typealias typealias PayloadID = (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) - + // MARK: Properties - // "pingx" let identifier: PayloadID let timestamp: CFAbsoluteTime // MARK: Initializer init( - identifier: PayloadID = (112, 105, 110, 103, 120, 0, 0, 0), + timestamp: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() + ) { + self.identifier = Payload.pingxID + self.timestamp = timestamp + } + + init( + identifier: PayloadID, timestamp: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() ) { self.identifier = identifier self.timestamp = timestamp } + + // MARK: Static + + // "pingx" + static let pingxID: PayloadID = (112, 105, 110, 103, 120, 0, 0, 0) + + static func == (lhs: Payload, rhs: Payload) -> Bool { + lhs.identifier.0 == rhs.identifier.0 && + lhs.identifier.1 == rhs.identifier.1 && + lhs.identifier.2 == rhs.identifier.2 && + lhs.identifier.3 == rhs.identifier.3 && + lhs.identifier.4 == rhs.identifier.4 && + lhs.identifier.5 == rhs.identifier.5 && + lhs.identifier.6 == rhs.identifier.6 && + lhs.identifier.7 == rhs.identifier.7 && + lhs.timestamp == rhs.timestamp + } } diff --git a/Sources/pingx/Model/Request/Request.swift b/Sources/pingx/Model/Request/Request.swift index 97e7f86..a19112d 100644 --- a/Sources/pingx/Model/Request/Request.swift +++ b/Sources/pingx/Model/Request/Request.swift @@ -25,66 +25,71 @@ import Foundation public final class Request: Identifiable, Equatable { - + // 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 } - - func setTimeRemainingUntilDeadline(_ timeRemainingUntilDeadline: TimeInterval) { - self.timeRemainingUntilDeadline = timeRemainingUntilDeadline - } - - func setDemand(_ demand: Request.Demand) { - self.demand = demand + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(type) + hasher.combine(destination) + hasher.combine(timeoutInterval) } - - func decreaseDemandAndUpdateTimeRemainingUntilDeadline() { - setDemand(demand - .max(1)) - setTimeRemainingUntilDeadline(timeoutInterval) + + func decreaseDemand() { + demand = demand - .max(1) } } // MARK: - Demand public extension Request { - struct Demand: Equatable, Hashable { + struct Demand: Equatable { // MARK: Properties diff --git a/Sources/pingx/Model/Socket/Impl/PingxSocketImpl.swift b/Sources/pingx/Model/Socket/PingxSocket.swift similarity index 85% rename from Sources/pingx/Model/Socket/Impl/PingxSocketImpl.swift rename to Sources/pingx/Model/Socket/PingxSocket.swift index b794058..9ad5a85 100644 --- a/Sources/pingx/Model/Socket/Impl/PingxSocketImpl.swift +++ b/Sources/pingx/Model/Socket/PingxSocket.swift @@ -24,6 +24,18 @@ import Foundation +// sourcery: AutoMockable +protocol PingxSocket { + + // MARK: Typealias + + associatedtype Instance: AnyObject = CommandBlock + + // MARK: Methods + + func send(address: CFData, data: CFData, timeout: CFTimeInterval) -> CFSocketError +} + final class PingxSocketImpl: PingxSocket { // MARK: Typealias @@ -48,6 +60,10 @@ final class PingxSocketImpl: PingxSocket { self.unmanaged = unmanaged } + deinit { + invalidate() + } + // MARK: Methods func send(address: CFData, data: CFData, timeout: CFTimeInterval) -> CFSocketError { @@ -59,7 +75,7 @@ final class PingxSocketImpl: PingxSocket { ) } - func invalidate() { + private func invalidate() { CFRunLoopSourceInvalidate(socketSource) CFSocketInvalidate(socket) unmanaged.release() diff --git a/Sources/pingx/Model/Timer/Impl/PingxDispatchSourceTimer.swift b/Sources/pingx/Model/Timer/Impl/PingxDispatchSourceTimer.swift deleted file mode 100644 index c74b2fd..0000000 --- a/Sources/pingx/Model/Timer/Impl/PingxDispatchSourceTimer.swift +++ /dev/null @@ -1,58 +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 PingxDispatchSourceTimer: PingxTimer { - private var dispatchTimer: DispatchSourceTimer? - private let timeinterval: TimeInterval - private let eventQueue: DispatchQueue - private let eventHandler: () -> Void - - init( - timeInterval: TimeInterval, - eventQueue: DispatchQueue, - eventHandler: @escaping () -> Void - ) { - self.timeinterval = timeInterval - self.eventQueue = eventQueue - self.eventHandler = eventHandler - } - - 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() - } - - func stop() { - dispatchTimer?.cancel() - dispatchTimer = nil - } -} 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/Error/PacketSenderError.swift b/Sources/pingx/PacketSender/Error/PacketSenderError.swift deleted file mode 100644 index 6341306..0000000 --- a/Sources/pingx/PacketSender/Error/PacketSenderError.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. -// - -import Foundation - -enum PacketSenderError: Error { - case socketCreationError - case unableToCreatePacket - case error - case timeout -} 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/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/Pinger/Error/ICMPResponseValidationError.swift b/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift index e6e161f..cc68104 100644 --- a/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift +++ b/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift @@ -22,7 +22,7 @@ // SOFTWARE. // -enum ICMPResponseValidationError: Error { +enum ICMPResponseValidationError: Error, Equatable { var icmpHeader: ICMPHeader? { switch self { case .checksumMismatch(let icmpHeader), diff --git a/Sources/pingx/Pinger/Error/PingerError.swift b/Sources/pingx/Pinger/Error/PingerError.swift index 7364055..61b065d 100644 --- a/Sources/pingx/Pinger/Error/PingerError.swift +++ b/Sources/pingx/Pinger/Error/PingerError.swift @@ -24,12 +24,11 @@ import Foundation -public enum PingerError: CustomNSError { - public static let errorDomain: String = "com.pingx.PingerError" - - case pingInProgress - case socketFailed - case invalidDemand - case invalidResponse +enum PingerError: Error, Equatable { + case cancelled + case socketCreationError case timeout + case validationError(ICMPResponseValidationError) + case unableToCreatePacket + case unknown } diff --git a/Sources/pingx/Pinger/Api/Pinger.swift b/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift similarity index 82% rename from Sources/pingx/Pinger/Api/Pinger.swift rename to Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift index 82fd594..6e1a302 100644 --- a/Sources/pingx/Pinger/Api/Pinger.swift +++ b/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift @@ -24,29 +24,13 @@ 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 @@ -63,11 +47,11 @@ extension Pinger { return icmpPackage } - - private func validateICMPPackage(_ icmpPackage: ICMPPacket) throws { - let identifier = Payload().identifier - - guard compareIdentifier(lhs: icmpPackage.icmpHeader.payload.identifier, rhs: identifier) else { +} + +private extension ICMPPacketExtractor { + private func validateICMPPackage(_ icmpPackage: ICMPPacket) throws(ICMPResponseValidationError) { + guard compareIdentifier(lhs: icmpPackage.icmpHeader.payload.identifier, rhs: Payload.pingxID) else { throw ICMPResponseValidationError.invalidPayload(icmpPackage.icmpHeader) } 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/Timer/Api/PingxTimer.swift b/Sources/pingx/Pinger/Model/PingerResult.swift similarity index 93% rename from Sources/pingx/Model/Timer/Api/PingxTimer.swift rename to Sources/pingx/Pinger/Model/PingerResult.swift index 9491ab4..13e15c2 100644 --- a/Sources/pingx/Model/Timer/Api/PingxTimer.swift +++ b/Sources/pingx/Pinger/Model/PingerResult.swift @@ -22,8 +22,4 @@ // SOFTWARE. // -// sourcery: AutoMockable -protocol PingxTimer { - func start() - func stop() -} +typealias PingerResult = Result diff --git a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift new file mode 100644 index 0000000..0ce7c6a --- /dev/null +++ b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift @@ -0,0 +1,162 @@ +// +// 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 + +protocol AsyncPingerProtocol: AnyObject { + func ping(request: Request) -> PingSequence + func cancel(request: Request) +} + +final class AsyncPinger: AsyncPingerProtocol { + + // MARK: Typealias + + private typealias Instance = SocketFactory.SocketCommand + + // MARK: Properties + + @Atomic private var completions = [UInt16: (PingerResult) -> Void]() + private let icmpHeaderFactory: ICMPHeaderFactoryProtocol + private let icmpPacketExtractor: ICMPPacketExtractorProtocol + private let socketFactory: SocketFactoryProtocol + private var pingxSocket: (any PingxSocket)! + + // MARK: Initializer + + init( + icmpHeaderFactory: ICMPHeaderFactoryProtocol, + icmpPacketExtractor: ICMPPacketExtractorProtocol, + socketFactory: SocketFactoryProtocol + ) { + self.icmpHeaderFactory = icmpHeaderFactory + self.icmpPacketExtractor = icmpPacketExtractor + self.socketFactory = socketFactory + } + + convenience init() { + self.init( + icmpHeaderFactory: ICMPHeaderFactory(), + icmpPacketExtractor: ICMPPacketExtractor(), + socketFactory: SocketFactory() + ) + } + + func ping(request: Request) -> PingSequence { + PingSequence(request: request, pinger: self) + } + + func ping( + _ request: Request, + completion: @escaping (PingerResult) -> Void + ) { + completions[request.id] = completion + + do { + try checkSocketCreation() + } catch { + invokeCompletion(identifier: request.id, result: .failure(.socketCreationError)) + return + } + + guard let packet = try? icmpHeaderFactory.make(type: request.type, identifier: request.id) else { + invokeCompletion(identifier: request.id, result: .failure(.unableToCreatePacket)) + return + } + + let cfSocketError = pingxSocket.send( + address: request.destination.socketAddress as CFData, + data: packet.data as CFData, + timeout: request.timeoutInterval + ) + + if let error = cfSocketError.mapToPingerError() { + invokeCompletion(identifier: request.id, result: .failure(error)) + } + } + + func cancel(request: Request) { + invokeCompletion(identifier: request.id, result: .failure(.cancelled)) + } +} + +// 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: PingerResult = { [icmpPacketExtractor] in + do { + let icmpPacket = try icmpPacketExtractor.extract(from: data) + return .success(icmpPacket) + } catch let error as ICMPResponseValidationError { + return .failure(.validationError(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.ID, result: PingerResult) { + let completion = completions.removeValue(forKey: identifier) + completion?(result) + } +} + +private extension CFSocketError { + func mapToPingerError() -> PingerError? { + switch self { + case .error: + return .unknown + case .timeout: + return .timeout + default: + return nil + } + } +} + +private extension Result { + var identifier: Request.ID? { + switch self { + case .success(let icmpPacket): + return icmpPacket.icmpHeader.identifier + case .failure(.validationError(let validationError)): + return validationError.icmpHeader?.identifier + case .failure: + return nil + } + } +} diff --git a/Tests/pingxTests/Extenstions/ICMPResponseValidationError+Equatable.swift b/Sources/pingx/Pinger/Pingers/Pinger.swift similarity index 58% rename from Tests/pingxTests/Extenstions/ICMPResponseValidationError+Equatable.swift rename to Sources/pingx/Pinger/Pingers/Pinger.swift index eb6f05c..d6a8832 100644 --- a/Tests/pingxTests/Extenstions/ICMPResponseValidationError+Equatable.swift +++ b/Sources/pingx/Pinger/Pingers/Pinger.swift @@ -22,23 +22,38 @@ // SOFTWARE. // -@testable import pingx +protocol PingerProtocol: AnyObject { + func ping(request: Request, completion: @escaping (PingerResult) -> Void) + func cancel(request: Request) +} -// MARK: - Equatable +final class Pinger: PingerProtocol { + private let asyncPinger: AsyncPinger + + init(asyncPinger: AsyncPinger) { + self.asyncPinger = asyncPinger + } + + convenience init() { + self.init( + asyncPinger: AsyncPinger() + ) + } + + func ping( + request: Request, + completion: @escaping (PingerResult) -> Void + ) { + Task { [weak asyncPinger] in + var sequence = asyncPinger?.ping(request: request) -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 - default: - return false - + while let result = try? await sequence?.next() { + completion(result) + } } } + + func cancel(request: Request) { + asyncPinger.cancel(request: request) + } } diff --git a/Sources/pingx/Pinger/Sequence/PingSequence.swift b/Sources/pingx/Pinger/Sequence/PingSequence.swift new file mode 100644 index 0000000..c9ed548 --- /dev/null +++ b/Sources/pingx/Pinger/Sequence/PingSequence.swift @@ -0,0 +1,76 @@ +// +// 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 + +struct PingSequence: AsyncSequence, AsyncIteratorProtocol { + typealias Failure = Never + + private let request: Request + private let pinger: AsyncPinger + + init(request: Request, pinger: AsyncPinger) { + self.request = request + self.pinger = pinger + } + + mutating func next() async throws -> PingerResult? { + guard request.demand != .none else { return nil } + + try Task.checkCancellation() + request.decreaseDemand() + + let result = await withTaskGroup( + of: PingerResult.self, + returning: Optional.self + ) { [weak pinger, request] taskGroup in + taskGroup.addTask { + do { + try await Task.sleep(nanoseconds: UInt64(request.timeoutInterval * 1_000_000)) + } catch {} + + return .failure(.timeout) + } + + taskGroup.addTask { + return await withCheckedContinuation { continutaion in + pinger?.ping(request) { result in + continutaion.resume(returning: result) + } + } + } + + defer { + taskGroup.cancelAll() + pinger?.cancel(request: request) + } + + return await taskGroup.next() + } + + return result + } + + func makeAsyncIterator() -> PingSequence { 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..9e70043 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", @@ -60,7 +60,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/Sources/pingx/Model/Socket/Api/PingxSocket.swift b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift similarity index 57% rename from Sources/pingx/Model/Socket/Api/PingxSocket.swift rename to Tests/pingxTests/Extenstions/Assertion+Extensions.swift index 1a3de3e..e8e3e2a 100644 --- a/Sources/pingx/Model/Socket/Api/PingxSocket.swift +++ b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift @@ -22,17 +22,33 @@ // SOFTWARE. // -import Foundation +import Testing +import XCTest -// sourcery: AutoMockable -protocol PingxSocket { +func expectToEventuallyBeCalled( + actualCallsCount: @autoclosure @escaping () -> Int, + expectedCallsCount: Int = 1, + timeout: TimeInterval = 1.0, + sourceLocation: SourceLocation = #_sourceLocation +) async { + let expectation = XCTestExpectation(description: "Wait for actualCallsCount to reach \(expectedCallsCount)") - // MARK: Typealias - - associatedtype Instance: AnyObject = CommandBlock - - // MARK: Methods + let task = Task { + while actualCallsCount() < expectedCallsCount { + if Task.isCancelled { return } + + try? await Task.sleep(nanoseconds: 30_000_000) // 30ms + } + + expectation.fulfill() + } - func send(address: CFData, data: CFData, timeout: CFTimeInterval) -> CFSocketError - func invalidate() + let result = await XCTWaiter.fulfillment(of: [expectation], timeout: timeout) + task.cancel() + + #expect( + result == .completed, + .__block("Wait for actualCallsCount to reach \(expectedCallsCount)"), + sourceLocation: sourceLocation + ) } diff --git a/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift b/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift new file mode 100644 index 0000000..b59647b --- /dev/null +++ b/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift @@ -0,0 +1,73 @@ +// +// 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: PingSequence, + count: Int = .max, + timeout: TimeInterval = 50 +) async throws -> [PingerResult] { + try await withThrowingTaskGroup( + of: [PingerResult].self, + returning: [PingerResult].self + ) { taskGroup in + taskGroup.addTask { + var values: [PingerResult] = [] + + for try await value in sequence where values.count < count { + values.append(value) + } + + return values + } + + taskGroup.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000)) + return [] + } + + defer { taskGroup.cancelAll() } + + return try await taskGroup.next().unsafelyUnwrapped + } +} + +func observerPingSequenceWithoutReturningResult( + sequence: PingSequence, + timeout: TimeInterval = 50 +) async throws { + try await collectValuesFromPingSequence( + sequence: sequence, + timeout: timeout + ) +} diff --git a/Tests/pingxTests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift b/Tests/pingxTests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift new file mode 100644 index 0000000..a920fce --- /dev/null +++ b/Tests/pingxTests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift @@ -0,0 +1,164 @@ +// +// 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.id) + #expect(icmpPacket.ipHeader.sourceAddress == request.destination) + } + + @Test("When payload is different, it throws invalidPayload error") + func extract_whenIdentifierIsDifferent_throwsInvalidPayloadError() async throws { + let payload = Payload.sample(identifier: (1, 1, 1, 1, 1, 1, 1, 1)) + let icmpHeader = ICMPHeader.sample(payload: payload) + let data = makeErrorData(icmpHeader: icmpHeader) + + #expect(throws: ICMPResponseValidationError.invalidPayload(icmpHeader)) { + try extractor.extract(from: data) + } + } + + @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.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) + + 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 + ) + let data: Data + + if let icmpHeader { + var icmp = ICMPPacket.sample(ipHeader: ipHeader, icmpHeader: icmpHeader) + data = Data(bytes: &icmp, count: MemoryLayout.size) + } else if shouldAddIpHeader { + var ipHeader = ipHeader + data = Data(bytes: &ipHeader, count: MemoryLayout.size) + } else { + data = Data() + } + + return data + } +} 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..85264d3 --- /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 makeReceivedArguments: (type: ICMPType, identifier: UInt16)? + var makeReceivedInvocations: [(type: ICMPType, identifier: UInt16)] = [] + var makeReturnValue: ICMPHeader! + var makeClosure: ((ICMPType, UInt16) throws -> ICMPHeader)? + + func make(type: ICMPType, identifier: UInt16) throws -> ICMPHeader { + makeCallsCount += 1 + makeReceivedArguments = (type: type, identifier: identifier) + makeReceivedInvocations.append((type: type, identifier: identifier)) + if let error = makeThrowableError { + throw error + } + if let makeClosure = makeClosure { + return try makeClosure(type, identifier) + } 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..e6d27a9 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: PingxSocket { // 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..240464e 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 PingxSocket)! + var makeClosure: ((CommandBlock) throws -> any PingxSocket)? + + func make(command: CommandBlock) throws -> any PingxSocket { + 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/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/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/AsyncPingerTests.swift b/Tests/pingxTests/Pinger/AsyncPingerTests.swift new file mode 100644 index 0000000..60a0c4a --- /dev/null +++ b/Tests/pingxTests/Pinger/AsyncPingerTests.swift @@ -0,0 +1,329 @@ +// +// 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 let 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 = AsyncPinger( + 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 pingerError.socketCreationError") + 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(.socketCreationError)] + ) + } + + @Test("When packet creation failed, it emits pingerError.unableToCreatePacket") + 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(.unableToCreatePacket)] + ) + } + + @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) + } + + @Test("When request failed, it emits pingerError.unknown") + 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(.unknown)] + ) + } + + @Test("When packet sending timed out, it emits pingerError.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.id + ) + ) + icmpPacketExtractor.extractReturnValue = icmpPacket + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: [.success(icmpPacket)], + 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: 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.id) + let error = ICMPResponseValidationError.invalidCode(icmpHeader) + icmpPacketExtractor.extractThrowableError = error + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: [.failure(.validationError(error))], + 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: 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: Int) async throws { + let request = Request.sample(demand: .max(demand)) + let icmpPacket = ICMPPacket.sample( + icmpHeader: .sample( + identifier: request.id + ) + ) + icmpPacketExtractor.extractReturnValue = icmpPacket + + let sequence = pinger.ping(request: request) + + try await checkThat( + sequence: sequence, + emits: Array(repeating: .success(icmpPacket), count: demand), + after: { + for callsCount in 1...demand { + await expectToEventuallyBeCalled( + actualCallsCount: socket.sendCallsCount, + expectedCallsCount: callsCount + ) + socketFactory.makeReceivedCommand?.closure(Data()) + } + }, + timeout: 1000 + ) + } +} + +private extension AsyncPingerTests { + func checkThat( + sequence: PingSequence, + emits expectedValues: [PingerResult], + after operation: (() async -> Void)? = nil, + timeout: TimeInterval = 50, + sourceLocation: SourceLocation = #_sourceLocation + ) async throws { + let expectation = XCTestExpectation( + description: "Wait for expectedValues to be equal to the values emitted by the sequence" + ) + + Task { + let values = try await collectValuesFromPingSequence(sequence: sequence, timeout: timeout) + #expect(expectedValues == values, sourceLocation: sourceLocation) + + expectation.fulfill() + } + + await operation?() + + let result = await XCTWaiter.fulfillment( + of: [expectation], + timeout: timeout * 1000 + ) + #expect(result == .completed) + } +} 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/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/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/Sources/pingx/Pinger/Configuration/PingerConfiguration.swift b/Tests/pingxTests/Samples/Payload+Sample.swift similarity index 80% rename from Sources/pingx/Pinger/Configuration/PingerConfiguration.swift rename to Tests/pingxTests/Samples/Payload+Sample.swift index f1bc72b..b18bab0 100644 --- a/Sources/pingx/Pinger/Configuration/PingerConfiguration.swift +++ b/Tests/pingxTests/Samples/Payload+Sample.swift @@ -24,16 +24,16 @@ 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 +@testable import pingx + +extension Payload { + static func sample( + identifier: PayloadID = Payload.pingxID, + timestamp: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() + ) -> Payload { + Payload( + identifier: identifier, + timestamp: timestamp + ) } } diff --git a/Sources/pingx/Factory/TimerFactory/Impl/TimerFactoryImpl.swift b/Tests/pingxTests/Samples/Request+Sample.swift similarity index 75% rename from Sources/pingx/Factory/TimerFactory/Impl/TimerFactoryImpl.swift rename to Tests/pingxTests/Samples/Request+Sample.swift index db15d4b..385e068 100644 --- a/Sources/pingx/Factory/TimerFactory/Impl/TimerFactoryImpl.swift +++ b/Tests/pingxTests/Samples/Request+Sample.swift @@ -24,16 +24,20 @@ 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 Request { + static func sample( + id: Request.ID = .zero, + destination: IPv4Address = .sample(), + timeoutInterval: TimeInterval = 1000, + demand: Demand = .max(1) + ) -> Request { + Request( + id: id, + destination: destination, + timeoutInterval: timeoutInterval, + demand: demand ) } } 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' From 655deb63fa380308f55a68d304ff3c63590a7e5d Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sat, 24 May 2025 13:25:28 +0200 Subject: [PATCH 02/25] [IMP-3] Add sequenceNumber --- .../Result+Extensions.swift} | 14 +- .../pingx/Extensions/String+Extensions.swift | 2 - .../ICMPHeaderFactory/ICMPHeaderFactory.swift | 14 +- .../Factory/SocketFactory/SocketFactory.swift | 6 +- Sources/pingx/Model/Request/Request.swift | 48 ++++--- Sources/pingx/Model/Response/PingError.swift | 70 +++++++++ .../Response/PingResult.swift} | 2 +- Sources/pingx/Model/Response/Response.swift | 3 + .../pingx/Pinger/Error/AsyncPingerError.swift | 53 +++++++ .../Model/AsyncPingerResult.swift} | 4 +- .../pingx/Pinger/Pingers/AsyncPinger.swift | 37 +++-- Sources/pingx/Pinger/Pingers/Pinger.swift | 28 +++- .../pingx/Pinger/Sequence/PingSequence.swift | 49 +++++-- .../Extenstions/Assertion+Extensions.swift | 29 ++++ .../Extenstions/PingSequence+Assertions.swift | 8 +- .../Extenstions/XCTestCase+Extensions.swift | 21 --- .../AsyncPinger+AutoMockable.generated.swift | 52 +++++++ ...HeaderFactory+AutoMockable.generated.swift | 14 +- Tests/pingxTests/Mocks/MockFunc.swift | 92 ++++++++++++ .../pingxTests/Pinger/AsyncPingerTests.swift | 136 +++++++++++++++--- Tests/pingxTests/Request/DemandTests.swift | 84 ++++++----- Tests/pingxTests/Request/RequestTests.swift | 70 +++++++++ Tests/pingxTests/Samples/Request+Sample.swift | 6 +- .../pingxTests/Samples/Response+Sample.swift | 41 ++++++ 24 files changed, 725 insertions(+), 158 deletions(-) rename Sources/pingx/{Pinger/Error/PingerError.swift => Extensions/Result+Extensions.swift} (84%) create mode 100644 Sources/pingx/Model/Response/PingError.swift rename Sources/pingx/{Pinger/Model/PingerResult.swift => Model/Response/PingResult.swift} (95%) create mode 100644 Sources/pingx/Pinger/Error/AsyncPingerError.swift rename Sources/pingx/{Model/FatalError/FatalError.swift => Pinger/Model/AsyncPingerResult.swift} (94%) create mode 100644 Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift create mode 100644 Tests/pingxTests/Mocks/MockFunc.swift create mode 100644 Tests/pingxTests/Request/RequestTests.swift create mode 100644 Tests/pingxTests/Samples/Response+Sample.swift diff --git a/Sources/pingx/Pinger/Error/PingerError.swift b/Sources/pingx/Extensions/Result+Extensions.swift similarity index 84% rename from Sources/pingx/Pinger/Error/PingerError.swift rename to Sources/pingx/Extensions/Result+Extensions.swift index 61b065d..0b06629 100644 --- a/Sources/pingx/Pinger/Error/PingerError.swift +++ b/Sources/pingx/Extensions/Result+Extensions.swift @@ -22,13 +22,9 @@ // SOFTWARE. // -import Foundation - -enum PingerError: Error, Equatable { - case cancelled - case socketCreationError - case timeout - case validationError(ICMPResponseValidationError) - case unableToCreatePacket - case unknown +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 66cc59f..cea3093 100644 --- a/Sources/pingx/Extensions/String+Extensions.swift +++ b/Sources/pingx/Extensions/String+Extensions.swift @@ -24,8 +24,6 @@ import Foundation -// MARK: - String+Extensions - extension String { var socketAddress: Data { var socketAddress = sockaddr_in() diff --git a/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift index 433355c..bbd5336 100644 --- a/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift +++ b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift @@ -26,15 +26,23 @@ import Foundation // sourcery: AutoMockable protocol ICMPHeaderFactoryProtocol { - func make(type: ICMPType, identifier: UInt16) throws -> ICMPHeader + func make( + type: ICMPType, + identifier: UInt16, + sequenceNumber: UInt16 + ) throws -> ICMPHeader } struct ICMPHeaderFactory: ICMPHeaderFactoryProtocol { - func make(type: ICMPType, identifier: UInt16) throws -> ICMPHeader { + func make( + type: ICMPType, + identifier: UInt16, + sequenceNumber: UInt16 + ) throws -> ICMPHeader { var icmpHeader = ICMPHeader( type: type, identifier: identifier, - sequenceNumber: CFSwapInt16HostToBig(UInt16.random(in: 0.. 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 { @@ -126,20 +140,22 @@ public extension Request { } 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/Model/Response/PingError.swift b/Sources/pingx/Model/Response/PingError.swift new file mode 100644 index 0000000..8d4ffe8 --- /dev/null +++ b/Sources/pingx/Model/Response/PingError.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 Foundation + +public enum PingError: CustomNSError { + public static var 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/Pinger/Model/PingerResult.swift b/Sources/pingx/Model/Response/PingResult.swift similarity index 95% rename from Sources/pingx/Pinger/Model/PingerResult.swift rename to Sources/pingx/Model/Response/PingResult.swift index 13e15c2..a3f47a9 100644 --- a/Sources/pingx/Pinger/Model/PingerResult.swift +++ b/Sources/pingx/Model/Response/PingResult.swift @@ -22,4 +22,4 @@ // SOFTWARE. // -typealias PingerResult = Result +public typealias PingResult = Result diff --git a/Sources/pingx/Model/Response/Response.swift b/Sources/pingx/Model/Response/Response.swift index 1b67ecc..04bae3f 100644 --- a/Sources/pingx/Model/Response/Response.swift +++ b/Sources/pingx/Model/Response/Response.swift @@ -33,4 +33,7 @@ public struct Response { /// Time elapsed between the request and the response. (ms) public let duration: TimeInterval + + /// Sequence number to match request/response. + public let sequenceNumber: UInt16 } diff --git a/Sources/pingx/Pinger/Error/AsyncPingerError.swift b/Sources/pingx/Pinger/Error/AsyncPingerError.swift new file mode 100644 index 0000000..2cf31a0 --- /dev/null +++ b/Sources/pingx/Pinger/Error/AsyncPingerError.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 + +enum AsyncPingerError: CustomNSError { + static var errorDomain: String { "pingx.PingerError" } + + public 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 timeout + case responseStructureInconsistent(ICMPResponseValidationError) + case unableToCreatePacket + case unknown +} diff --git a/Sources/pingx/Model/FatalError/FatalError.swift b/Sources/pingx/Pinger/Model/AsyncPingerResult.swift similarity index 94% rename from Sources/pingx/Model/FatalError/FatalError.swift rename to Sources/pingx/Pinger/Model/AsyncPingerResult.swift index 1d9e927..dd9de45 100644 --- a/Sources/pingx/Model/FatalError/FatalError.swift +++ b/Sources/pingx/Pinger/Model/AsyncPingerResult.swift @@ -22,6 +22,4 @@ // SOFTWARE. // -enum FatalError { - static var trigger = Swift.fatalError -} +typealias AsyncPingerResult = Result diff --git a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift index 0ce7c6a..8ae00ab 100644 --- a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift +++ b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift @@ -24,6 +24,7 @@ import Foundation +// sourcery: AutoMockable protocol AsyncPingerProtocol: AnyObject { func ping(request: Request) -> PingSequence func cancel(request: Request) @@ -37,7 +38,7 @@ final class AsyncPinger: AsyncPingerProtocol { // MARK: Properties - @Atomic private var completions = [UInt16: (PingerResult) -> Void]() + @Atomic private var completions = [UInt16: (AsyncPingerResult) -> Void]() private let icmpHeaderFactory: ICMPHeaderFactoryProtocol private let icmpPacketExtractor: ICMPPacketExtractorProtocol private let socketFactory: SocketFactoryProtocol @@ -67,9 +68,17 @@ final class AsyncPinger: AsyncPingerProtocol { PingSequence(request: request, pinger: self) } + func cancel(request: Request) { + invokeCompletion(identifier: request.id, result: .failure(.cancelled)) + } +} + +// MARK: - Internal API + +extension AsyncPinger { func ping( _ request: Request, - completion: @escaping (PingerResult) -> Void + completion: @escaping (AsyncPingerResult) -> Void ) { completions[request.id] = completion @@ -80,7 +89,13 @@ final class AsyncPinger: AsyncPingerProtocol { return } - guard let packet = try? icmpHeaderFactory.make(type: request.type, identifier: request.id) else { + let packet = try? icmpHeaderFactory.make( + type: request.type, + identifier: request.id, + sequenceNumber: request.sequenceNumber + ) + + guard let packet else { invokeCompletion(identifier: request.id, result: .failure(.unableToCreatePacket)) return } @@ -95,10 +110,6 @@ final class AsyncPinger: AsyncPingerProtocol { invokeCompletion(identifier: request.id, result: .failure(error)) } } - - func cancel(request: Request) { - invokeCompletion(identifier: request.id, result: .failure(.cancelled)) - } } // MARK: - Private API @@ -110,12 +121,12 @@ private extension AsyncPinger { let command: CommandBlock = CommandBlock { [weak self] data in guard let self else { return } - let result: PingerResult = { [icmpPacketExtractor] in + let result: AsyncPingerResult = { [icmpPacketExtractor] in do { let icmpPacket = try icmpPacketExtractor.extract(from: data) return .success(icmpPacket) } catch let error as ICMPResponseValidationError { - return .failure(.validationError(error)) + return .failure(.responseStructureInconsistent(error)) } catch { return .failure(.unknown) } @@ -129,14 +140,14 @@ private extension AsyncPinger { pingxSocket = try socketFactory.make(command: command) } - func invokeCompletion(identifier: Request.ID, result: PingerResult) { + func invokeCompletion(identifier: Request.ID, result: AsyncPingerResult) { let completion = completions.removeValue(forKey: identifier) completion?(result) } } private extension CFSocketError { - func mapToPingerError() -> PingerError? { + func mapToPingerError() -> AsyncPingerError? { switch self { case .error: return .unknown @@ -148,12 +159,12 @@ private extension CFSocketError { } } -private extension Result { +private extension Result { var identifier: Request.ID? { switch self { case .success(let icmpPacket): return icmpPacket.icmpHeader.identifier - case .failure(.validationError(let validationError)): + case .failure(.responseStructureInconsistent(let validationError)): return validationError.icmpHeader?.identifier case .failure: return nil diff --git a/Sources/pingx/Pinger/Pingers/Pinger.swift b/Sources/pingx/Pinger/Pingers/Pinger.swift index d6a8832..d28c5b2 100644 --- a/Sources/pingx/Pinger/Pingers/Pinger.swift +++ b/Sources/pingx/Pinger/Pingers/Pinger.swift @@ -23,14 +23,15 @@ // protocol PingerProtocol: AnyObject { - func ping(request: Request, completion: @escaping (PingerResult) -> Void) + func ping(request: Request, completion: @escaping (PingResult) -> Void) func cancel(request: Request) } final class Pinger: PingerProtocol { - private let asyncPinger: AsyncPinger + private let asyncPinger: AsyncPingerProtocol + @Atomic private var activeRequests: [UInt16: Request] = [:] - init(asyncPinger: AsyncPinger) { + init(asyncPinger: AsyncPingerProtocol) { self.asyncPinger = asyncPinger } @@ -40,20 +41,35 @@ final class Pinger: PingerProtocol { ) } + deinit { + cancelAllActiveRequests() + } + func ping( request: Request, - completion: @escaping (PingerResult) -> Void + completion: @escaping (PingResult) -> Void ) { - Task { [weak asyncPinger] in - var sequence = asyncPinger?.ping(request: request) + activeRequests = [request.id: request] + + Task { [weak self] in + var sequence = self?.asyncPinger.ping(request: request) while let result = try? await sequence?.next() { completion(result) } + + self?.cancel(request: request) } } func cancel(request: Request) { + activeRequests.removeValue(forKey: request.id) asyncPinger.cancel(request: request) } + + private func cancelAllActiveRequests() { + activeRequests.values.forEach { request in + cancel(request: request) + } + } } diff --git a/Sources/pingx/Pinger/Sequence/PingSequence.swift b/Sources/pingx/Pinger/Sequence/PingSequence.swift index c9ed548..cabd8b3 100644 --- a/Sources/pingx/Pinger/Sequence/PingSequence.swift +++ b/Sources/pingx/Pinger/Sequence/PingSequence.swift @@ -24,9 +24,7 @@ import Foundation -struct PingSequence: AsyncSequence, AsyncIteratorProtocol { - typealias Failure = Never - +public struct PingSequence: AsyncSequence, AsyncIteratorProtocol { private let request: Request private let pinger: AsyncPinger @@ -35,15 +33,14 @@ struct PingSequence: AsyncSequence, AsyncIteratorProtocol { self.pinger = pinger } - mutating func next() async throws -> PingerResult? { + public mutating func next() async throws -> PingResult? { guard request.demand != .none else { return nil } try Task.checkCancellation() - request.decreaseDemand() let result = await withTaskGroup( - of: PingerResult.self, - returning: Optional.self + of: AsyncPingerResult.self, + returning: Optional.self ) { [weak pinger, request] taskGroup in taskGroup.addTask { do { @@ -68,9 +65,41 @@ struct PingSequence: AsyncSequence, AsyncIteratorProtocol { return await taskGroup.next() } - - return result + + request.decreaseDemand() + request.incrementSequenceNumber() + + if case .cancelled = result?.error { + request.setDemand(.none) + } + + return result? + .map { icmpPacket in + Response( + destination: icmpPacket.ipHeader.sourceAddress, + duration: (CFAbsoluteTimeGetCurrent() - icmpPacket.icmpHeader.payload.timestamp) * 1000, + sequenceNumber: icmpPacket.icmpHeader.sequenceNumber + ) + } + .mapError { $0.mapToPingError() } } - func makeAsyncIterator() -> PingSequence { self } + public func makeAsyncIterator() -> PingSequence { self } +} + +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/pingxTests/Extenstions/Assertion+Extensions.swift b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift index e8e3e2a..9945ee4 100644 --- a/Tests/pingxTests/Extenstions/Assertion+Extensions.swift +++ b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift @@ -52,3 +52,32 @@ func expectToEventuallyBeCalled( sourceLocation: sourceLocation ) } + +func expectToEventuallyNotToBeCalled( + actualCallsCount: @autoclosure @escaping () -> Int, + expectedCallsCount: Int = 1, + timeout: TimeInterval = 0.1, + sourceLocation: SourceLocation = #_sourceLocation +) async { + let expectation = XCTestExpectation(description: "Wait for actualCallsCount to reach \(expectedCallsCount)") + expectation.isInverted = true + + let task = Task { + while actualCallsCount() < expectedCallsCount { + if Task.isCancelled { return } + + try? await Task.sleep(nanoseconds: 30_000_000) // 30ms + } + + expectation.fulfill() + } + + let result = await XCTWaiter.fulfillment(of: [expectation], timeout: timeout) + task.cancel() + + #expect( + result == .completed, + .__block("Wait for actualCallsCount to not reach \(expectedCallsCount)"), + sourceLocation: sourceLocation + ) +} diff --git a/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift b/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift index b59647b..3fb743c 100644 --- a/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift +++ b/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift @@ -36,13 +36,13 @@ import Foundation sequence: PingSequence, count: Int = .max, timeout: TimeInterval = 50 -) async throws -> [PingerResult] { +) async throws -> [PingResult] { try await withThrowingTaskGroup( - of: [PingerResult].self, - returning: [PingerResult].self + of: [PingResult].self, + returning: [PingResult].self ) { taskGroup in taskGroup.addTask { - var values: [PingerResult] = [] + var values: [PingResult] = [] for try await value in sequence where values.count < count { values.append(value) diff --git a/Tests/pingxTests/Extenstions/XCTestCase+Extensions.swift b/Tests/pingxTests/Extenstions/XCTestCase+Extensions.swift index 084e706..3dfda3b 100644 --- a/Tests/pingxTests/Extenstions/XCTestCase+Extensions.swift +++ b/Tests/pingxTests/Extenstions/XCTestCase+Extensions.swift @@ -26,27 +26,6 @@ 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, 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..c9f455b --- /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 + + var pingCallsCount = 0 + var pingCalled: Bool { + return pingCallsCount > 0 + } + var pingReceivedRequest: (Request)? + var pingReceivedInvocations: [(Request)] = [] + var pingReturnValue: PingSequence! + var pingClosure: ((Request) -> PingSequence)? + + func ping(request: Request) -> PingSequence { + pingCallsCount += 1 + pingReceivedRequest = request + pingReceivedInvocations.append(request) + if let pingClosure = pingClosure { + return pingClosure(request) + } else { + return pingReturnValue + } + } + + // MARK: - cancel + + var cancelCallsCount = 0 + var cancelCalled: Bool { + return cancelCallsCount > 0 + } + var cancelReceivedRequest: (Request)? + var cancelReceivedInvocations: [(Request)] = [] + var cancelClosure: ((Request) -> Void)? + + func cancel(request: Request) { + cancelCallsCount += 1 + cancelReceivedRequest = request + cancelReceivedInvocations.append(request) + cancelClosure?(request) + } + +} + +// swiftlint:enable all diff --git a/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift index 85264d3..41c108d 100644 --- a/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift +++ b/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift @@ -15,20 +15,20 @@ final class ICMPHeaderFactoryMock: ICMPHeaderFactoryProtocol { var makeCalled: Bool { return makeCallsCount > 0 } - var makeReceivedArguments: (type: ICMPType, identifier: UInt16)? - var makeReceivedInvocations: [(type: ICMPType, identifier: UInt16)] = [] + var makeReceivedArguments: (type: ICMPType, identifier: UInt16, sequenceNumber: UInt16)? + var makeReceivedInvocations: [(type: ICMPType, identifier: UInt16, sequenceNumber: UInt16)] = [] var makeReturnValue: ICMPHeader! - var makeClosure: ((ICMPType, UInt16) throws -> ICMPHeader)? + var makeClosure: ((ICMPType, UInt16, UInt16) throws -> ICMPHeader)? - func make(type: ICMPType, identifier: UInt16) throws -> ICMPHeader { + func make(type: ICMPType, identifier: UInt16, sequenceNumber: UInt16) throws -> ICMPHeader { makeCallsCount += 1 - makeReceivedArguments = (type: type, identifier: identifier) - makeReceivedInvocations.append((type: type, identifier: identifier)) + makeReceivedArguments = (type: type, identifier: identifier, sequenceNumber: sequenceNumber) + makeReceivedInvocations.append((type: type, identifier: identifier, sequenceNumber: sequenceNumber)) if let error = makeThrowableError { throw error } if let makeClosure = makeClosure { - return try makeClosure(type, identifier) + return try makeClosure(type, identifier, sequenceNumber) } else { return makeReturnValue } diff --git a/Tests/pingxTests/Mocks/MockFunc.swift b/Tests/pingxTests/Mocks/MockFunc.swift new file mode 100644 index 0000000..d4d6ff7 --- /dev/null +++ b/Tests/pingxTests/Mocks/MockFunc.swift @@ -0,0 +1,92 @@ +// +// 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 + } +} + +//// MARK: Syntactic Sugar +// +//extension MockFunc { +// public mutating func returns(_ value: Output) { +// result = { _ in value } +// } +// +// public mutating func returns() where Output == Void { +// result = { _ in () } +// } +// +// public mutating func returnsNil() +// where Output == Optional { +// +// result = { _ in nil } +// } +// +// public mutating func succeeds(_ value: T) +// where Output == Result { +// +// result = { _ in .success(value) } +// } +// +// public mutating func fails(_ error: Error) +// where Output == Result { +// +// result = { _ in .failure(error) } +// } +//} diff --git a/Tests/pingxTests/Pinger/AsyncPingerTests.swift b/Tests/pingxTests/Pinger/AsyncPingerTests.swift index 60a0c4a..d4fe6e7 100644 --- a/Tests/pingxTests/Pinger/AsyncPingerTests.swift +++ b/Tests/pingxTests/Pinger/AsyncPingerTests.swift @@ -79,7 +79,7 @@ struct AsyncPingerTests { #expect(socketFactory.makeCallsCount == 1) } - @Test("When socket creation failed, it emits pingerError.socketCreationError") + @Test("When socket creation failed, it emits pingError.socketFailed") func send_whenSocketIsNotCreatedAndCreationFailed_emitsSocketCreationError() async throws { let request = Request.sample() socketFactory.makeThrowableError = AnyError() @@ -88,11 +88,11 @@ struct AsyncPingerTests { try await checkThat( sequence: sequence, - emits: [.failure(.socketCreationError)] + emits: [.failure(.socketFailed)] ) } - @Test("When packet creation failed, it emits pingerError.unableToCreatePacket") + @Test("When packet creation failed, it emits pingError.internalError") func send_whenPacketCreationFailed_emitsPacketCreationError() async throws { let request = Request.sample() socketFactory.makeReturnValue = PingxSocketMock() @@ -102,7 +102,7 @@ struct AsyncPingerTests { try await checkThat( sequence: sequence, - emits: [.failure(.unableToCreatePacket)] + emits: [.failure(.internalError(AsyncPingerError.unableToCreatePacket))] ) } @@ -121,7 +121,7 @@ struct AsyncPingerTests { #expect(socket.sendReceivedArguments?.timeout == request.timeoutInterval) } - @Test("When request failed, it emits pingerError.unknown") + @Test("When request failed, it emits pingError.internalError") func send_whenPacketSendingFailed_emitsError() async throws { let request = Request.sample() socket.sendReturnValue = .error @@ -130,11 +130,11 @@ struct AsyncPingerTests { try await checkThat( sequence: sequence, - emits: [.failure(.unknown)] + emits: [.failure(.internalError(AsyncPingerError.unknown))] ) } - @Test("When packet sending timed out, it emits pingerError.timeout") + @Test("When packet sending timed out, it emits pingError.timeout") func send_whenPacketSendingTimedOut_emitsTimedOutError() async throws { let request = Request.sample() socket.sendReturnValue = .timeout @@ -158,10 +158,14 @@ struct AsyncPingerTests { 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(icmpPacket)], + emits: [.success(response)], after: { await expectToEventuallyBeCalled(actualCallsCount: socket.sendCallsCount) socketFactory.makeReceivedCommand?.closure(Data()) @@ -218,7 +222,7 @@ struct AsyncPingerTests { try await checkThat( sequence: sequence, - emits: [.failure(.validationError(error))], + emits: [.failure(.responseStructureInconsistent)], after: { await expectToEventuallyBeCalled(actualCallsCount: socket.sendCallsCount) socketFactory.makeReceivedCommand?.closure(Data()) @@ -271,22 +275,28 @@ struct AsyncPingerTests { "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: Int) async throws { + func send_whenRequestDemandIsGreaterThanOne_emitsCorrespondingNumberOfValues(demand: UInt) async throws { let request = Request.sample(demand: .max(demand)) - let icmpPacket = ICMPPacket.sample( - icmpHeader: .sample( - identifier: request.id - ) - ) - icmpPacketExtractor.extractReturnValue = icmpPacket let sequence = pinger.ping(request: request) + let responses = (0.. Void)? = nil, timeout: TimeInterval = 50, sourceLocation: SourceLocation = #_sourceLocation @@ -313,7 +366,14 @@ private extension AsyncPingerTests { Task { let values = try await collectValuesFromPingSequence(sequence: sequence, timeout: timeout) - #expect(expectedValues == values, sourceLocation: sourceLocation) + + for (actualValue, expectedValue) in zip(values, expectedValues) { + PingResult.beEqual( + actualValue: actualValue, + expectedValue: expectedValue, + sourceLocation: sourceLocation + ) + } expectation.fulfill() } @@ -327,3 +387,37 @@ private extension AsyncPingerTests { #expect(result == .completed) } } + +private extension PingResult { + static func beEqual( + actualValue: PingResult, + expectedValue: PingResult, + sourceLocation: SourceLocation = #_sourceLocation + ) { + switch (actualValue, expectedValue) { + case (.success(let lValue), .success(let rValue)): + #expect( + lValue.destination == rValue.destination, + sourceLocation: sourceLocation + ) + #expect( + lValue.sequenceNumber == rValue.sequenceNumber, + sourceLocation: sourceLocation + ) + case (.failure(let lError), .failure(let rError)): + #expect( + lError.errorCode == rError.errorCode, + sourceLocation: sourceLocation + ) + #expect( + lError.underlyingError?.errorCode == rError.underlyingError?.errorCode, + sourceLocation: sourceLocation + ) + default: + #expect( + Bool(false), + sourceLocation: sourceLocation + ) + } + } +} diff --git a/Tests/pingxTests/Request/DemandTests.swift b/Tests/pingxTests/Request/DemandTests.swift index 06b8f7e..e7d94e3 100644 --- a/Tests/pingxTests/Request/DemandTests.swift +++ b/Tests/pingxTests/Request/DemandTests.swift @@ -22,47 +22,57 @@ // SOFTWARE. // -import XCTest +import Testing + @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) - } +@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) } - 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]) - } - } + @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) + } - 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]) - } + @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/Request/RequestTests.swift b/Tests/pingxTests/Request/RequestTests.swift new file mode 100644 index 0000000..78813eb --- /dev/null +++ b/Tests/pingxTests/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 + ) { + let 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 + ) { + let request = Request.sample(sequenceNumber: initialSequenceNumber) + + request.incrementSequenceNumber() + + #expect(request.sequenceNumber == expectedSequenceNumber) + } +} diff --git a/Tests/pingxTests/Samples/Request+Sample.swift b/Tests/pingxTests/Samples/Request+Sample.swift index 385e068..3441142 100644 --- a/Tests/pingxTests/Samples/Request+Sample.swift +++ b/Tests/pingxTests/Samples/Request+Sample.swift @@ -31,13 +31,15 @@ extension Request { id: Request.ID = .zero, destination: IPv4Address = .sample(), timeoutInterval: TimeInterval = 1000, - demand: Demand = .max(1) + demand: Demand = .max(1), + sequenceNumber: UInt16 = .zero ) -> Request { Request( id: id, destination: destination, timeoutInterval: timeoutInterval, - demand: demand + demand: demand, + sequenceNumber: sequenceNumber ) } } diff --git a/Tests/pingxTests/Samples/Response+Sample.swift b/Tests/pingxTests/Samples/Response+Sample.swift new file mode 100644 index 0000000..bd6da0b --- /dev/null +++ b/Tests/pingxTests/Samples/Response+Sample.swift @@ -0,0 +1,41 @@ +// +// 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 Response { + static func sample( + destination: IPv4Address = .sample(), + duration: TimeInterval = .zero, + sequenceNumber: UInt16 = .zero + ) -> Response { + Response( + destination: destination, + duration: duration, + sequenceNumber: sequenceNumber + ) + } +} From f6d5b5d7202199c8d0537878d8dc43de7c335f90 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 25 May 2025 11:11:02 +0200 Subject: [PATCH 03/25] Update structure --- .../Checksum/{Impl => }/ICMPChecksum.swift | 0 .../Factory/SocketFactory/SocketFactory.swift | 6 +- Sources/pingx/Model/IP/IPv4/IPv4Address.swift | 10 ++-- Sources/pingx/Model/Request/Request.swift | 6 +- Sources/pingx/Model/Socket/PingxSocket.swift | 4 +- .../Error/ICMPResponseValidationError.swift | 2 +- .../pingx/Pinger/Pingers/AsyncPinger.swift | 10 ++-- Sources/pingx/Pinger/Pingers/Pinger.swift | 29 +++++----- .../Extenstions/XCTestCase+Extensions.swift | 57 ------------------- .../AsyncPinger+AutoMockable.generated.swift | 26 ++++----- .../PingxSocket+AutoMockable.generated.swift | 2 +- ...SocketFactory+AutoMockable.generated.swift | 6 +- Tests/pingxTests/Mocks/MockFunc.swift | 31 ---------- .../ICMPChecksum/ICMPChecksumTests.swift | 0 .../ICMPPackageExtractorTests.swift | 20 +++++++ .../{ => Tests}/Pinger/AsyncPingerTests.swift | 0 .../{ => Tests}/Request/DemandTests.swift | 0 .../{ => Tests}/Request/RequestTests.swift | 0 18 files changed, 72 insertions(+), 137 deletions(-) rename Sources/pingx/Checksum/{Impl => }/ICMPChecksum.swift (100%) delete mode 100644 Tests/pingxTests/Extenstions/XCTestCase+Extensions.swift rename Tests/pingxTests/{ => Tests}/ICMPChecksum/ICMPChecksumTests.swift (100%) rename Tests/pingxTests/{ => Tests}/ICMPPackageExtractor/ICMPPackageExtractorTests.swift (88%) rename Tests/pingxTests/{ => Tests}/Pinger/AsyncPingerTests.swift (100%) rename Tests/pingxTests/{ => Tests}/Request/DemandTests.swift (100%) rename Tests/pingxTests/{ => Tests}/Request/RequestTests.swift (100%) diff --git a/Sources/pingx/Checksum/Impl/ICMPChecksum.swift b/Sources/pingx/Checksum/ICMPChecksum.swift similarity index 100% rename from Sources/pingx/Checksum/Impl/ICMPChecksum.swift rename to Sources/pingx/Checksum/ICMPChecksum.swift diff --git a/Sources/pingx/Factory/SocketFactory/SocketFactory.swift b/Sources/pingx/Factory/SocketFactory/SocketFactory.swift index 40920a7..b935f27 100644 --- a/Sources/pingx/Factory/SocketFactory/SocketFactory.swift +++ b/Sources/pingx/Factory/SocketFactory/SocketFactory.swift @@ -26,13 +26,13 @@ import Foundation // sourcery: AutoMockable protocol SocketFactoryProtocol { - func make(command: CommandBlock) throws -> any PingxSocket + func make(command: CommandBlock) throws -> any PingxSocketProtocol } final class SocketFactory: SocketFactoryProtocol { typealias SocketCommand = CommandBlock - func make(command: SocketCommand) throws -> any PingxSocket { + func make(command: SocketCommand) throws -> any PingxSocketProtocol { let unmanaged = Unmanaged.passRetained(command) var context = CFSocketContext( version: .zero, @@ -86,7 +86,7 @@ final class SocketFactory: SocketFactoryProtocol { .commonModes ) - return PingxSocketImpl( + return PingxSocket( socket: socket, socketSource: socketSource, unmanaged: unmanaged diff --git a/Sources/pingx/Model/IP/IPv4/IPv4Address.swift b/Sources/pingx/Model/IP/IPv4/IPv4Address.swift index 548797f..f034c0f 100644 --- a/Sources/pingx/Model/IP/IPv4/IPv4Address.swift +++ b/Sources/pingx/Model/IP/IPv4/IPv4Address.swift @@ -24,7 +24,7 @@ import Foundation -public struct IPv4Address { +public struct IPv4Address: Hashable { // MARK: Properties @@ -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 && diff --git a/Sources/pingx/Model/Request/Request.swift b/Sources/pingx/Model/Request/Request.swift index b4729a5..940c1bd 100644 --- a/Sources/pingx/Model/Request/Request.swift +++ b/Sources/pingx/Model/Request/Request.swift @@ -24,7 +24,7 @@ import Foundation -public final class Request: Identifiable, Equatable { +public final class Request: Identifiable, Hashable { // MARK: Properties @@ -85,6 +85,8 @@ public final class Request: Identifiable, Equatable { hasher.combine(type) hasher.combine(destination) hasher.combine(timeoutInterval) + hasher.combine(demand) + hasher.combine(sequenceNumber) } func setDemand(_ demand: Demand) { @@ -104,7 +106,7 @@ public final class Request: Identifiable, Equatable { // MARK: - Demand public extension Request { - struct Demand: Equatable { + struct Demand: Hashable { // MARK: Properties diff --git a/Sources/pingx/Model/Socket/PingxSocket.swift b/Sources/pingx/Model/Socket/PingxSocket.swift index 9ad5a85..4270ab3 100644 --- a/Sources/pingx/Model/Socket/PingxSocket.swift +++ b/Sources/pingx/Model/Socket/PingxSocket.swift @@ -25,7 +25,7 @@ import Foundation // sourcery: AutoMockable -protocol PingxSocket { +protocol PingxSocketProtocol { // MARK: Typealias @@ -36,7 +36,7 @@ protocol PingxSocket { func send(address: CFData, data: CFData, timeout: CFTimeInterval) -> CFSocketError } -final class PingxSocketImpl: PingxSocket { +final class PingxSocket: PingxSocketProtocol { // MARK: Typealias diff --git a/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift b/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift index cc68104..e6e161f 100644 --- a/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift +++ b/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift @@ -22,7 +22,7 @@ // SOFTWARE. // -enum ICMPResponseValidationError: Error, Equatable { +enum ICMPResponseValidationError: Error { var icmpHeader: ICMPHeader? { switch self { case .checksumMismatch(let icmpHeader), diff --git a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift index 8ae00ab..39a0e38 100644 --- a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift +++ b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift @@ -25,12 +25,12 @@ import Foundation // sourcery: AutoMockable -protocol AsyncPingerProtocol: AnyObject { +public protocol AsyncPingerProtocol: AnyObject { func ping(request: Request) -> PingSequence func cancel(request: Request) } -final class AsyncPinger: AsyncPingerProtocol { +public final class AsyncPinger: AsyncPingerProtocol { // MARK: Typealias @@ -42,7 +42,7 @@ final class AsyncPinger: AsyncPingerProtocol { private let icmpHeaderFactory: ICMPHeaderFactoryProtocol private let icmpPacketExtractor: ICMPPacketExtractorProtocol private let socketFactory: SocketFactoryProtocol - private var pingxSocket: (any PingxSocket)! + private var pingxSocket: (any PingxSocketProtocol)! // MARK: Initializer @@ -64,11 +64,11 @@ final class AsyncPinger: AsyncPingerProtocol { ) } - func ping(request: Request) -> PingSequence { + public func ping(request: Request) -> PingSequence { PingSequence(request: request, pinger: self) } - func cancel(request: Request) { + public func cancel(request: Request) { invokeCompletion(identifier: request.id, result: .failure(.cancelled)) } } diff --git a/Sources/pingx/Pinger/Pingers/Pinger.swift b/Sources/pingx/Pinger/Pingers/Pinger.swift index d28c5b2..1214814 100644 --- a/Sources/pingx/Pinger/Pingers/Pinger.swift +++ b/Sources/pingx/Pinger/Pingers/Pinger.swift @@ -22,20 +22,20 @@ // SOFTWARE. // -protocol PingerProtocol: AnyObject { +public protocol PingerProtocol: AnyObject { func ping(request: Request, completion: @escaping (PingResult) -> Void) func cancel(request: Request) } -final class Pinger: PingerProtocol { +public final class Pinger: PingerProtocol { private let asyncPinger: AsyncPingerProtocol - @Atomic private var activeRequests: [UInt16: Request] = [:] + @Atomic private var activeTasks: [UInt16: Task] = [:] init(asyncPinger: AsyncPingerProtocol) { self.asyncPinger = asyncPinger } - convenience init() { + public convenience init() { self.init( asyncPinger: AsyncPinger() ) @@ -45,31 +45,34 @@ final class Pinger: PingerProtocol { cancelAllActiveRequests() } - func ping( + public func ping( request: Request, completion: @escaping (PingResult) -> Void ) { - activeRequests = [request.id: request] + var task: Task? - Task { [weak self] in + task = Task { [weak self] in var sequence = self?.asyncPinger.ping(request: request) - while let result = try? await sequence?.next() { + while !Task.isCancelled, let result = try? await sequence?.next() as? PingResult { completion(result) } self?.cancel(request: request) } + + activeTasks[request.id] = task } - func cancel(request: Request) { - activeRequests.removeValue(forKey: request.id) + public func cancel(request: Request) { asyncPinger.cancel(request: request) + + let task = activeTasks.removeValue(forKey: request.id) + task?.cancel() } private func cancelAllActiveRequests() { - activeRequests.values.forEach { request in - cancel(request: request) - } + activeTasks.values.forEach { $0.cancel() } + activeTasks.removeAll() } } diff --git a/Tests/pingxTests/Extenstions/XCTestCase+Extensions.swift b/Tests/pingxTests/Extenstions/XCTestCase+Extensions.swift deleted file mode 100644 index 3dfda3b..0000000 --- a/Tests/pingxTests/Extenstions/XCTestCase+Extensions.swift +++ /dev/null @@ -1,57 +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 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 index c9f455b..072558d 100644 --- a/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift +++ b/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift @@ -10,16 +10,16 @@ final class AsyncPingerMock: AsyncPingerProtocol { // MARK: - ping - var pingCallsCount = 0 - var pingCalled: Bool { + public var pingCallsCount = 0 + public var pingCalled: Bool { return pingCallsCount > 0 } - var pingReceivedRequest: (Request)? - var pingReceivedInvocations: [(Request)] = [] - var pingReturnValue: PingSequence! - var pingClosure: ((Request) -> PingSequence)? + public var pingReceivedRequest: (Request)? + public var pingReceivedInvocations: [(Request)] = [] + public var pingReturnValue: PingSequence! + public var pingClosure: ((Request) -> PingSequence)? - func ping(request: Request) -> PingSequence { + public func ping(request: Request) -> PingSequence { pingCallsCount += 1 pingReceivedRequest = request pingReceivedInvocations.append(request) @@ -32,15 +32,15 @@ final class AsyncPingerMock: AsyncPingerProtocol { // MARK: - cancel - var cancelCallsCount = 0 - var cancelCalled: Bool { + public var cancelCallsCount = 0 + public var cancelCalled: Bool { return cancelCallsCount > 0 } - var cancelReceivedRequest: (Request)? - var cancelReceivedInvocations: [(Request)] = [] - var cancelClosure: ((Request) -> Void)? + public var cancelReceivedRequest: (Request)? + public var cancelReceivedInvocations: [(Request)] = [] + public var cancelClosure: ((Request) -> Void)? - func cancel(request: Request) { + public func cancel(request: Request) { cancelCallsCount += 1 cancelReceivedRequest = request cancelReceivedInvocations.append(request) diff --git a/Tests/pingxTests/Mocks/Generated/PingxSocket+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/PingxSocket+AutoMockable.generated.swift index e6d27a9..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 -final class PingxSocketMock: PingxSocket { +final class PingxSocketMock: PingxSocketProtocol { // MARK: - send diff --git a/Tests/pingxTests/Mocks/Generated/SocketFactory+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/SocketFactory+AutoMockable.generated.swift index 240464e..d55b2a8 100644 --- a/Tests/pingxTests/Mocks/Generated/SocketFactory+AutoMockable.generated.swift +++ b/Tests/pingxTests/Mocks/Generated/SocketFactory+AutoMockable.generated.swift @@ -17,10 +17,10 @@ final class SocketFactoryMock: SocketFactoryProtocol { } var makeReceivedCommand: (CommandBlock)? var makeReceivedInvocations: [(CommandBlock)] = [] - var makeReturnValue: (any PingxSocket)! - var makeClosure: ((CommandBlock) throws -> any PingxSocket)? + var makeReturnValue: (any PingxSocketProtocol)! + var makeClosure: ((CommandBlock) throws -> any PingxSocketProtocol)? - func make(command: CommandBlock) throws -> any PingxSocket { + func make(command: CommandBlock) throws -> any PingxSocketProtocol { makeCallsCount += 1 makeReceivedCommand = command makeReceivedInvocations.append(command) diff --git a/Tests/pingxTests/Mocks/MockFunc.swift b/Tests/pingxTests/Mocks/MockFunc.swift index d4d6ff7..4557d6e 100644 --- a/Tests/pingxTests/Mocks/MockFunc.swift +++ b/Tests/pingxTests/Mocks/MockFunc.swift @@ -22,7 +22,6 @@ // SOFTWARE. // - import Foundation final class MockFunc { @@ -60,33 +59,3 @@ final class MockFunc { return output } } - -//// MARK: Syntactic Sugar -// -//extension MockFunc { -// public mutating func returns(_ value: Output) { -// result = { _ in value } -// } -// -// public mutating func returns() where Output == Void { -// result = { _ in () } -// } -// -// public mutating func returnsNil() -// where Output == Optional { -// -// result = { _ in nil } -// } -// -// public mutating func succeeds(_ value: T) -// where Output == Result { -// -// result = { _ in .success(value) } -// } -// -// public mutating func fails(_ error: Error) -// where Output == Result { -// -// result = { _ in .failure(error) } -// } -//} diff --git a/Tests/pingxTests/ICMPChecksum/ICMPChecksumTests.swift b/Tests/pingxTests/Tests/ICMPChecksum/ICMPChecksumTests.swift similarity index 100% rename from Tests/pingxTests/ICMPChecksum/ICMPChecksumTests.swift rename to Tests/pingxTests/Tests/ICMPChecksum/ICMPChecksumTests.swift diff --git a/Tests/pingxTests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift b/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift similarity index 88% rename from Tests/pingxTests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift rename to Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift index a920fce..c529f47 100644 --- a/Tests/pingxTests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift +++ b/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift @@ -162,3 +162,23 @@ private extension ICMPPackageExtractorTests { 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 (.invalidPayload(let lValue), .invalidPayload(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/Pinger/AsyncPingerTests.swift b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift similarity index 100% rename from Tests/pingxTests/Pinger/AsyncPingerTests.swift rename to Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift diff --git a/Tests/pingxTests/Request/DemandTests.swift b/Tests/pingxTests/Tests/Request/DemandTests.swift similarity index 100% rename from Tests/pingxTests/Request/DemandTests.swift rename to Tests/pingxTests/Tests/Request/DemandTests.swift diff --git a/Tests/pingxTests/Request/RequestTests.swift b/Tests/pingxTests/Tests/Request/RequestTests.swift similarity index 100% rename from Tests/pingxTests/Request/RequestTests.swift rename to Tests/pingxTests/Tests/Request/RequestTests.swift From d08e3adcaa507e40f41306f05674fcfb617f46ab Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 25 May 2025 11:30:50 +0200 Subject: [PATCH 04/25] Update README.md --- README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 241aae8..686e2a9 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,70 @@ 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") +``` + +### Request + +The Request class represents a single ping request configuration. It encapsulates all necessary information to perform an ICMP ping to a specified IPv4 address. -## Integration +```swift +let destination = IPv4Address(address: (8, 8, 8, 8)) +let request = Request( + destination: destination, // Destination + timeoutInterval: 1500, // 1.5 seconds timeout (1 second by default) + 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 +) +``` -Import the pingx module into your Swift code and initialize the Pinger instance. +### Asynchronous Pinging ```swift import pingx -let pinger = ContinuousPinger() -pinger.delegate = self +let request = Request(destination: destination) -let destination = IPv4Address(address: (8, 8, 8, 8)) -let request = Request(destination: destination, demand: .unlimited) +let pinger = AsyncPinger() +let sequence = pinger.ping(request: request) + +for try await result in sequence { + print("Result: \(result)") +} +``` + +### Callback-based Pinging + +```swift +import pingx + +let request = Request(destination: destination) -pinger.ping(request: request) +let pinger = Pinger() +pinger.ping(request: request) { result in + print("Result: \(result)") +} ``` +## 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). From ff08c466072ffe9bed5ca9ef429ed22fe9082284 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 25 May 2025 12:42:25 +0200 Subject: [PATCH 05/25] Update example --- Example/pingx.xcodeproj/project.pbxproj | 76 +++++++++++++----- Example/pingx/AppDelegate.swift | 7 +- Example/pingx/PingxView.swift | 3 - Example/pingx/PresenterImpl.swift | 29 ------- Example/pingx/ViewController.swift | 77 ------------------- .../Views/AsyncPingView/AsyncPingView.swift | 54 +++++++++++++ .../AsyncPingView/AsyncPingViewModel.swift | 77 +++++++++++++++++++ .../CallbackPingView/CallbackPingView.swift | 55 +++++++++++++ .../CallbackPingViewModel.swift | 77 +++++++++++++++++++ Example/pingx/Views/HomeView/HomeView.swift | 43 +++++++++++ Sources/pingx/Model/Request/Request.swift | 2 +- .../pingx/Pinger/Pingers/AsyncPinger.swift | 2 +- 12 files changed, 368 insertions(+), 134 deletions(-) delete mode 100644 Example/pingx/PingxView.swift delete mode 100644 Example/pingx/PresenterImpl.swift delete mode 100644 Example/pingx/ViewController.swift create mode 100644 Example/pingx/Views/AsyncPingView/AsyncPingView.swift create mode 100644 Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift create mode 100644 Example/pingx/Views/CallbackPingView/CallbackPingView.swift create mode 100644 Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift create mode 100644 Example/pingx/Views/HomeView/HomeView.swift diff --git a/Example/pingx.xcodeproj/project.pbxproj b/Example/pingx.xcodeproj/project.pbxproj index d129c53..ab5cf9b 100644 --- a/Example/pingx.xcodeproj/project.pbxproj +++ b/Example/pingx.xcodeproj/project.pbxproj @@ -7,24 +7,28 @@ objects = { /* Begin PBXBuildFile section */ + 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 */ + 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 = ""; }; - 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; }; @@ -44,6 +48,42 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 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 = ( @@ -65,13 +105,11 @@ 146872502D23313B00373862 /* pingx */ = { isa = PBXGroup; children = ( + 146766482DE31BC200B89B10 /* Views */, 146872472D23313B00373862 /* AppDelegate.swift */, 146872482D23313B00373862 /* Images.xcassets */, 146872492D23313B00373862 /* Info.plist */, 1468724B2D23313B00373862 /* LaunchScreen.xib */, - 1468724C2D23313B00373862 /* PingxView.swift */, - 1468724E2D23313B00373862 /* PresenterImpl.swift */, - 1468724F2D23313B00373862 /* ViewController.swift */, ); path = pingx; sourceTree = ""; @@ -211,10 +249,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 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 */, + 146766522DE31D1000B89B10 /* CallbackPingViewModel.swift in Sources */, + 146766502DE31C0E00B89B10 /* HomeView.swift in Sources */, + 146766552DE322BC00B89B10 /* AsyncPingView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -240,9 +280,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; @@ -252,7 +292,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -276,9 +316,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; @@ -288,7 +328,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Example/pingx/AppDelegate.swift b/Example/pingx/AppDelegate.swift index 8592902..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 = Presenter() - 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/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/PresenterImpl.swift b/Example/pingx/PresenterImpl.swift deleted file mode 100644 index e13ec35..0000000 --- a/Example/pingx/PresenterImpl.swift +++ /dev/null @@ -1,29 +0,0 @@ -import pingx - -// MARK: - Presenter - -final class Presenter { - - // MARK: Constants - - enum Constants { - static var destinationAddress: IPv4Address { IPv4Address(address: (8, 8, 8, 8)) } - } - - // MARK: Properties - - private let pinger: Pinger - - // MARK: Initializer - - init(pinger: Pinger = Pinger()) { - self.pinger = pinger - } - - func didTapSendButton() { - let request = Request(destination: Constants.destinationAddress, demand: .max(1)) - pinger.ping(request: request) { result in - print(result) - } - } -} 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..e79f3a3 --- /dev/null +++ b/Example/pingx/Views/AsyncPingView/AsyncPingView.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 + +struct AsyncPingView: View { + @ObservedObject 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.pingResults.indices, id: \.self) { index in + let result = viewModel.pingResults[index] + + Text("Response: \(result)") + } + } + } + .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..b280b27 --- /dev/null +++ b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift @@ -0,0 +1,77 @@ +// +// 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 + +final class AsyncPingViewModel: ObservableObject { + private enum Constants { + static var destinationAddress: IPv4Address { IPv4Address(address: (8, 8, 8, 8)) } + } + + @Published var isPingActive = false + @Published var pingResults: [PingResult] = [] + + private let pinger: AsyncPingerProtocol + private var pingTask: Task? = nil + + init(pinger: AsyncPingerProtocol) { + self.pinger = pinger + } + + convenience init() { + self.init( + pinger: AsyncPinger() + ) + } + + func startPinging() { + isPingActive = true + pingResults.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 { + DispatchQueue.main.async { [weak self] in + self?.pingResults.append(result) + } + } + + 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..d3557df --- /dev/null +++ b/Example/pingx/Views/CallbackPingView/CallbackPingView.swift @@ -0,0 +1,55 @@ +// +// 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 { + @ObservedObject 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.pingResults.indices, id: \.self) { index in + let result = viewModel.pingResults[index] + + Text("Response: \(result)") + } + } + } + .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..0ba0c7d --- /dev/null +++ b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift @@ -0,0 +1,77 @@ +// +// 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 + +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 pingResults = [PingResult]() + + private let pinger: PingerProtocol + private var activeRequest: Request? + + init(pinger: PingerProtocol) { + self.pinger = pinger + } + + convenience init() { + self.init( + pinger: Pinger() + ) + } + + func startPinging() { + pingResults.removeAll() + + let request = Request( + destination: Constants.destinationAddress, + demand: .max(5) + ) + + isPingActive = true + activeRequest = request + pinger.ping(request: request) { [weak self] result in + DispatchQueue.main.async { + self?.pingResults.append(result) + + if request.demand == .none { + self?.isPingActive = false + } + } + } + } + + func stopPinging() { + isPingActive = false + + guard let activeRequest else { return } + pinger.cancel(request: activeRequest) + + self.activeRequest = nil + } +} diff --git a/Example/pingx/Views/HomeView/HomeView.swift b/Example/pingx/Views/HomeView/HomeView.swift new file mode 100644 index 0000000..9f1d593 --- /dev/null +++ b/Example/pingx/Views/HomeView/HomeView.swift @@ -0,0 +1,43 @@ +// +// 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 { + var body: some View { + NavigationView { + VStack { + NavigationLink("Open callback ping view", destination: CallbackPingView()) + .padding() + + NavigationLink("Open async ping view", destination: AsyncPingView()) + .padding() + } + } + } +} + +#Preview { + HomeView() +} diff --git a/Sources/pingx/Model/Request/Request.swift b/Sources/pingx/Model/Request/Request.swift index 940c1bd..7d78ebc 100644 --- a/Sources/pingx/Model/Request/Request.swift +++ b/Sources/pingx/Model/Request/Request.swift @@ -41,7 +41,7 @@ public final class Request: Identifiable, Hashable { public let timeoutInterval: TimeInterval /// The desired quantity of ping requests to be sent. - private(set) var demand: Request.Demand + public private(set) var demand: Request.Demand /// A sequence number to help in matching Echo and Echo Reply messages. private(set) var sequenceNumber: UInt16 diff --git a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift index 39a0e38..54a6efd 100644 --- a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift +++ b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift @@ -56,7 +56,7 @@ public final class AsyncPinger: AsyncPingerProtocol { self.socketFactory = socketFactory } - convenience init() { + public convenience init() { self.init( icmpHeaderFactory: ICMPHeaderFactory(), icmpPacketExtractor: ICMPPacketExtractor(), From f281239e19d983fc32bd4ca90ac8eea3273bb48a Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sat, 7 Jun 2025 08:44:14 +0200 Subject: [PATCH 06/25] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 686e2a9..76585ca 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,11 @@ let request = Request( destination: destination, // Destination timeoutInterval: 1500, // 1.5 seconds timeout (1 second by default) demand: .max(5) // Send 5 ping requests (default is 1). - // Available options for demand: +) // Available options for demand: // - .none: send no requests // - .max(n): send up to n requests // - .unlimited: send unlimited requests -) + ``` ### Asynchronous Pinging From 75a13a20cc8275e52a32caa5af8162a0c1326606 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 22 Jun 2025 20:50:38 +0200 Subject: [PATCH 07/25] [IMP-3] Update showroom --- Example/pingx.xcodeproj/project.pbxproj | 4 +-- Example/pingx/Views/HomeView/HomeView.swift | 27 +++++++++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/Example/pingx.xcodeproj/project.pbxproj b/Example/pingx.xcodeproj/project.pbxproj index ab5cf9b..57f2ca5 100644 --- a/Example/pingx.xcodeproj/project.pbxproj +++ b/Example/pingx.xcodeproj/project.pbxproj @@ -292,7 +292,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", @@ -328,7 +328,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", diff --git a/Example/pingx/Views/HomeView/HomeView.swift b/Example/pingx/Views/HomeView/HomeView.swift index 9f1d593..3c0207c 100644 --- a/Example/pingx/Views/HomeView/HomeView.swift +++ b/Example/pingx/Views/HomeView/HomeView.swift @@ -25,14 +25,27 @@ import SwiftUI struct HomeView: View { + private enum Destination: Hashable { + case asyncPing + case callbackPing + } + + @State private var navigationPath = NavigationPath() + var body: some View { - NavigationView { - VStack { - NavigationLink("Open callback ping view", destination: CallbackPingView()) - .padding() - - NavigationLink("Open async ping view", destination: AsyncPingView()) - .padding() + 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() + } } } } From 00bbd3d50125457f5fe36b4c72b187ac314190ea Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 22 Jun 2025 21:18:44 +0200 Subject: [PATCH 08/25] [IMP-3] Update request --- .../CallbackPingViewModel.swift | 2 +- .../Factory/SocketFactory/SocketFactory.swift | 6 ++---- Sources/pingx/Model/IP/IPv4/IPv4Address.swift | 2 +- Sources/pingx/Model/Request/Request.swift | 10 +++++----- Sources/pingx/Model/Socket/PingxSocket.swift | 17 +++-------------- Sources/pingx/Pinger/Pingers/AsyncPinger.swift | 12 ++++-------- Sources/pingx/Pinger/Pingers/Pinger.swift | 10 +++++----- .../pingx/Pinger/Sequence/PingSequence.swift | 4 ++-- .../AsyncPinger+AutoMockable.generated.swift | 14 +++++++------- .../Tests/Pinger/AsyncPingerTests.swift | 6 +++--- .../pingxTests/Tests/Request/RequestTests.swift | 4 ++-- 11 files changed, 35 insertions(+), 52 deletions(-) diff --git a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift index 0ba0c7d..04a6b86 100644 --- a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift +++ b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift @@ -70,7 +70,7 @@ final class CallbackPingViewModel: ObservableObject { isPingActive = false guard let activeRequest else { return } - pinger.cancel(request: activeRequest) + pinger.cancel(requestId: activeRequest.id) self.activeRequest = nil } diff --git a/Sources/pingx/Factory/SocketFactory/SocketFactory.swift b/Sources/pingx/Factory/SocketFactory/SocketFactory.swift index b935f27..eee5df6 100644 --- a/Sources/pingx/Factory/SocketFactory/SocketFactory.swift +++ b/Sources/pingx/Factory/SocketFactory/SocketFactory.swift @@ -30,9 +30,7 @@ protocol SocketFactoryProtocol { } final class SocketFactory: SocketFactoryProtocol { - typealias SocketCommand = CommandBlock - - func make(command: SocketCommand) throws -> any PingxSocketProtocol { + func make(command: CommandBlock) throws -> any PingxSocketProtocol { let unmanaged = Unmanaged.passRetained(command) var context = CFSocketContext( version: .zero, @@ -53,7 +51,7 @@ final class SocketFactory: SocketFactoryProtocol { (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) }, diff --git a/Sources/pingx/Model/IP/IPv4/IPv4Address.swift b/Sources/pingx/Model/IP/IPv4/IPv4Address.swift index f034c0f..b41a71e 100644 --- a/Sources/pingx/Model/IP/IPv4/IPv4Address.swift +++ b/Sources/pingx/Model/IP/IPv4/IPv4Address.swift @@ -24,7 +24,7 @@ import Foundation -public struct IPv4Address: Hashable { +public struct IPv4Address: Hashable, Sendable { // MARK: Properties diff --git a/Sources/pingx/Model/Request/Request.swift b/Sources/pingx/Model/Request/Request.swift index 7d78ebc..f8e79dd 100644 --- a/Sources/pingx/Model/Request/Request.swift +++ b/Sources/pingx/Model/Request/Request.swift @@ -24,7 +24,7 @@ import Foundation -public final class Request: Identifiable, Hashable { +public struct Request: Identifiable, Hashable, Sendable { // MARK: Properties @@ -89,15 +89,15 @@ public final class Request: Identifiable, Hashable { hasher.combine(sequenceNumber) } - func setDemand(_ demand: Demand) { + mutating func setDemand(_ demand: Demand) { self.demand = demand } - func decreaseDemand() { + mutating func decreaseDemand() { demand = demand - .max(1) } - func incrementSequenceNumber() { + mutating func incrementSequenceNumber() { let (result, overflow) = sequenceNumber.addingReportingOverflow(1) sequenceNumber = overflow ? .zero : result } @@ -106,7 +106,7 @@ public final class Request: Identifiable, Hashable { // MARK: - Demand public extension Request { - struct Demand: Hashable { + struct Demand: Hashable, Sendable { // MARK: Properties diff --git a/Sources/pingx/Model/Socket/PingxSocket.swift b/Sources/pingx/Model/Socket/PingxSocket.swift index 4270ab3..fb04cf0 100644 --- a/Sources/pingx/Model/Socket/PingxSocket.swift +++ b/Sources/pingx/Model/Socket/PingxSocket.swift @@ -26,34 +26,23 @@ import Foundation // sourcery: AutoMockable protocol PingxSocketProtocol { - - // MARK: Typealias - - associatedtype Instance: AnyObject = CommandBlock - - // MARK: Methods - func send(address: CFData, data: CFData, timeout: CFTimeInterval) -> CFSocketError } -final class PingxSocket: PingxSocketProtocol { - - // MARK: Typealias - - typealias Instance = T +final class PingxSocket: PingxSocketProtocol { // 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 diff --git a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift index 54a6efd..be41630 100644 --- a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift +++ b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift @@ -27,22 +27,18 @@ import Foundation // sourcery: AutoMockable public protocol AsyncPingerProtocol: AnyObject { func ping(request: Request) -> PingSequence - func cancel(request: Request) + func cancel(requestId: Request.ID) } public final class AsyncPinger: AsyncPingerProtocol { - // MARK: Typealias - - private typealias Instance = SocketFactory.SocketCommand - // MARK: Properties + @Atomic private var pingxSocket: (any PingxSocketProtocol)! @Atomic private var completions = [UInt16: (AsyncPingerResult) -> Void]() private let icmpHeaderFactory: ICMPHeaderFactoryProtocol private let icmpPacketExtractor: ICMPPacketExtractorProtocol private let socketFactory: SocketFactoryProtocol - private var pingxSocket: (any PingxSocketProtocol)! // MARK: Initializer @@ -68,8 +64,8 @@ public final class AsyncPinger: AsyncPingerProtocol { PingSequence(request: request, pinger: self) } - public func cancel(request: Request) { - invokeCompletion(identifier: request.id, result: .failure(.cancelled)) + public func cancel(requestId: Request.ID) { + invokeCompletion(identifier: requestId, result: .failure(.cancelled)) } } diff --git a/Sources/pingx/Pinger/Pingers/Pinger.swift b/Sources/pingx/Pinger/Pingers/Pinger.swift index 1214814..39cff5e 100644 --- a/Sources/pingx/Pinger/Pingers/Pinger.swift +++ b/Sources/pingx/Pinger/Pingers/Pinger.swift @@ -24,7 +24,7 @@ public protocol PingerProtocol: AnyObject { func ping(request: Request, completion: @escaping (PingResult) -> Void) - func cancel(request: Request) + func cancel(requestId: Request.ID) } public final class Pinger: PingerProtocol { @@ -58,16 +58,16 @@ public final class Pinger: PingerProtocol { completion(result) } - self?.cancel(request: request) + self?.cancel(requestId: request.id) } activeTasks[request.id] = task } - public func cancel(request: Request) { - asyncPinger.cancel(request: request) + public func cancel(requestId: Request.ID) { + asyncPinger.cancel(requestId: requestId) - let task = activeTasks.removeValue(forKey: request.id) + let task = activeTasks.removeValue(forKey: requestId) task?.cancel() } diff --git a/Sources/pingx/Pinger/Sequence/PingSequence.swift b/Sources/pingx/Pinger/Sequence/PingSequence.swift index cabd8b3..086d131 100644 --- a/Sources/pingx/Pinger/Sequence/PingSequence.swift +++ b/Sources/pingx/Pinger/Sequence/PingSequence.swift @@ -25,7 +25,7 @@ import Foundation public struct PingSequence: AsyncSequence, AsyncIteratorProtocol { - private let request: Request + private var request: Request private let pinger: AsyncPinger init(request: Request, pinger: AsyncPinger) { @@ -60,7 +60,7 @@ public struct PingSequence: AsyncSequence, AsyncIteratorProtocol { defer { taskGroup.cancelAll() - pinger?.cancel(request: request) + pinger?.cancel(requestId: request.id) } return await taskGroup.next() diff --git a/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift index 072558d..6bc4d2e 100644 --- a/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift +++ b/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift @@ -36,15 +36,15 @@ final class AsyncPingerMock: AsyncPingerProtocol { public var cancelCalled: Bool { return cancelCallsCount > 0 } - public var cancelReceivedRequest: (Request)? - public var cancelReceivedInvocations: [(Request)] = [] - public var cancelClosure: ((Request) -> Void)? + public var cancelReceivedRequestId: (Request.ID)? + public var cancelReceivedInvocations: [(Request.ID)] = [] + public var cancelClosure: ((Request.ID) -> Void)? - public func cancel(request: Request) { + public func cancel(requestId: Request.ID) { cancelCallsCount += 1 - cancelReceivedRequest = request - cancelReceivedInvocations.append(request) - cancelClosure?(request) + cancelReceivedRequestId = requestId + cancelReceivedInvocations.append(requestId) + cancelClosure?(requestId) } } diff --git a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift index d4fe6e7..91f7309 100644 --- a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift +++ b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift @@ -287,7 +287,7 @@ struct AsyncPingerTests { ICMPPacket.sample( icmpHeader: .sample( identifier: request.id, - sequenceNumber: request.sequenceNumber + sequenceNumber: UInt16(icmpPacketExtractor.extractCallsCount - 1) ) ) } @@ -321,7 +321,7 @@ struct AsyncPingerTests { actualCallsCount: socket.sendCallsCount, expectedCallsCount: 1 ) - pinger.cancel(request: request) + pinger.cancel(requestId: request.id) }, timeout: 200 ) @@ -340,7 +340,7 @@ struct AsyncPingerTests { actualCallsCount: socket.sendCallsCount, expectedCallsCount: 1 ) - pinger.cancel(request: request) + pinger.cancel(requestId: request.id) await expectToEventuallyNotToBeCalled( actualCallsCount: socket.sendCallsCount, diff --git a/Tests/pingxTests/Tests/Request/RequestTests.swift b/Tests/pingxTests/Tests/Request/RequestTests.swift index 78813eb..0ec6fa8 100644 --- a/Tests/pingxTests/Tests/Request/RequestTests.swift +++ b/Tests/pingxTests/Tests/Request/RequestTests.swift @@ -41,7 +41,7 @@ struct RequestTests { initialDemand: Request.Demand, expectedDemand: Request.Demand ) { - let request = Request.sample(demand: initialDemand) + var request = Request.sample(demand: initialDemand) request.decreaseDemand() @@ -61,7 +61,7 @@ struct RequestTests { initialSequenceNumber: UInt16, expectedSequenceNumber: UInt16 ) { - let request = Request.sample(sequenceNumber: initialSequenceNumber) + var request = Request.sample(sequenceNumber: initialSequenceNumber) request.incrementSequenceNumber() From 5e5749641a3b8dd2f17b149bce16b34e415df8a3 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 22 Jun 2025 22:02:45 +0200 Subject: [PATCH 09/25] [IMP-3] Add configuration --- .../AsyncPingView/AsyncPingViewModel.swift | 4 +- .../CallbackPingViewModel.swift | 4 +- Sources/pingx/Model/Interval/Interval.swift | 64 +++++++++++++++++ Sources/pingx/Model/Request/Request.swift | 8 +-- .../Configuration/PingConfiguration.swift | 39 +++++++++++ .../pingx/Pinger/Pingers/AsyncPinger.swift | 12 +++- Sources/pingx/Pinger/Pingers/Pinger.swift | 8 ++- .../pingx/Pinger/Sequence/PingSequence.swift | 68 ++++++++++++------- .../Extenstions/PingSequence+Assertions.swift | 6 +- Tests/pingxTests/Samples/Request+Sample.swift | 2 +- .../Tests/Pinger/AsyncPingerTests.swift | 56 ++++++++++++--- 11 files changed, 224 insertions(+), 47 deletions(-) create mode 100644 Sources/pingx/Model/Interval/Interval.swift create mode 100644 Sources/pingx/Pinger/Configuration/PingConfiguration.swift diff --git a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift index b280b27..221958f 100644 --- a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift +++ b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift @@ -41,7 +41,9 @@ final class AsyncPingViewModel: ObservableObject { convenience init() { self.init( - pinger: AsyncPinger() + pinger: AsyncPinger( + configuration: PingConfiguration(intervalBetweenRequests: .milliseconds(500)) + ) ) } diff --git a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift index 04a6b86..ba4c818 100644 --- a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift +++ b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift @@ -41,7 +41,9 @@ final class CallbackPingViewModel: ObservableObject { convenience init() { self.init( - pinger: Pinger() + pinger: Pinger( + configuration: PingConfiguration(intervalBetweenRequests: .milliseconds(500)) + ) ) } diff --git a/Sources/pingx/Model/Interval/Interval.swift b/Sources/pingx/Model/Interval/Interval.swift new file mode 100644 index 0000000..2a0d3b3 --- /dev/null +++ b/Sources/pingx/Model/Interval/Interval.swift @@ -0,0 +1,64 @@ +// +// 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 enum Interval: Sendable, Hashable { + case seconds(TimeInterval) + case milliseconds(TimeInterval) + case nanoseconds(TimeInterval) + + 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 + } + } + + 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 + } + } + + 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/Request/Request.swift b/Sources/pingx/Model/Request/Request.swift index f8e79dd..ab39323 100644 --- a/Sources/pingx/Model/Request/Request.swift +++ b/Sources/pingx/Model/Request/Request.swift @@ -37,8 +37,8 @@ public struct Request: Identifiable, Hashable, Sendable { /// The destination IP. public let destination: IPv4Address - /// Timeout interval (in milliseconds). - public let timeoutInterval: TimeInterval + /// Timeout interval. + public let timeoutInterval: Interval /// The desired quantity of ping requests to be sent. public private(set) var demand: Request.Demand @@ -50,7 +50,7 @@ public struct Request: Identifiable, Hashable, Sendable { public init( destination: IPv4Address, - timeoutInterval: TimeInterval = 1000, + timeoutInterval: Interval = .seconds(1), demand: Request.Demand = .max(1) ) { self.id = CFSwapInt16HostToBig(UInt16.random(in: 0.. Void]() + private let configuration: PingConfiguration private let icmpHeaderFactory: ICMPHeaderFactoryProtocol private let icmpPacketExtractor: ICMPPacketExtractorProtocol private let socketFactory: SocketFactoryProtocol @@ -43,17 +44,22 @@ public final class AsyncPinger: AsyncPingerProtocol { // 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() { + public convenience init( + configuration: PingConfiguration = .default + ) { self.init( + configuration: configuration, icmpHeaderFactory: ICMPHeaderFactory(), icmpPacketExtractor: ICMPPacketExtractor(), socketFactory: SocketFactory() @@ -61,7 +67,7 @@ public final class AsyncPinger: AsyncPingerProtocol { } public func ping(request: Request) -> PingSequence { - PingSequence(request: request, pinger: self) + PingSequence(configuration: configuration, pinger: self, request: request) } public func cancel(requestId: Request.ID) { @@ -99,7 +105,7 @@ extension AsyncPinger { let cfSocketError = pingxSocket.send( address: request.destination.socketAddress as CFData, data: packet.data as CFData, - timeout: request.timeoutInterval + timeout: request.timeoutInterval.milliseconds ) if let error = cfSocketError.mapToPingerError() { diff --git a/Sources/pingx/Pinger/Pingers/Pinger.swift b/Sources/pingx/Pinger/Pingers/Pinger.swift index 39cff5e..4381006 100644 --- a/Sources/pingx/Pinger/Pingers/Pinger.swift +++ b/Sources/pingx/Pinger/Pingers/Pinger.swift @@ -28,16 +28,18 @@ public protocol PingerProtocol: AnyObject { } public final class Pinger: PingerProtocol { - private let asyncPinger: AsyncPingerProtocol @Atomic private var activeTasks: [UInt16: Task] = [:] + private let asyncPinger: AsyncPingerProtocol init(asyncPinger: AsyncPingerProtocol) { self.asyncPinger = asyncPinger } - public convenience init() { + public convenience init( + configuration: PingConfiguration = .default + ) { self.init( - asyncPinger: AsyncPinger() + asyncPinger: AsyncPinger(configuration: configuration) ) } diff --git a/Sources/pingx/Pinger/Sequence/PingSequence.swift b/Sources/pingx/Pinger/Sequence/PingSequence.swift index 086d131..267e20e 100644 --- a/Sources/pingx/Pinger/Sequence/PingSequence.swift +++ b/Sources/pingx/Pinger/Sequence/PingSequence.swift @@ -25,26 +25,52 @@ import Foundation public struct PingSequence: AsyncSequence, AsyncIteratorProtocol { - private var request: Request + private let configuration: PingConfiguration private let pinger: AsyncPinger + private var request: Request + private var shouldDelayNextPing = false - init(request: Request, pinger: AsyncPinger) { - self.request = request + init( + configuration: PingConfiguration, + pinger: AsyncPinger, + request: Request + ) { + self.configuration = configuration self.pinger = pinger + self.request = request } public mutating func next() async throws -> PingResult? { guard request.demand != .none else { return nil } - try Task.checkCancellation() + + if shouldDelayNextPing { + try await Task.sleep(nanoseconds: UInt64(configuration.intervalBetweenRequests.nanoseconds)) + try Task.checkCancellation() + } else { + shouldDelayNextPing = true + } + + let result = await performPingWithTimeout() + + request.decreaseDemand() + request.incrementSequenceNumber() + + if case .cancelled = result?.error { + request.setDemand(.none) + } - let result = await withTaskGroup( + return result?.mapToPingResult() + } + + private func performPingWithTimeout() async -> AsyncPingerResult? { + await withTaskGroup( of: AsyncPingerResult.self, returning: Optional.self ) { [weak pinger, request] taskGroup in taskGroup.addTask { do { - try await Task.sleep(nanoseconds: UInt64(request.timeoutInterval * 1_000_000)) + try await Task.sleep(nanoseconds: UInt64(request.timeoutInterval.nanoseconds)) } catch {} return .failure(.timeout) @@ -65,28 +91,24 @@ public struct PingSequence: AsyncSequence, AsyncIteratorProtocol { return await taskGroup.next() } - - request.decreaseDemand() - request.incrementSequenceNumber() - - if case .cancelled = result?.error { - request.setDemand(.none) - } - - return result? - .map { icmpPacket in - Response( - destination: icmpPacket.ipHeader.sourceAddress, - duration: (CFAbsoluteTimeGetCurrent() - icmpPacket.icmpHeader.payload.timestamp) * 1000, - sequenceNumber: icmpPacket.icmpHeader.sequenceNumber - ) - } - .mapError { $0.mapToPingError() } } public func makeAsyncIterator() -> PingSequence { self } } +private extension AsyncPingerResult { + func mapToPingResult() -> PingResult { + map { icmpPacket in + Response( + destination: icmpPacket.ipHeader.sourceAddress, + duration: (CFAbsoluteTimeGetCurrent() - icmpPacket.icmpHeader.payload.timestamp) * 1000, + sequenceNumber: icmpPacket.icmpHeader.sequenceNumber + ) + } + .mapError { $0.mapToPingError() } + } +} + private extension AsyncPingerError { func mapToPingError() -> PingError { switch self { diff --git a/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift b/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift index 3fb743c..6cafefc 100644 --- a/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift +++ b/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift @@ -35,7 +35,7 @@ import Foundation @discardableResult func collectValuesFromPingSequence( sequence: PingSequence, count: Int = .max, - timeout: TimeInterval = 50 + timeout: Interval = .milliseconds(50) ) async throws -> [PingResult] { try await withThrowingTaskGroup( of: [PingResult].self, @@ -52,7 +52,7 @@ import Foundation } taskGroup.addTask { - try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000)) + try await Task.sleep(nanoseconds: UInt64(timeout.nanoseconds)) return [] } @@ -64,7 +64,7 @@ import Foundation func observerPingSequenceWithoutReturningResult( sequence: PingSequence, - timeout: TimeInterval = 50 + timeout: Interval = .milliseconds(50) ) async throws { try await collectValuesFromPingSequence( sequence: sequence, diff --git a/Tests/pingxTests/Samples/Request+Sample.swift b/Tests/pingxTests/Samples/Request+Sample.swift index 3441142..f88cffa 100644 --- a/Tests/pingxTests/Samples/Request+Sample.swift +++ b/Tests/pingxTests/Samples/Request+Sample.swift @@ -30,7 +30,7 @@ extension Request { static func sample( id: Request.ID = .zero, destination: IPv4Address = .sample(), - timeoutInterval: TimeInterval = 1000, + timeoutInterval: Interval = .seconds(1), demand: Demand = .max(1), sequenceNumber: UInt16 = .zero ) -> Request { diff --git a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift index 91f7309..8166a65 100644 --- a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift +++ b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift @@ -34,7 +34,7 @@ struct AsyncPingerTests { private let icmpHeaderFactory: ICMPHeaderFactoryMock private let icmpPacketExtractor: ICMPPacketExtractorMock private let socketFactory: SocketFactoryMock - private let pinger: AsyncPinger + private var pinger: AsyncPinger init() { self.socket = PingxSocketMock() @@ -50,6 +50,7 @@ struct AsyncPingerTests { self.socketFactory.makeReturnValue = socket self.pinger = AsyncPinger( + configuration: .default, icmpHeaderFactory: icmpHeaderFactory, icmpPacketExtractor: icmpPacketExtractor, socketFactory: socketFactory @@ -118,7 +119,7 @@ struct AsyncPingerTests { #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) + #expect(socket.sendReceivedArguments?.timeout == request.timeoutInterval.milliseconds) } @Test("When request failed, it emits pingError.internalError") @@ -249,7 +250,7 @@ struct AsyncPingerTests { @Test("When request timed out, it emits pingerError.timeout") func send_whenRequestIsTimedOut_emitsTimedOutError() async throws { - let request = Request.sample(timeoutInterval: 1) + let request = Request.sample(timeoutInterval: .milliseconds(1)) let sequence = pinger.ping(request: request) @@ -304,7 +305,46 @@ struct AsyncPingerTests { socketFactory.makeReceivedCommand?.closure(Data()) } }, - timeout: 200 + timeout: .milliseconds(200) + ) + } + + @Test("When interval between requests is greater than 0, doesn't call the second ping immediately") + func send_whenIntervalBetweenRequestsIsGreaterThanZero_doesNotCallSendBeforeIntervalIsReached() async throws { + let pinger = AsyncPinger( + configuration: PingConfiguration(intervalBetweenRequests: .milliseconds(100)), + icmpHeaderFactory: icmpHeaderFactory, + icmpPacketExtractor: icmpPacketExtractor, + socketFactory: socketFactory + ) + let request = Request.sample(demand: .unlimited) + 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, + expectedCallsCount: 1 + ) + socketFactory.makeReceivedCommand?.closure(Data()) + + await expectToEventuallyNotToBeCalled( + actualCallsCount: socket.sendCallsCount, + expectedCallsCount: 2, + timeout: 0.1 + ) + await expectToEventuallyBeCalled( + actualCallsCount: socket.sendCallsCount, + expectedCallsCount: 2 + ) + }, + timeout: .milliseconds(200) ) } @@ -323,7 +363,7 @@ struct AsyncPingerTests { ) pinger.cancel(requestId: request.id) }, - timeout: 200 + timeout: .milliseconds(200) ) } @@ -347,7 +387,7 @@ struct AsyncPingerTests { expectedCallsCount: 2 ) }, - timeout: 200 + timeout: .milliseconds(200) ) } } @@ -357,7 +397,7 @@ private extension AsyncPingerTests { sequence: PingSequence, emits expectedValues: [PingResult], after operation: (() async -> Void)? = nil, - timeout: TimeInterval = 50, + timeout: Interval = .milliseconds(50), sourceLocation: SourceLocation = #_sourceLocation ) async throws { let expectation = XCTestExpectation( @@ -382,7 +422,7 @@ private extension AsyncPingerTests { let result = await XCTWaiter.fulfillment( of: [expectation], - timeout: timeout * 1000 + timeout: timeout.seconds ) #expect(result == .completed) } From 9ba68799419f1b2f6415084ec7ff1ceef98ec633 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sat, 28 Jun 2025 12:32:48 +0200 Subject: [PATCH 10/25] [IMP-3] Update tests --- .../pingx/Pinger/Sequence/PingSequence.swift | 6 +- .../Extenstions/Assertion+Extensions.swift | 11 +-- .../Extenstions/PingSequence+Assertions.swift | 12 +-- .../Tests/Pinger/AsyncPingerTests.swift | 97 +++++++------------ 4 files changed, 47 insertions(+), 79 deletions(-) diff --git a/Sources/pingx/Pinger/Sequence/PingSequence.swift b/Sources/pingx/Pinger/Sequence/PingSequence.swift index 267e20e..98dbbf1 100644 --- a/Sources/pingx/Pinger/Sequence/PingSequence.swift +++ b/Sources/pingx/Pinger/Sequence/PingSequence.swift @@ -53,11 +53,11 @@ public struct PingSequence: AsyncSequence, AsyncIteratorProtocol { let result = await performPingWithTimeout() - request.decreaseDemand() - request.incrementSequenceNumber() - if case .cancelled = result?.error { request.setDemand(.none) + } else { + request.decreaseDemand() + request.incrementSequenceNumber() } return result?.mapToPingResult() diff --git a/Tests/pingxTests/Extenstions/Assertion+Extensions.swift b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift index 9945ee4..5f293ed 100644 --- a/Tests/pingxTests/Extenstions/Assertion+Extensions.swift +++ b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift @@ -35,9 +35,8 @@ func expectToEventuallyBeCalled( let task = Task { while actualCallsCount() < expectedCallsCount { - if Task.isCancelled { return } - - try? await Task.sleep(nanoseconds: 30_000_000) // 30ms + try Task.checkCancellation() + try? await Task.sleep(nanoseconds: 20_000_000) // 20ms } expectation.fulfill() @@ -48,7 +47,7 @@ func expectToEventuallyBeCalled( #expect( result == .completed, - .__block("Wait for actualCallsCount to reach \(expectedCallsCount)"), + "Wait for actualCallsCount to reach \(expectedCallsCount)", sourceLocation: sourceLocation ) } @@ -66,7 +65,7 @@ func expectToEventuallyNotToBeCalled( while actualCallsCount() < expectedCallsCount { if Task.isCancelled { return } - try? await Task.sleep(nanoseconds: 30_000_000) // 30ms + try? await Task.sleep(nanoseconds: 20_000_000) // 20ms } expectation.fulfill() @@ -77,7 +76,7 @@ func expectToEventuallyNotToBeCalled( #expect( result == .completed, - .__block("Wait for actualCallsCount to not reach \(expectedCallsCount)"), + "Wait for actualCallsCount to not reach \(expectedCallsCount)", sourceLocation: sourceLocation ) } diff --git a/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift b/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift index 6cafefc..7979c4a 100644 --- a/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift +++ b/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift @@ -38,27 +38,25 @@ import Foundation timeout: Interval = .milliseconds(50) ) async throws -> [PingResult] { try await withThrowingTaskGroup( - of: [PingResult].self, + of: Void.self, returning: [PingResult].self ) { taskGroup in - taskGroup.addTask { - var values: [PingResult] = [] + var values: [PingResult] = [] + taskGroup.addTask { for try await value in sequence where values.count < count { values.append(value) } - - return values } taskGroup.addTask { try await Task.sleep(nanoseconds: UInt64(timeout.nanoseconds)) - return [] } defer { taskGroup.cancelAll() } - return try await taskGroup.next().unsafelyUnwrapped + _ = try await taskGroup.next() + return values } } diff --git a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift index 8166a65..74a69ca 100644 --- a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift +++ b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift @@ -34,7 +34,7 @@ struct AsyncPingerTests { private let icmpHeaderFactory: ICMPHeaderFactoryMock private let icmpPacketExtractor: ICMPPacketExtractorMock private let socketFactory: SocketFactoryMock - private var pinger: AsyncPinger + private var pinger: AsyncPinger! init() { self.socket = PingxSocketMock() @@ -49,8 +49,14 @@ struct AsyncPingerTests { self.socketFactory = SocketFactoryMock() self.socketFactory.makeReturnValue = socket - self.pinger = AsyncPinger( - configuration: .default, + self.pinger = makeAsyncPinger() + } + + private func makeAsyncPinger( + configuration: PingConfiguration = PingConfiguration(intervalBetweenRequests: .milliseconds(0)) + ) -> AsyncPinger { + AsyncPinger( + configuration: configuration, icmpHeaderFactory: icmpHeaderFactory, icmpPacketExtractor: icmpPacketExtractor, socketFactory: socketFactory @@ -305,17 +311,14 @@ struct AsyncPingerTests { socketFactory.makeReceivedCommand?.closure(Data()) } }, - timeout: .milliseconds(200) + timeout: .milliseconds(150) ) } @Test("When interval between requests is greater than 0, doesn't call the second ping immediately") - func send_whenIntervalBetweenRequestsIsGreaterThanZero_doesNotCallSendBeforeIntervalIsReached() async throws { - let pinger = AsyncPinger( - configuration: PingConfiguration(intervalBetweenRequests: .milliseconds(100)), - icmpHeaderFactory: icmpHeaderFactory, - icmpPacketExtractor: icmpPacketExtractor, - socketFactory: socketFactory + mutating func send_whenRequestIntervalIsGreaterThanZero_doesNotCallSendBeforeIntervalIsReached() async throws { + pinger = makeAsyncPinger( + configuration: PingConfiguration(intervalBetweenRequests: .milliseconds(50)) ) let request = Request.sample(demand: .unlimited) let sequence = pinger.ping(request: request) @@ -327,7 +330,7 @@ struct AsyncPingerTests { try await checkThat( sequence: sequence, emits: [.success(response)], - after: { + after: { [self] in await expectToEventuallyBeCalled( actualCallsCount: socket.sendCallsCount, expectedCallsCount: 1 @@ -337,14 +340,14 @@ struct AsyncPingerTests { await expectToEventuallyNotToBeCalled( actualCallsCount: socket.sendCallsCount, expectedCallsCount: 2, - timeout: 0.1 + timeout: 0.05 ) await expectToEventuallyBeCalled( actualCallsCount: socket.sendCallsCount, expectedCallsCount: 2 ) }, - timeout: .milliseconds(200) + timeout: .milliseconds(100) ) } @@ -362,8 +365,7 @@ struct AsyncPingerTests { expectedCallsCount: 1 ) pinger.cancel(requestId: request.id) - }, - timeout: .milliseconds(200) + } ) } @@ -386,8 +388,7 @@ struct AsyncPingerTests { actualCallsCount: socket.sendCallsCount, expectedCallsCount: 2 ) - }, - timeout: .milliseconds(200) + } ) } } @@ -400,64 +401,34 @@ private extension AsyncPingerTests { timeout: Interval = .milliseconds(50), sourceLocation: SourceLocation = #_sourceLocation ) async throws { - let expectation = XCTestExpectation( - description: "Wait for expectedValues to be equal to the values emitted by the sequence" - ) - - Task { - let values = try await collectValuesFromPingSequence(sequence: sequence, timeout: timeout) - - for (actualValue, expectedValue) in zip(values, expectedValues) { - PingResult.beEqual( - actualValue: actualValue, - expectedValue: expectedValue, - sourceLocation: sourceLocation - ) - } - - expectation.fulfill() + let collectingValuesTask = Task { + return try await collectValuesFromPingSequence(sequence: sequence, timeout: timeout) } await operation?() - let result = await XCTWaiter.fulfillment( - of: [expectation], - timeout: timeout.seconds + let values = try await collectingValuesTask.value + let areValuesEqualToExpectedValues = values.elementsEqual(expectedValues, by: { $0.equals($1) }) + + try #require( + areValuesEqualToExpectedValues, + "Expected: \(expectedValues), Got: \(values)", + sourceLocation: sourceLocation ) - #expect(result == .completed) } } private extension PingResult { - static func beEqual( - actualValue: PingResult, - expectedValue: PingResult, - sourceLocation: SourceLocation = #_sourceLocation - ) { - switch (actualValue, expectedValue) { + func equals(_ rhs: PingResult) -> Bool { + switch (self, rhs) { case (.success(let lValue), .success(let rValue)): - #expect( - lValue.destination == rValue.destination, - sourceLocation: sourceLocation - ) - #expect( - lValue.sequenceNumber == rValue.sequenceNumber, - sourceLocation: sourceLocation - ) + return lValue.destination == rValue.destination && + lValue.sequenceNumber == rValue.sequenceNumber case (.failure(let lError), .failure(let rError)): - #expect( - lError.errorCode == rError.errorCode, - sourceLocation: sourceLocation - ) - #expect( - lError.underlyingError?.errorCode == rError.underlyingError?.errorCode, - sourceLocation: sourceLocation - ) + return lError.errorCode == rError.errorCode && + lError.underlyingError?.errorCode == rError.underlyingError?.errorCode default: - #expect( - Bool(false), - sourceLocation: sourceLocation - ) + return false } } } From a2f729b697e4dcd308245efa02bfe4c7eb7b1865 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sat, 28 Jun 2025 12:56:58 +0200 Subject: [PATCH 11/25] [IMP-3] Update default configuration --- Example/pingx.xcodeproj/project.pbxproj | 12 +++++ .../Views/AsyncPingView/AsyncPingView.swift | 7 ++- .../AsyncPingView/AsyncPingViewModel.swift | 11 ++--- .../CallbackPingView/CallbackPingView.swift | 7 ++- .../CallbackPingViewModel.swift | 11 ++--- .../PingResultDisplayModel.swift | 47 +++++++++++++++++++ Sources/pingx/Model/Interval/Interval.swift | 6 +-- Sources/pingx/Model/Response/Response.swift | 4 +- .../Configuration/PingConfiguration.swift | 2 +- .../pingx/Pinger/Sequence/PingSequence.swift | 2 +- .../pingxTests/Samples/Response+Sample.swift | 2 +- 11 files changed, 83 insertions(+), 28 deletions(-) create mode 100644 Example/pingx/Views/DisplayModels/PingResultDisplayModel.swift diff --git a/Example/pingx.xcodeproj/project.pbxproj b/Example/pingx.xcodeproj/project.pbxproj index 57f2ca5..821bf2e 100644 --- a/Example/pingx.xcodeproj/project.pbxproj +++ b/Example/pingx.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 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 */; }; @@ -19,6 +20,7 @@ /* 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 = ""; }; @@ -48,9 +50,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 145E71752E0FFF5400F92C01 /* DisplayModels */ = { + isa = PBXGroup; + children = ( + 145E71762E0FFF5D00F92C01 /* PingResultDisplayModel.swift */, + ); + path = DisplayModels; + sourceTree = ""; + }; 146766482DE31BC200B89B10 /* Views */ = { isa = PBXGroup; children = ( + 145E71752E0FFF5400F92C01 /* DisplayModels */, 146766532DE322AE00B89B10 /* AsyncPingView */, 1467664A2DE31BD400B89B10 /* HomeView */, 146766492DE31BCD00B89B10 /* CallbackPingView */, @@ -252,6 +263,7 @@ 1467664E2DE31BFD00B89B10 /* CallbackPingView.swift in Sources */, 146766572DE322C200B89B10 /* AsyncPingViewModel.swift in Sources */, 146872522D23313B00373862 /* AppDelegate.swift in Sources */, + 145E71772E0FFF6100F92C01 /* PingResultDisplayModel.swift in Sources */, 146766522DE31D1000B89B10 /* CallbackPingViewModel.swift in Sources */, 146766502DE31C0E00B89B10 /* HomeView.swift in Sources */, 146766552DE322BC00B89B10 /* AsyncPingView.swift in Sources */, diff --git a/Example/pingx/Views/AsyncPingView/AsyncPingView.swift b/Example/pingx/Views/AsyncPingView/AsyncPingView.swift index e79f3a3..de5b964 100644 --- a/Example/pingx/Views/AsyncPingView/AsyncPingView.swift +++ b/Example/pingx/Views/AsyncPingView/AsyncPingView.swift @@ -36,10 +36,9 @@ struct AsyncPingView: View { } List { - ForEach(viewModel.pingResults.indices, id: \.self) { index in - let result = viewModel.pingResults[index] - - Text("Response: \(result)") + ForEach(viewModel.pingResultDisplayModels.indices, id: \.self) { index in + let displayModel = viewModel.pingResultDisplayModels[index] + Text(displayModel.makeUserFriendlyMessage()) } } } diff --git a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift index 221958f..f77b6c2 100644 --- a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift +++ b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift @@ -30,7 +30,7 @@ final class AsyncPingViewModel: ObservableObject { } @Published var isPingActive = false - @Published var pingResults: [PingResult] = [] + @Published var pingResultDisplayModels: [PingResultDisplayModel] = [] private let pinger: AsyncPingerProtocol private var pingTask: Task? = nil @@ -41,15 +41,13 @@ final class AsyncPingViewModel: ObservableObject { convenience init() { self.init( - pinger: AsyncPinger( - configuration: PingConfiguration(intervalBetweenRequests: .milliseconds(500)) - ) + pinger: AsyncPinger(configuration: .default) ) } func startPinging() { isPingActive = true - pingResults.removeAll() + pingResultDisplayModels.removeAll() let request = Request( destination: Constants.destinationAddress, @@ -61,7 +59,8 @@ final class AsyncPingViewModel: ObservableObject { for try await result in sequence { DispatchQueue.main.async { [weak self] in - self?.pingResults.append(result) + let displayModel = PingResultDisplayModel(pingResult: result) + self?.pingResultDisplayModels.append(displayModel) } } diff --git a/Example/pingx/Views/CallbackPingView/CallbackPingView.swift b/Example/pingx/Views/CallbackPingView/CallbackPingView.swift index d3557df..98a4941 100644 --- a/Example/pingx/Views/CallbackPingView/CallbackPingView.swift +++ b/Example/pingx/Views/CallbackPingView/CallbackPingView.swift @@ -37,10 +37,9 @@ struct CallbackPingView: View { } List { - ForEach(viewModel.pingResults.indices, id: \.self) { index in - let result = viewModel.pingResults[index] - - Text("Response: \(result)") + ForEach(viewModel.pingResultDisplayModels.indices, id: \.self) { index in + let displayModel = viewModel.pingResultDisplayModels[index] + Text(displayModel.makeUserFriendlyMessage()) } } } diff --git a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift index ba4c818..44bc57c 100644 --- a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift +++ b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift @@ -30,7 +30,7 @@ final class CallbackPingViewModel: ObservableObject { } @Published private(set) var isPingActive = false - @Published private(set) var pingResults = [PingResult]() + @Published private(set) var pingResultDisplayModels = [PingResultDisplayModel]() private let pinger: PingerProtocol private var activeRequest: Request? @@ -41,14 +41,12 @@ final class CallbackPingViewModel: ObservableObject { convenience init() { self.init( - pinger: Pinger( - configuration: PingConfiguration(intervalBetweenRequests: .milliseconds(500)) - ) + pinger: Pinger(configuration: .default) ) } func startPinging() { - pingResults.removeAll() + pingResultDisplayModels.removeAll() let request = Request( destination: Constants.destinationAddress, @@ -59,7 +57,8 @@ final class CallbackPingViewModel: ObservableObject { activeRequest = request pinger.ping(request: request) { [weak self] result in DispatchQueue.main.async { - self?.pingResults.append(result) + let displayModel = PingResultDisplayModel(pingResult: result) + self?.pingResultDisplayModels.append(displayModel) if request.demand == .none { self?.isPingActive = false diff --git a/Example/pingx/Views/DisplayModels/PingResultDisplayModel.swift b/Example/pingx/Views/DisplayModels/PingResultDisplayModel.swift new file mode 100644 index 0000000..b9d02bd --- /dev/null +++ b/Example/pingx/Views/DisplayModels/PingResultDisplayModel.swift @@ -0,0 +1,47 @@ +// +// 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 + +struct PingResultDisplayModel { + private let pingResult: PingResult + + init(pingResult: PingResult) { + self.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/Sources/pingx/Model/Interval/Interval.swift b/Sources/pingx/Model/Interval/Interval.swift index 2a0d3b3..8a03e9f 100644 --- a/Sources/pingx/Model/Interval/Interval.swift +++ b/Sources/pingx/Model/Interval/Interval.swift @@ -29,7 +29,7 @@ public enum Interval: Sendable, Hashable { case milliseconds(TimeInterval) case nanoseconds(TimeInterval) - var seconds: TimeInterval { + public var seconds: TimeInterval { switch self { case .seconds(let value): return value @@ -40,7 +40,7 @@ public enum Interval: Sendable, Hashable { } } - var milliseconds: TimeInterval { + public var milliseconds: TimeInterval { switch self { case .seconds(let value): return value * 1_000 @@ -51,7 +51,7 @@ public enum Interval: Sendable, Hashable { } } - var nanoseconds: TimeInterval { + public var nanoseconds: TimeInterval { switch self { case .seconds(let value): return value * 1_000_000_000 diff --git a/Sources/pingx/Model/Response/Response.swift b/Sources/pingx/Model/Response/Response.swift index 04bae3f..ca18aba 100644 --- a/Sources/pingx/Model/Response/Response.swift +++ b/Sources/pingx/Model/Response/Response.swift @@ -31,8 +31,8 @@ public struct Response { /// 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/Pinger/Configuration/PingConfiguration.swift b/Sources/pingx/Pinger/Configuration/PingConfiguration.swift index 1d373a6..c2e11b1 100644 --- a/Sources/pingx/Pinger/Configuration/PingConfiguration.swift +++ b/Sources/pingx/Pinger/Configuration/PingConfiguration.swift @@ -34,6 +34,6 @@ public struct PingConfiguration { public extension PingConfiguration { static var `default`: PingConfiguration { - PingConfiguration(intervalBetweenRequests: .milliseconds(0)) + PingConfiguration(intervalBetweenRequests: .seconds(1)) } } diff --git a/Sources/pingx/Pinger/Sequence/PingSequence.swift b/Sources/pingx/Pinger/Sequence/PingSequence.swift index 98dbbf1..66e910c 100644 --- a/Sources/pingx/Pinger/Sequence/PingSequence.swift +++ b/Sources/pingx/Pinger/Sequence/PingSequence.swift @@ -101,7 +101,7 @@ private extension AsyncPingerResult { map { icmpPacket in Response( destination: icmpPacket.ipHeader.sourceAddress, - duration: (CFAbsoluteTimeGetCurrent() - icmpPacket.icmpHeader.payload.timestamp) * 1000, + duration: .seconds(CFAbsoluteTimeGetCurrent() - icmpPacket.icmpHeader.payload.timestamp), sequenceNumber: icmpPacket.icmpHeader.sequenceNumber ) } diff --git a/Tests/pingxTests/Samples/Response+Sample.swift b/Tests/pingxTests/Samples/Response+Sample.swift index bd6da0b..e9b86ff 100644 --- a/Tests/pingxTests/Samples/Response+Sample.swift +++ b/Tests/pingxTests/Samples/Response+Sample.swift @@ -29,7 +29,7 @@ import Foundation extension Response { static func sample( destination: IPv4Address = .sample(), - duration: TimeInterval = .zero, + duration: Interval = .milliseconds(.zero), sequenceNumber: UInt16 = .zero ) -> Response { Response( From a9b0c9ca8141526bdb4ac8f3cb7700cc43b84cac Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sat, 28 Jun 2025 17:51:21 +0200 Subject: [PATCH 12/25] [IMP-3] Update tests and add request identifier --- .../CallbackPingViewModel.swift | 2 +- Sources/pingx/Checksum/ICMPChecksum.swift | 13 +- .../ICMPHeaderFactory/ICMPHeaderFactory.swift | 13 +- .../pingx/Model/Packets/ICMP/ICMPHeader.swift | 2 +- Sources/pingx/Model/Payload/Payload.swift | 36 +---- Sources/pingx/Model/Request/Request.swift | 26 +++- .../Error/ICMPResponseValidationError.swift | 4 +- .../Pinger/Helpers/ICMPPackageExtractor.swift | 15 -- .../pingx/Pinger/Pingers/AsyncPinger.swift | 47 +++++-- Sources/pingx/Pinger/Pingers/Pinger.swift | 14 +- .../Pinger/Sequence/AnyPingSequence.swift | 57 ++++++++ .../pingx/Pinger/Sequence/PingSequence.swift | 11 +- .../Extenstions/Assertion+Extensions.swift | 70 ++++++++-- .../Extenstions/PingResult+Extensions.swift | 40 ++++++ .../Extenstions/PingSequence+Assertions.swift | 4 +- .../AsyncPinger+AutoMockable.generated.swift | 14 +- ...HeaderFactory+AutoMockable.generated.swift | 14 +- Tests/pingxTests/Mocks/MockPingSequence.swift | 71 ++++++++++ .../Samples/ICMPHeader+Sample.swift | 2 +- Tests/pingxTests/Samples/Payload+Sample.swift | 22 ++- Tests/pingxTests/Samples/Request+Sample.swift | 20 ++- .../ICMPChecksum/ICMPChecksumTests.swift | 4 +- .../ICMPPackageExtractorTests.swift | 28 ++-- .../Tests/Pinger/AsyncPingerTests.swift | 41 ++---- .../pingxTests/Tests/Pinger/PingerTests.swift | 131 ++++++++++++++++++ 25 files changed, 522 insertions(+), 179 deletions(-) create mode 100644 Sources/pingx/Pinger/Sequence/AnyPingSequence.swift create mode 100644 Tests/pingxTests/Extenstions/PingResult+Extensions.swift create mode 100644 Tests/pingxTests/Mocks/MockPingSequence.swift create mode 100644 Tests/pingxTests/Tests/Pinger/PingerTests.swift diff --git a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift index 44bc57c..cf1f4c0 100644 --- a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift +++ b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift @@ -71,7 +71,7 @@ final class CallbackPingViewModel: ObservableObject { isPingActive = false guard let activeRequest else { return } - pinger.cancel(requestId: activeRequest.id) + pinger.cancel(requestId: activeRequest.identifier) self.activeRequest = nil } diff --git a/Sources/pingx/Checksum/ICMPChecksum.swift b/Sources/pingx/Checksum/ICMPChecksum.swift index 33bc8df..52da256 100644 --- a/Sources/pingx/Checksum/ICMPChecksum.swift +++ b/Sources/pingx/Checksum/ICMPChecksum.swift @@ -51,15 +51,12 @@ extension ICMPChecksum { 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 + var bytes: [UInt8] = [] + + withUnsafeBytes(of: payload.identifier) { bytes.append(contentsOf: $0) } + withUnsafeBytes(of: ×tamp) { bytes.append(contentsOf: $0) } - return identifier + Data( - bytes: ×tamp, - count: MemoryLayout.size - ).withUnsafeBytes { Array($0) } + return bytes } } diff --git a/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift index bbd5336..b11a5e2 100644 --- a/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift +++ b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift @@ -28,7 +28,7 @@ import Foundation protocol ICMPHeaderFactoryProtocol { func make( type: ICMPType, - identifier: UInt16, + requestIdentifier: Request.Identifier, sequenceNumber: UInt16 ) throws -> ICMPHeader } @@ -36,14 +36,19 @@ protocol ICMPHeaderFactoryProtocol { struct ICMPHeaderFactory: ICMPHeaderFactoryProtocol { func make( type: ICMPType, - identifier: UInt16, + requestIdentifier: Request.Identifier, sequenceNumber: UInt16 ) throws -> ICMPHeader { var icmpHeader = ICMPHeader( type: type, - identifier: identifier, + identifier: requestIdentifier.id, sequenceNumber: sequenceNumber, - payload: Payload() + payload: Payload( + identifier: Payload.Identifier( + id: requestIdentifier.id, + uniqueToken: requestIdentifier.uniqueToken + ) + ) ) let checksum = try ICMPChecksum()(icmpHeader: icmpHeader) diff --git a/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift b/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift index 3f2d172..af04772 100644 --- a/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift +++ b/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift @@ -76,6 +76,6 @@ struct ICMPHeader: Equatable { extension ICMPHeader: Packet { var data: Data { var packet = self - return Data(bytes: &packet, count: MemoryLayout.size) + return withUnsafeBytes(of: &packet) { Data($0) } } } diff --git a/Sources/pingx/Model/Payload/Payload.swift b/Sources/pingx/Model/Payload/Payload.swift index 18c33f3..b1bfc35 100644 --- a/Sources/pingx/Model/Payload/Payload.swift +++ b/Sources/pingx/Model/Payload/Payload.swift @@ -25,47 +25,23 @@ import Foundation struct Payload: Equatable { - - // MARK: Typealias - - typealias PayloadID = (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) + struct Identifier: Equatable { + let id: UInt16 + let uniqueToken: UUID + } // MARK: Properties - let identifier: PayloadID + let identifier: Identifier let timestamp: CFAbsoluteTime // MARK: Initializer init( - timestamp: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() - ) { - self.identifier = Payload.pingxID - self.timestamp = timestamp - } - - init( - identifier: PayloadID, + identifier: Identifier, timestamp: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() ) { self.identifier = identifier self.timestamp = timestamp } - - // MARK: Static - - // "pingx" - static let pingxID: PayloadID = (112, 105, 110, 103, 120, 0, 0, 0) - - static func == (lhs: Payload, rhs: Payload) -> Bool { - lhs.identifier.0 == rhs.identifier.0 && - lhs.identifier.1 == rhs.identifier.1 && - lhs.identifier.2 == rhs.identifier.2 && - lhs.identifier.3 == rhs.identifier.3 && - lhs.identifier.4 == rhs.identifier.4 && - lhs.identifier.5 == rhs.identifier.5 && - lhs.identifier.6 == rhs.identifier.6 && - lhs.identifier.7 == rhs.identifier.7 && - lhs.timestamp == rhs.timestamp - } } diff --git a/Sources/pingx/Model/Request/Request.swift b/Sources/pingx/Model/Request/Request.swift index ab39323..bb085da 100644 --- a/Sources/pingx/Model/Request/Request.swift +++ b/Sources/pingx/Model/Request/Request.swift @@ -24,12 +24,24 @@ import Foundation -public struct Request: Identifiable, Hashable, Sendable { +public struct Request: Hashable, Sendable { + public struct Identifier: Hashable, Sendable { + let id: UInt16 + let uniqueToken: UUID + + init( + id: UInt16, + uniqueToken: UUID = UUID() + ) { + self.id = id + self.uniqueToken = uniqueToken + } + } // MARK: Properties /// The unique identifier for the request. - public let id: UInt16 + public let identifier: Identifier /// The type of icmp. let type: ICMPType = .echoRequest @@ -53,7 +65,7 @@ public struct Request: Identifiable, Hashable, Sendable { timeoutInterval: Interval = .seconds(1), demand: Request.Demand = .max(1) ) { - self.id = CFSwapInt16HostToBig(UInt16.random(in: 0.. Bool { - lhs.id == rhs.id && lhs.destination == rhs.destination + lhs.identifier == rhs.identifier && lhs.destination == rhs.destination } public func hash(into hasher: inout Hasher) { - hasher.combine(id) + hasher.combine(identifier) hasher.combine(type) hasher.combine(destination) hasher.combine(timeoutInterval) diff --git a/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift b/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift index e6e161f..a26f5d4 100644 --- a/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift +++ b/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift @@ -27,8 +27,7 @@ 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 @@ -36,7 +35,6 @@ enum ICMPResponseValidationError: Error { } case checksumMismatch(ICMPHeader) - case invalidPayload(ICMPHeader) case invalidType(ICMPHeader) case invalidCode(ICMPHeader) case missedIpHeader diff --git a/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift b/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift index 6e1a302..c1064c9 100644 --- a/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift +++ b/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift @@ -51,10 +51,6 @@ struct ICMPPacketExtractor: ICMPPacketExtractorProtocol { private extension ICMPPacketExtractor { private func validateICMPPackage(_ icmpPackage: ICMPPacket) throws(ICMPResponseValidationError) { - guard compareIdentifier(lhs: icmpPackage.icmpHeader.payload.identifier, rhs: Payload.pingxID) else { - throw ICMPResponseValidationError.invalidPayload(icmpPackage.icmpHeader) - } - guard icmpPackage.icmpHeader.type == ICMPType.echoReply.rawValue else { throw ICMPResponseValidationError.invalidType(icmpPackage.icmpHeader) } @@ -73,15 +69,4 @@ private extension ICMPPacketExtractor { 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/Pingers/AsyncPinger.swift b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift index d8777a8..d2f8525 100644 --- a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift +++ b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift @@ -26,8 +26,8 @@ import Foundation // sourcery: AutoMockable public protocol AsyncPingerProtocol: AnyObject { - func ping(request: Request) -> PingSequence - func cancel(requestId: Request.ID) + func ping(request: Request) -> AnyPingSequence + func cancel(requestId: Request.Identifier) } public final class AsyncPinger: AsyncPingerProtocol { @@ -35,7 +35,7 @@ public final class AsyncPinger: AsyncPingerProtocol { // MARK: Properties @Atomic private var pingxSocket: (any PingxSocketProtocol)! - @Atomic private var completions = [UInt16: (AsyncPingerResult) -> Void]() + @Atomic private var completions = [Request.Identifier: (AsyncPingerResult) -> Void]() private let configuration: PingConfiguration private let icmpHeaderFactory: ICMPHeaderFactoryProtocol private let icmpPacketExtractor: ICMPPacketExtractorProtocol @@ -66,11 +66,17 @@ public final class AsyncPinger: AsyncPingerProtocol { ) } - public func ping(request: Request) -> PingSequence { - PingSequence(configuration: configuration, pinger: self, request: request) + public func ping(request: Request) -> AnyPingSequence { + AnyPingSequence( + sequence: PingSequence( + configuration: configuration, + pinger: self, + request: request + ) + ) } - public func cancel(requestId: Request.ID) { + public func cancel(requestId: Request.Identifier) { invokeCompletion(identifier: requestId, result: .failure(.cancelled)) } } @@ -82,23 +88,23 @@ extension AsyncPinger { _ request: Request, completion: @escaping (AsyncPingerResult) -> Void ) { - completions[request.id] = completion + completions[request.identifier] = completion do { try checkSocketCreation() } catch { - invokeCompletion(identifier: request.id, result: .failure(.socketCreationError)) + invokeCompletion(identifier: request.identifier, result: .failure(.socketCreationError)) return } let packet = try? icmpHeaderFactory.make( type: request.type, - identifier: request.id, + requestIdentifier: request.identifier, sequenceNumber: request.sequenceNumber ) guard let packet else { - invokeCompletion(identifier: request.id, result: .failure(.unableToCreatePacket)) + invokeCompletion(identifier: request.identifier, result: .failure(.unableToCreatePacket)) return } @@ -109,7 +115,7 @@ extension AsyncPinger { ) if let error = cfSocketError.mapToPingerError() { - invokeCompletion(identifier: request.id, result: .failure(error)) + invokeCompletion(identifier: request.identifier, result: .failure(error)) } } } @@ -142,7 +148,7 @@ private extension AsyncPinger { pingxSocket = try socketFactory.make(command: command) } - func invokeCompletion(identifier: Request.ID, result: AsyncPingerResult) { + func invokeCompletion(identifier: Request.Identifier, result: AsyncPingerResult) { let completion = completions.removeValue(forKey: identifier) completion?(result) } @@ -162,14 +168,25 @@ private extension CFSocketError { } private extension Result { - var identifier: Request.ID? { + var identifier: Request.Identifier? { switch self { case .success(let icmpPacket): - return icmpPacket.icmpHeader.identifier + return icmpPacket.icmpHeader.payload.toRequestIdentifier() case .failure(.responseStructureInconsistent(let validationError)): - return validationError.icmpHeader?.identifier + return validationError.icmpHeader.map { icmpHeader in + icmpHeader.payload.toRequestIdentifier() + } case .failure: return nil } } } + +private extension Payload { + func toRequestIdentifier() -> Request.Identifier { + Request.Identifier( + id: identifier.id, + uniqueToken: identifier.uniqueToken + ) + } +} diff --git a/Sources/pingx/Pinger/Pingers/Pinger.swift b/Sources/pingx/Pinger/Pingers/Pinger.swift index 4381006..fe490eb 100644 --- a/Sources/pingx/Pinger/Pingers/Pinger.swift +++ b/Sources/pingx/Pinger/Pingers/Pinger.swift @@ -24,11 +24,11 @@ public protocol PingerProtocol: AnyObject { func ping(request: Request, completion: @escaping (PingResult) -> Void) - func cancel(requestId: Request.ID) + func cancel(requestId: Request.Identifier) } public final class Pinger: PingerProtocol { - @Atomic private var activeTasks: [UInt16: Task] = [:] + @Atomic private var activeTasks: [Request.Identifier: Task] = [:] private let asyncPinger: AsyncPingerProtocol init(asyncPinger: AsyncPingerProtocol) { @@ -54,19 +54,19 @@ public final class Pinger: PingerProtocol { var task: Task? task = Task { [weak self] in - var sequence = self?.asyncPinger.ping(request: request) + let sequence = self?.asyncPinger.ping(request: request) while !Task.isCancelled, let result = try? await sequence?.next() as? PingResult { completion(result) } - self?.cancel(requestId: request.id) + self?.cancel(requestId: request.identifier) } - activeTasks[request.id] = task + activeTasks[request.identifier] = task } - public func cancel(requestId: Request.ID) { + public func cancel(requestId: Request.Identifier) { asyncPinger.cancel(requestId: requestId) let task = activeTasks.removeValue(forKey: requestId) @@ -74,7 +74,7 @@ public final class Pinger: PingerProtocol { } private func cancelAllActiveRequests() { - activeTasks.values.forEach { $0.cancel() } + activeTasks.keys.forEach { cancel(requestId: $0) } activeTasks.removeAll() } } diff --git a/Sources/pingx/Pinger/Sequence/AnyPingSequence.swift b/Sources/pingx/Pinger/Sequence/AnyPingSequence.swift new file mode 100644 index 0000000..0e414e2 --- /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 index 66e910c..859c220 100644 --- a/Sources/pingx/Pinger/Sequence/PingSequence.swift +++ b/Sources/pingx/Pinger/Sequence/PingSequence.swift @@ -24,7 +24,9 @@ import Foundation -public struct PingSequence: AsyncSequence, AsyncIteratorProtocol { +public protocol PingSequenceProtocol: AsyncSequence, AsyncIteratorProtocol where Element == PingResult {} + +struct PingSequence: PingSequenceProtocol { private let configuration: PingConfiguration private let pinger: AsyncPinger private var request: Request @@ -40,7 +42,7 @@ public struct PingSequence: AsyncSequence, AsyncIteratorProtocol { self.request = request } - public mutating func next() async throws -> PingResult? { + mutating func next() async throws -> PingResult? { guard request.demand != .none else { return nil } try Task.checkCancellation() @@ -55,6 +57,7 @@ public struct PingSequence: AsyncSequence, AsyncIteratorProtocol { if case .cancelled = result?.error { request.setDemand(.none) + return nil } else { request.decreaseDemand() request.incrementSequenceNumber() @@ -86,14 +89,14 @@ public struct PingSequence: AsyncSequence, AsyncIteratorProtocol { defer { taskGroup.cancelAll() - pinger?.cancel(requestId: request.id) + pinger?.cancel(requestId: request.identifier) } return await taskGroup.next() } } - public func makeAsyncIterator() -> PingSequence { self } + func makeAsyncIterator() -> PingSequence { self } } private extension AsyncPingerResult { diff --git a/Tests/pingxTests/Extenstions/Assertion+Extensions.swift b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift index 5f293ed..0b531df 100644 --- a/Tests/pingxTests/Extenstions/Assertion+Extensions.swift +++ b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift @@ -25,16 +25,16 @@ import Testing import XCTest -func expectToEventuallyBeCalled( - actualCallsCount: @autoclosure @escaping () -> Int, - expectedCallsCount: Int = 1, +func expectTo( + expression: @escaping () -> Bool, timeout: TimeInterval = 1.0, + description: String = "", sourceLocation: SourceLocation = #_sourceLocation ) async { - let expectation = XCTestExpectation(description: "Wait for actualCallsCount to reach \(expectedCallsCount)") + let expectation = XCTestExpectation(description: description) let task = Task { - while actualCallsCount() < expectedCallsCount { + while !expression() { try Task.checkCancellation() try? await Task.sleep(nanoseconds: 20_000_000) // 20ms } @@ -47,24 +47,23 @@ func expectToEventuallyBeCalled( #expect( result == .completed, - "Wait for actualCallsCount to reach \(expectedCallsCount)", + .__block(description), sourceLocation: sourceLocation ) } -func expectToEventuallyNotToBeCalled( - actualCallsCount: @autoclosure @escaping () -> Int, - expectedCallsCount: Int = 1, - timeout: TimeInterval = 0.1, +func expectNotTo( + expression: @escaping () -> Bool, + timeout: TimeInterval = 1.0, + description: String = "", sourceLocation: SourceLocation = #_sourceLocation ) async { - let expectation = XCTestExpectation(description: "Wait for actualCallsCount to reach \(expectedCallsCount)") + let expectation = XCTestExpectation(description: description) expectation.isInverted = true let task = Task { - while actualCallsCount() < expectedCallsCount { - if Task.isCancelled { return } - + while !expression() { + try Task.checkCancellation() try? await Task.sleep(nanoseconds: 20_000_000) // 20ms } @@ -76,7 +75,48 @@ func expectToEventuallyNotToBeCalled( #expect( result == .completed, - "Wait for actualCallsCount to not reach \(expectedCallsCount)", + .__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)", + 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)", + 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 actualCallsCount not to be nil", sourceLocation: sourceLocation ) } diff --git a/Tests/pingxTests/Extenstions/PingResult+Extensions.swift b/Tests/pingxTests/Extenstions/PingResult+Extensions.swift new file mode 100644 index 0000000..9073af0 --- /dev/null +++ b/Tests/pingxTests/Extenstions/PingResult+Extensions.swift @@ -0,0 +1,40 @@ +// +// 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 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 index 7979c4a..a92997d 100644 --- a/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift +++ b/Tests/pingxTests/Extenstions/PingSequence+Assertions.swift @@ -33,7 +33,7 @@ import Foundation /// - timeout: Time limit for collecting values (in milliseconds) /// - Returns: An array of collected elements @discardableResult func collectValuesFromPingSequence( - sequence: PingSequence, + sequence: some PingSequenceProtocol, count: Int = .max, timeout: Interval = .milliseconds(50) ) async throws -> [PingResult] { @@ -61,7 +61,7 @@ import Foundation } func observerPingSequenceWithoutReturningResult( - sequence: PingSequence, + sequence: some PingSequenceProtocol, timeout: Interval = .milliseconds(50) ) async throws { try await collectValuesFromPingSequence( diff --git a/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift index 6bc4d2e..7740ac7 100644 --- a/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift +++ b/Tests/pingxTests/Mocks/Generated/AsyncPinger+AutoMockable.generated.swift @@ -16,10 +16,10 @@ final class AsyncPingerMock: AsyncPingerProtocol { } public var pingReceivedRequest: (Request)? public var pingReceivedInvocations: [(Request)] = [] - public var pingReturnValue: PingSequence! - public var pingClosure: ((Request) -> PingSequence)? + public var pingReturnValue: AnyPingSequence! + public var pingClosure: ((Request) -> AnyPingSequence)? - public func ping(request: Request) -> PingSequence { + public func ping(request: Request) -> AnyPingSequence { pingCallsCount += 1 pingReceivedRequest = request pingReceivedInvocations.append(request) @@ -36,11 +36,11 @@ final class AsyncPingerMock: AsyncPingerProtocol { public var cancelCalled: Bool { return cancelCallsCount > 0 } - public var cancelReceivedRequestId: (Request.ID)? - public var cancelReceivedInvocations: [(Request.ID)] = [] - public var cancelClosure: ((Request.ID) -> Void)? + public var cancelReceivedRequestId: (Request.Identifier)? + public var cancelReceivedInvocations: [(Request.Identifier)] = [] + public var cancelClosure: ((Request.Identifier) -> Void)? - public func cancel(requestId: Request.ID) { + public func cancel(requestId: Request.Identifier) { cancelCallsCount += 1 cancelReceivedRequestId = requestId cancelReceivedInvocations.append(requestId) diff --git a/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift index 41c108d..e40c8a6 100644 --- a/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift +++ b/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift @@ -15,20 +15,20 @@ final class ICMPHeaderFactoryMock: ICMPHeaderFactoryProtocol { var makeCalled: Bool { return makeCallsCount > 0 } - var makeReceivedArguments: (type: ICMPType, identifier: UInt16, sequenceNumber: UInt16)? - var makeReceivedInvocations: [(type: ICMPType, identifier: UInt16, sequenceNumber: UInt16)] = [] + var makeReceivedArguments: (type: ICMPType, requestIdentifier: Request.Identifier, sequenceNumber: UInt16)? + var makeReceivedInvocations: [(type: ICMPType, requestIdentifier: Request.Identifier, sequenceNumber: UInt16)] = [] var makeReturnValue: ICMPHeader! - var makeClosure: ((ICMPType, UInt16, UInt16) throws -> ICMPHeader)? + var makeClosure: ((ICMPType, Request.Identifier, UInt16) throws -> ICMPHeader)? - func make(type: ICMPType, identifier: UInt16, sequenceNumber: UInt16) throws -> ICMPHeader { + func make(type: ICMPType, requestIdentifier: Request.Identifier, sequenceNumber: UInt16) throws -> ICMPHeader { makeCallsCount += 1 - makeReceivedArguments = (type: type, identifier: identifier, sequenceNumber: sequenceNumber) - makeReceivedInvocations.append((type: type, identifier: identifier, sequenceNumber: sequenceNumber)) + makeReceivedArguments = (type: type, requestIdentifier: requestIdentifier, sequenceNumber: sequenceNumber) + makeReceivedInvocations.append((type: type, requestIdentifier: requestIdentifier, sequenceNumber: sequenceNumber)) if let error = makeThrowableError { throw error } if let makeClosure = makeClosure { - return try makeClosure(type, identifier, sequenceNumber) + return try makeClosure(type, requestIdentifier, sequenceNumber) } else { return makeReturnValue } diff --git a/Tests/pingxTests/Mocks/MockPingSequence.swift b/Tests/pingxTests/Mocks/MockPingSequence.swift new file mode 100644 index 0000000..957b80a --- /dev/null +++ b/Tests/pingxTests/Mocks/MockPingSequence.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 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, + sourceLocation: SourceLocation = #_sourceLocation + ) async { + await waitForContinuationIfNeeeded(sourceLocation: sourceLocation) + continuation?.resume(returning: result) + continuation = nil + } + + func finish( + sourceLocation: SourceLocation = #_sourceLocation + ) async { + await waitForContinuationIfNeeeded(sourceLocation: sourceLocation) + continuation?.resume(returning: nil) + continuation = nil + } + + private func waitForContinuationIfNeeeded( + sourceLocation: SourceLocation + ) async { + guard continuation == nil else { return } + await expectNotToEventuallyBeNil( + actualValue: self.continuation, + sourceLocation: sourceLocation + ) + } +} 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/Tests/pingxTests/Samples/Payload+Sample.swift b/Tests/pingxTests/Samples/Payload+Sample.swift index b18bab0..6489533 100644 --- a/Tests/pingxTests/Samples/Payload+Sample.swift +++ b/Tests/pingxTests/Samples/Payload+Sample.swift @@ -28,8 +28,8 @@ import Foundation extension Payload { static func sample( - identifier: PayloadID = Payload.pingxID, - timestamp: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() + identifier: Payload.Identifier = .sample(), + timestamp: CFAbsoluteTime = .zero ) -> Payload { Payload( identifier: identifier, @@ -37,3 +37,21 @@ extension Payload { ) } } + +extension Payload.Identifier { + 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 + )) + ) -> Payload.Identifier { + Payload.Identifier( + id: id, + uniqueToken: uniqueToken + ) + } +} diff --git a/Tests/pingxTests/Samples/Request+Sample.swift b/Tests/pingxTests/Samples/Request+Sample.swift index f88cffa..3c79e60 100644 --- a/Tests/pingxTests/Samples/Request+Sample.swift +++ b/Tests/pingxTests/Samples/Request+Sample.swift @@ -28,7 +28,7 @@ import Foundation extension Request { static func sample( - id: Request.ID = .zero, + id: Request.Identifier = .sample(), destination: IPv4Address = .sample(), timeoutInterval: Interval = .seconds(1), demand: Demand = .max(1), @@ -43,3 +43,21 @@ extension Request { ) } } + +extension Request.Identifier { + 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 + )) + ) -> Request.Identifier { + Request.Identifier( + id: id, + uniqueToken: uniqueToken + ) + } +} diff --git a/Tests/pingxTests/Tests/ICMPChecksum/ICMPChecksumTests.swift b/Tests/pingxTests/Tests/ICMPChecksum/ICMPChecksumTests.swift index f70afd6..10a8a92 100644 --- a/Tests/pingxTests/Tests/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 index c529f47..4c76e77 100644 --- a/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift +++ b/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift @@ -43,20 +43,9 @@ struct ICMPPackageExtractorTests { let icmpPacket = try extractor.extract(from: data) - #expect(icmpPacket.icmpHeader.identifier == request.id) + #expect(icmpPacket.icmpHeader.identifier == request.identifier.id) #expect(icmpPacket.ipHeader.sourceAddress == request.destination) } - - @Test("When payload is different, it throws invalidPayload error") - func extract_whenIdentifierIsDifferent_throwsInvalidPayloadError() async throws { - let payload = Payload.sample(identifier: (1, 1, 1, 1, 1, 1, 1, 1)) - let icmpHeader = ICMPHeader.sample(payload: payload) - let data = makeErrorData(icmpHeader: icmpHeader) - - #expect(throws: ICMPResponseValidationError.invalidPayload(icmpHeader)) { - try extractor.extract(from: data) - } - } @Test("When icmp type is not echo reply, it throws invalidType error") func extract_whenIcmpTypeIsNotEchoReply_throwsInvalidTypeError() async throws { @@ -120,9 +109,14 @@ private extension ICMPPackageExtractorTests { var icmpHeader = ICMPHeader.sample( type: .echoReply, code: .zero, - identifier: request.id, + identifier: request.identifier.id, sequenceNumber: .zero, - payload: Payload() + payload: .sample( + identifier: .sample( + id: request.identifier.id, + uniqueToken: request.identifier.uniqueToken + ) + ) ) guard let checksum = try? ICMPChecksum()(icmpHeader: icmpHeader) else { @@ -131,7 +125,7 @@ private extension ICMPPackageExtractorTests { icmpHeader.setChecksum(checksum) var icmpPacket = ICMPPacket(ipHeader: ipHeader, icmpHeader: icmpHeader) - let data = Data(bytes: &icmpPacket, count: MemoryLayout.size) + let data = withUnsafeBytes(of: &icmpPacket) { Data($0) } return data } @@ -151,7 +145,7 @@ private extension ICMPPackageExtractorTests { if let icmpHeader { var icmp = ICMPPacket.sample(ipHeader: ipHeader, icmpHeader: icmpHeader) - data = Data(bytes: &icmp, count: MemoryLayout.size) + data = withUnsafeBytes(of: &icmp) { Data($0) } } else if shouldAddIpHeader { var ipHeader = ipHeader data = Data(bytes: &ipHeader, count: MemoryLayout.size) @@ -168,8 +162,6 @@ extension ICMPResponseValidationError: Equatable { switch (lhs, rhs) { case (.checksumMismatch(let lValue), .checksumMismatch(let rValue)): return lValue == rValue - case (.invalidPayload(let lValue), .invalidPayload(let rValue)): - return lValue == rValue case (.invalidType(let lValue), .invalidType(let rValue)): return lValue == rValue case (.invalidCode(let lValue), .invalidCode(let rValue)): diff --git a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift index 74a69ca..72e75a2 100644 --- a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift +++ b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift @@ -159,7 +159,7 @@ struct AsyncPingerTests { let request = Request.sample() let icmpPacket = ICMPPacket.sample( icmpHeader: .sample( - identifier: request.id + identifier: request.identifier.id ) ) icmpPacketExtractor.extractReturnValue = icmpPacket @@ -182,7 +182,7 @@ struct AsyncPingerTests { @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: 1) + let request = Request.sample(id: .sample(id: 1)) let icmpPacket = ICMPPacket.sample( icmpHeader: .sample(identifier: 2) ) @@ -221,7 +221,7 @@ struct AsyncPingerTests { @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.id) + let icmpHeader = ICMPHeader.sample(identifier: request.identifier.id) let error = ICMPResponseValidationError.invalidCode(icmpHeader) icmpPacketExtractor.extractThrowableError = error @@ -293,7 +293,7 @@ struct AsyncPingerTests { icmpPacketExtractor.extractClosure = { _ in ICMPPacket.sample( icmpHeader: .sample( - identifier: request.id, + identifier: request.identifier.id, sequenceNumber: UInt16(icmpPacketExtractor.extractCallsCount - 1) ) ) @@ -337,7 +337,7 @@ struct AsyncPingerTests { ) socketFactory.makeReceivedCommand?.closure(Data()) - await expectToEventuallyNotToBeCalled( + await expectNotToEventuallyBeCalled( actualCallsCount: socket.sendCallsCount, expectedCallsCount: 2, timeout: 0.05 @@ -351,20 +351,20 @@ struct AsyncPingerTests { ) } - @Test("When request is cancelled, it throws cancel error") - func cancel_whenRequestIsCancelled_emitsCancelError() async throws { + @Test("When request is cancelled, it doesn't throw cancel error") + func cancel_whenRequestIsCancelled_doesNotEmitCancelError() async throws { let request = Request.sample(demand: .max(1)) let sequence = pinger.ping(request: request) try await checkThat( sequence: sequence, - emits: [.failure(.cancelled)], + emits: [], after: { await expectToEventuallyBeCalled( actualCallsCount: socket.sendCallsCount, expectedCallsCount: 1 ) - pinger.cancel(requestId: request.id) + pinger.cancel(requestId: request.identifier) } ) } @@ -376,15 +376,15 @@ struct AsyncPingerTests { try await checkThat( sequence: sequence, - emits: [.failure(.cancelled)], + emits: [], after: { await expectToEventuallyBeCalled( actualCallsCount: socket.sendCallsCount, expectedCallsCount: 1 ) - pinger.cancel(requestId: request.id) + pinger.cancel(requestId: request.identifier) - await expectToEventuallyNotToBeCalled( + await expectNotToEventuallyBeCalled( actualCallsCount: socket.sendCallsCount, expectedCallsCount: 2 ) @@ -395,7 +395,7 @@ struct AsyncPingerTests { private extension AsyncPingerTests { func checkThat( - sequence: PingSequence, + sequence: some PingSequenceProtocol, emits expectedValues: [PingResult], after operation: (() async -> Void)? = nil, timeout: Interval = .milliseconds(50), @@ -417,18 +417,3 @@ private extension AsyncPingerTests { ) } } - -private 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/Tests/Pinger/PingerTests.swift b/Tests/pingxTests/Tests/Pinger/PingerTests.swift new file mode 100644 index 0000000..d2e379b --- /dev/null +++ b/Tests/pingxTests/Tests/Pinger/PingerTests.swift @@ -0,0 +1,131 @@ +// +// 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 = MockFunc() + let pingResults: [PingResult] = [ + .success(.sample()), + .failure(.timeout), + .success(.sample()) + ] + + pinger.ping(completion: completion) + for pingResult in pingResults { + await pingSequence.send(pingResult) + } + + await expectToEventuallyBeCalled( + actualCallsCount: completion.count, + expectedCallsCount: 3 + ) + #expect(completion.parameters.elementsEqual(pingResults, by: { $0.equals($1) })) + } + + @Test("When sequence completes, cancels request by id") + func ping_whenSequenceCompletes_cancelsRequestById() async { + let request = Request.sample() + + pinger.ping(request: request) + await pingSequence.finish() + + await expectToEventuallyBeCalled( + actualCallsCount: asyncPinger.cancelCallsCount, + expectedCallsCount: 1 + ) + #expect(asyncPinger.cancelReceivedInvocations == [request.identifier]) + } + + @Test("When request is cancelled, cancels request by id") + func cancel_cancelsRequestById() async { + let request = Request.sample() + + pinger.cancel(requestId: request.identifier) + + #expect(asyncPinger.cancelReceivedInvocations == [request.identifier]) + } + + @Test("When pinger is deinitialized, cancels all active requests") + mutating func cancel_cancelsAllActiveRequests() async { + let request1 = Request.sample(id: .sample(id: 0)) + let request2 = Request.sample(id: .sample(id: 1)) + + pinger.ping(request: request1) + pinger.ping(request: request2) + pinger = nil + + #expect( + asyncPinger.cancelReceivedInvocations.sorted(by: { $0.id < $1.id }) == [ + request1.identifier, request2.identifier + ] + ) + } +} + +private extension Pinger { + func ping( + request: Request = .sample(), + completion: MockFunc = MockFunc() + ) { + ping( + request: request, + completion: completion.call + ) + } +} From 88fb01768e9c3110c75bb484ee1ac04f00e7e21a Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 29 Jun 2025 11:56:37 +0200 Subject: [PATCH 13/25] [IMP-3] Minor fix --- Sources/pingx/Checksum/ICMPChecksum.swift | 18 ++--- ...swift => IPv4AddressStringConverter.swift} | 2 +- .../Error/IPv4AddressConverterError.swift | 2 +- .../Converter/Impl/IPv4AddressConverter.swift | 11 ++- .../pingx/Extensions/String+Extensions.swift | 1 - .../ICMPHeaderFactory/ICMPHeaderFactory.swift | 22 ++--- .../Factory/SocketFactory/SocketFactory.swift | 2 +- Sources/pingx/Model/Atomic/Atomic.swift | 10 +-- .../Model/CommandBlock/CommandBlock.swift | 8 +- Sources/pingx/Model/IP/IPv4/IPv4Address.swift | 9 +-- Sources/pingx/Model/IPHeader/IPHeader.swift | 2 +- Sources/pingx/Model/Interval/Interval.swift | 4 +- .../pingx/Model/Packets/ICMP/ICMPHeader.swift | 11 +-- .../pingx/Model/Packets/ICMP/ICMPPacket.swift | 4 +- .../pingx/Model/Packets/ICMP/ICMPType.swift | 34 ++++---- .../RawDataProviding.swift} | 8 +- Sources/pingx/Model/Payload/Payload.swift | 6 +- Sources/pingx/Model/Request/Request.swift | 44 +++++----- Sources/pingx/Model/Response/PingError.swift | 8 +- Sources/pingx/Model/Response/Response.swift | 8 +- Sources/pingx/Model/Socket/PingxSocket.swift | 16 ++-- .../Configuration/PingConfiguration.swift | 2 +- .../pingx/Pinger/Error/AsyncPingerError.swift | 2 +- .../Error/ICMPResponseValidationError.swift | 2 +- .../Pinger/Helpers/ICMPPackageExtractor.swift | 12 +-- .../pingx/Pinger/Pingers/AsyncPinger.swift | 38 ++++----- Sources/pingx/Pinger/Pingers/Pinger.swift | 26 +++--- .../Pinger/Sequence/AnyPingSequence.swift | 2 +- .../pingx/Pinger/Sequence/PingSequence.swift | 34 ++++---- .../Converter/IPv4AddressConverterTests.swift | 5 +- ...HeaderFactory+AutoMockable.generated.swift | 14 ++-- .../ICMPPackageExtractorTests.swift | 40 +++++----- .../Tests/Pinger/AsyncPingerTests.swift | 80 ++++++++++--------- .../pingxTests/Tests/Pinger/PingerTests.swift | 28 +++---- .../Tests/Request/DemandTests.swift | 4 +- .../Tests/Request/RequestTests.swift | 4 +- 36 files changed, 252 insertions(+), 271 deletions(-) rename Sources/pingx/Converter/Api/{IPv4AddressConverterApi.swift => IPv4AddressStringConverter.swift} (99%) rename Sources/pingx/Model/Packets/{Packet/Packet.swift => Protocols/RawDataProviding.swift} (90%) diff --git a/Sources/pingx/Checksum/ICMPChecksum.swift b/Sources/pingx/Checksum/ICMPChecksum.swift index 52da256..9530af1 100644 --- a/Sources/pingx/Checksum/ICMPChecksum.swift +++ b/Sources/pingx/Checksum/ICMPChecksum.swift @@ -29,34 +29,34 @@ struct ICMPChecksum { 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)) } } - + 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 { + enum ChecksumError: CustomNSError { + static let errorDomain = "pingx.ChecksumError" + case outOfBounds } } private extension ICMPChecksum { func arrayPayload(_ payload: Payload) -> [UInt8] { - var timestamp = payload.timestamp var bytes: [UInt8] = [] - withUnsafeBytes(of: payload.identifier) { bytes.append(contentsOf: $0) } - withUnsafeBytes(of: ×tamp) { bytes.append(contentsOf: $0) } - + withUnsafeBytes(of: payload.timestamp) { bytes.append(contentsOf: $0) } + return bytes } } diff --git a/Sources/pingx/Converter/Api/IPv4AddressConverterApi.swift b/Sources/pingx/Converter/Api/IPv4AddressStringConverter.swift similarity index 99% rename from Sources/pingx/Converter/Api/IPv4AddressConverterApi.swift rename to Sources/pingx/Converter/Api/IPv4AddressStringConverter.swift index 18935c2..727eabb 100644 --- a/Sources/pingx/Converter/Api/IPv4AddressConverterApi.swift +++ b/Sources/pingx/Converter/Api/IPv4AddressStringConverter.swift @@ -23,7 +23,7 @@ // 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/Extensions/String+Extensions.swift b/Sources/pingx/Extensions/String+Extensions.swift index cea3093..8b7530c 100644 --- a/Sources/pingx/Extensions/String+Extensions.swift +++ b/Sources/pingx/Extensions/String+Extensions.swift @@ -27,7 +27,6 @@ import Foundation 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/ICMPHeaderFactory/ICMPHeaderFactory.swift b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift index b11a5e2..5a1b8bb 100644 --- a/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift +++ b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift @@ -26,27 +26,19 @@ import Foundation // sourcery: AutoMockable protocol ICMPHeaderFactoryProtocol { - func make( - type: ICMPType, - requestIdentifier: Request.Identifier, - sequenceNumber: UInt16 - ) throws -> ICMPHeader + func make(from request: Request) throws -> ICMPHeader } struct ICMPHeaderFactory: ICMPHeaderFactoryProtocol { - func make( - type: ICMPType, - requestIdentifier: Request.Identifier, - sequenceNumber: UInt16 - ) throws -> ICMPHeader { + func make(from request: Request) throws -> ICMPHeader { var icmpHeader = ICMPHeader( - type: type, - identifier: requestIdentifier.id, - sequenceNumber: sequenceNumber, + type: request.type, + identifier: request.identifier.id, + sequenceNumber: request.sequenceNumber, payload: Payload( identifier: Payload.Identifier( - id: requestIdentifier.id, - uniqueToken: requestIdentifier.uniqueToken + id: request.identifier.id, + uniqueToken: request.identifier.uniqueToken ) ) ) diff --git a/Sources/pingx/Factory/SocketFactory/SocketFactory.swift b/Sources/pingx/Factory/SocketFactory/SocketFactory.swift index eee5df6..684f99e 100644 --- a/Sources/pingx/Factory/SocketFactory/SocketFactory.swift +++ b/Sources/pingx/Factory/SocketFactory/SocketFactory.swift @@ -29,7 +29,7 @@ protocol SocketFactoryProtocol { func make(command: CommandBlock) throws -> any PingxSocketProtocol } -final class SocketFactory: SocketFactoryProtocol { +struct SocketFactory: SocketFactoryProtocol { func make(command: CommandBlock) throws -> any PingxSocketProtocol { let unmanaged = Unmanaged.passRetained(command) var context = CFSocketContext( diff --git a/Sources/pingx/Model/Atomic/Atomic.swift b/Sources/pingx/Model/Atomic/Atomic.swift index 8647f56..5339717 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 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 9866b6a..baf891b 100644 --- a/Sources/pingx/Model/CommandBlock/CommandBlock.swift +++ b/Sources/pingx/Model/CommandBlock/CommandBlock.swift @@ -23,13 +23,13 @@ // final class CommandBlock { - + // MARK: Properties - + 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 b41a71e..e425ad0 100644 --- a/Sources/pingx/Model/IP/IPv4/IPv4Address.swift +++ b/Sources/pingx/Model/IP/IPv4/IPv4Address.swift @@ -25,7 +25,7 @@ import Foundation public struct IPv4Address: Hashable, Sendable { - + // MARK: Properties public let address: (UInt8, UInt8, UInt8, UInt8) @@ -61,11 +61,8 @@ public struct IPv4Address: Hashable, Sendable { // 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 c4e56db..a0ee37e 100644 --- a/Sources/pingx/Model/IPHeader/IPHeader.swift +++ b/Sources/pingx/Model/IPHeader/IPHeader.swift @@ -64,7 +64,7 @@ struct IPHeader: Equatable { } // MARK: Static - + static func == (lhs: IPHeader, rhs: IPHeader) -> Bool { lhs.versionAndHeaderLength == rhs.versionAndHeaderLength && lhs.serviceType == rhs.serviceType && diff --git a/Sources/pingx/Model/Interval/Interval.swift b/Sources/pingx/Model/Interval/Interval.swift index 8a03e9f..18a7ff1 100644 --- a/Sources/pingx/Model/Interval/Interval.swift +++ b/Sources/pingx/Model/Interval/Interval.swift @@ -28,7 +28,7 @@ public enum Interval: Sendable, Hashable { case seconds(TimeInterval) case milliseconds(TimeInterval) case nanoseconds(TimeInterval) - + public var seconds: TimeInterval { switch self { case .seconds(let value): @@ -50,7 +50,7 @@ public enum Interval: Sendable, Hashable { return value / 1_000_000 } } - + public var nanoseconds: TimeInterval { switch self { case .seconds(let value): diff --git a/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift b/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift index af04772..f2b27b1 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: Equatable { +struct ICMPHeader: Equatable, RawDataProviding { // MARK: Properties @@ -70,12 +70,3 @@ struct ICMPHeader: Equatable { self.checksum = checksum } } - -// MARK: - Packet - -extension ICMPHeader: Packet { - var data: Data { - var packet = self - return withUnsafeBytes(of: &packet) { Data($0) } - } -} diff --git a/Sources/pingx/Model/Packets/ICMP/ICMPPacket.swift b/Sources/pingx/Model/Packets/ICMP/ICMPPacket.swift index a6f9518..473718b 100644 --- a/Sources/pingx/Model/Packets/ICMP/ICMPPacket.swift +++ b/Sources/pingx/Model/Packets/ICMP/ICMPPacket.swift @@ -23,9 +23,9 @@ // 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 cb74ace..34c6bba 100644 --- a/Sources/pingx/Model/Packets/ICMP/ICMPType.swift +++ b/Sources/pingx/Model/Packets/ICMP/ICMPType.swift @@ -23,55 +23,55 @@ // 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/Packets/Packet/Packet.swift b/Sources/pingx/Model/Packets/Protocols/RawDataProviding.swift similarity index 90% rename from Sources/pingx/Model/Packets/Packet/Packet.swift rename to Sources/pingx/Model/Packets/Protocols/RawDataProviding.swift index 7d12ddb..9d8b66d 100644 --- a/Sources/pingx/Model/Packets/Packet/Packet.swift +++ b/Sources/pingx/Model/Packets/Protocols/RawDataProviding.swift @@ -24,6 +24,12 @@ import Foundation -protocol Packet { +protocol RawDataProviding { var data: Data { get } } + +extension RawDataProviding { + var data: Data { + withUnsafeBytes(of: self) { Data($0) } + } +} diff --git a/Sources/pingx/Model/Payload/Payload.swift b/Sources/pingx/Model/Payload/Payload.swift index b1bfc35..04873ee 100644 --- a/Sources/pingx/Model/Payload/Payload.swift +++ b/Sources/pingx/Model/Payload/Payload.swift @@ -31,12 +31,12 @@ struct Payload: Equatable { } // MARK: Properties - + let identifier: Identifier let timestamp: CFAbsoluteTime - + // MARK: Initializer - + init( identifier: Identifier, timestamp: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() diff --git a/Sources/pingx/Model/Request/Request.swift b/Sources/pingx/Model/Request/Request.swift index bb085da..e68803d 100644 --- a/Sources/pingx/Model/Request/Request.swift +++ b/Sources/pingx/Model/Request/Request.swift @@ -28,7 +28,7 @@ public struct Request: Hashable, Sendable { public struct Identifier: Hashable, Sendable { let id: UInt16 let uniqueToken: UUID - + init( id: UInt16, uniqueToken: UUID = UUID() @@ -39,7 +39,7 @@ public struct Request: Hashable, Sendable { } // MARK: Properties - + /// The unique identifier for the request. public let identifier: Identifier @@ -48,18 +48,18 @@ public struct Request: Hashable, Sendable { /// The destination IP. public let destination: IPv4Address - + /// Timeout interval. public let timeoutInterval: Interval - + /// The desired quantity of ping requests to be sent. public private(set) var demand: Request.Demand - + /// A sequence number to help in matching Echo and Echo Reply messages. private(set) var sequenceNumber: UInt16 - + // MARK: Initializer - + public init( destination: IPv4Address, timeoutInterval: Interval = .seconds(1), @@ -85,7 +85,7 @@ public struct Request: Hashable, Sendable { self.demand = demand self.sequenceNumber = sequenceNumber } - + // MARK: Methods public static func == (lhs: Request, rhs: Request) -> Bool { @@ -108,7 +108,7 @@ public struct Request: Hashable, Sendable { mutating func decreaseDemand() { demand = demand - .max(1) } - + mutating func incrementSequenceNumber() { let (result, overflow) = sequenceNumber.addingReportingOverflow(1) sequenceNumber = overflow ? .zero : result @@ -119,35 +119,35 @@ public struct Request: Hashable, Sendable { public extension Request { struct Demand: Hashable, Sendable { - + // MARK: Properties - + /// Represents the current demand, which indicates the number of values requested. public let max: UInt? - + // MARK: Initializer - + 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. 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 @@ -156,18 +156,18 @@ public extension Request { } else { 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 } - + 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/Model/Response/PingError.swift b/Sources/pingx/Model/Response/PingError.swift index 8d4ffe8..f87bff9 100644 --- a/Sources/pingx/Model/Response/PingError.swift +++ b/Sources/pingx/Model/Response/PingError.swift @@ -25,8 +25,8 @@ import Foundation public enum PingError: CustomNSError { - public static var errorDomain: String { "pingx.PingError" } - + public static let errorDomain: String = "pingx.PingError" + public var errorDescription: String? { switch self { case .cancelled: @@ -41,7 +41,7 @@ public enum PingError: CustomNSError { return "An internal error occurred: \(error.localizedDescription)" } } - + public var errorCode: Int { switch self { case .cancelled: @@ -56,7 +56,7 @@ public enum PingError: CustomNSError { 105 } } - + public var underlyingError: CustomNSError? { guard case .internalError(let error) = self else { return nil } return error diff --git a/Sources/pingx/Model/Response/Response.swift b/Sources/pingx/Model/Response/Response.swift index ca18aba..4a4f762 100644 --- a/Sources/pingx/Model/Response/Response.swift +++ b/Sources/pingx/Model/Response/Response.swift @@ -25,15 +25,15 @@ import Foundation public struct Response { - + // MARK: Properties - + /// Destination address. public let destination: IPv4Address - + /// 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/PingxSocket.swift b/Sources/pingx/Model/Socket/PingxSocket.swift index fb04cf0..2a4c2f4 100644 --- a/Sources/pingx/Model/Socket/PingxSocket.swift +++ b/Sources/pingx/Model/Socket/PingxSocket.swift @@ -30,15 +30,15 @@ protocol PingxSocketProtocol { } final class PingxSocket: PingxSocketProtocol { - + // MARK: Properties - + let socket: CFSocket let socketSource: CFRunLoopSource let unmanaged: Unmanaged - + // MARK: Initializer - + init( socket: CFSocket, socketSource: CFRunLoopSource, @@ -48,13 +48,13 @@ final class PingxSocket: PingxSocketProtocol { self.socketSource = socketSource self.unmanaged = unmanaged } - + deinit { invalidate() } - + // MARK: Methods - + func send(address: CFData, data: CFData, timeout: CFTimeInterval) -> CFSocketError { CFSocketSendData( socket, @@ -63,7 +63,7 @@ final class PingxSocket: PingxSocketProtocol { timeout ) } - + private func invalidate() { CFRunLoopSourceInvalidate(socketSource) CFSocketInvalidate(socket) diff --git a/Sources/pingx/Pinger/Configuration/PingConfiguration.swift b/Sources/pingx/Pinger/Configuration/PingConfiguration.swift index c2e11b1..6fe0baf 100644 --- a/Sources/pingx/Pinger/Configuration/PingConfiguration.swift +++ b/Sources/pingx/Pinger/Configuration/PingConfiguration.swift @@ -26,7 +26,7 @@ import Foundation public struct PingConfiguration { public let intervalBetweenRequests: Interval - + public init(intervalBetweenRequests: Interval) { self.intervalBetweenRequests = intervalBetweenRequests } diff --git a/Sources/pingx/Pinger/Error/AsyncPingerError.swift b/Sources/pingx/Pinger/Error/AsyncPingerError.swift index 2cf31a0..478b794 100644 --- a/Sources/pingx/Pinger/Error/AsyncPingerError.swift +++ b/Sources/pingx/Pinger/Error/AsyncPingerError.swift @@ -25,7 +25,7 @@ import Foundation enum AsyncPingerError: CustomNSError { - static var errorDomain: String { "pingx.PingerError" } + static let errorDomain: String = "pingx.AsyncPingerError" public var errorCode: Int { switch self { diff --git a/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift b/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift index a26f5d4..05cec40 100644 --- a/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift +++ b/Sources/pingx/Pinger/Error/ICMPResponseValidationError.swift @@ -33,7 +33,7 @@ enum ICMPResponseValidationError: Error { return nil } } - + case checksumMismatch(ICMPHeader) case invalidType(ICMPHeader) case invalidCode(ICMPHeader) diff --git a/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift b/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift index c1064c9..4d24b1d 100644 --- a/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift +++ b/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift @@ -37,14 +37,14 @@ struct ICMPPacketExtractor: ICMPPacketExtractorProtocol { } 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 } } @@ -54,14 +54,14 @@ private extension ICMPPacketExtractor { 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) } diff --git a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift index d2f8525..283e4ed 100644 --- a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift +++ b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift @@ -31,18 +31,18 @@ public protocol AsyncPingerProtocol: AnyObject { } public final class AsyncPinger: AsyncPingerProtocol { - + // 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, @@ -54,7 +54,7 @@ public final class AsyncPinger: AsyncPingerProtocol { self.icmpPacketExtractor = icmpPacketExtractor self.socketFactory = socketFactory } - + public convenience init( configuration: PingConfiguration = .default ) { @@ -65,7 +65,7 @@ public final class AsyncPinger: AsyncPingerProtocol { socketFactory: SocketFactory() ) } - + public func ping(request: Request) -> AnyPingSequence { AnyPingSequence( sequence: PingSequence( @@ -75,7 +75,7 @@ public final class AsyncPinger: AsyncPingerProtocol { ) ) } - + public func cancel(requestId: Request.Identifier) { invokeCompletion(identifier: requestId, result: .failure(.cancelled)) } @@ -89,7 +89,7 @@ extension AsyncPinger { completion: @escaping (AsyncPingerResult) -> Void ) { completions[request.identifier] = completion - + do { try checkSocketCreation() } catch { @@ -97,23 +97,17 @@ extension AsyncPinger { return } - let packet = try? icmpHeaderFactory.make( - type: request.type, - requestIdentifier: request.identifier, - sequenceNumber: request.sequenceNumber - ) - - guard let packet else { + 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: packet.data as CFData, + data: icmpHeader.data as CFData, timeout: request.timeoutInterval.milliseconds ) - + if let error = cfSocketError.mapToPingerError() { invokeCompletion(identifier: request.identifier, result: .failure(error)) } @@ -125,7 +119,7 @@ extension AsyncPinger { private extension AsyncPinger { func checkSocketCreation() throws { guard pingxSocket == nil else { return } - + let command: CommandBlock = CommandBlock { [weak self] data in guard let self else { return } @@ -139,15 +133,15 @@ private extension AsyncPinger { 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) diff --git a/Sources/pingx/Pinger/Pingers/Pinger.swift b/Sources/pingx/Pinger/Pingers/Pinger.swift index fe490eb..c4df718 100644 --- a/Sources/pingx/Pinger/Pingers/Pinger.swift +++ b/Sources/pingx/Pinger/Pingers/Pinger.swift @@ -30,11 +30,11 @@ public protocol PingerProtocol: AnyObject { 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 ) { @@ -42,27 +42,30 @@ public final class Pinger: PingerProtocol { asyncPinger: AsyncPinger(configuration: configuration) ) } - + deinit { cancelAllActiveRequests() } - + + private func cancelAllActiveRequests() { + activeTasks.keys.forEach { cancel(requestId: $0) } + activeTasks.removeAll() + } + public func ping( request: Request, completion: @escaping (PingResult) -> Void ) { - var task: Task? - - task = Task { [weak self] in + let task = Task { [weak self] in let sequence = self?.asyncPinger.ping(request: request) while !Task.isCancelled, let result = try? await sequence?.next() as? PingResult { completion(result) } - + self?.cancel(requestId: request.identifier) } - + activeTasks[request.identifier] = task } @@ -72,9 +75,4 @@ public final class Pinger: PingerProtocol { let task = activeTasks.removeValue(forKey: requestId) task?.cancel() } - - private func cancelAllActiveRequests() { - activeTasks.keys.forEach { cancel(requestId: $0) } - activeTasks.removeAll() - } } diff --git a/Sources/pingx/Pinger/Sequence/AnyPingSequence.swift b/Sources/pingx/Pinger/Sequence/AnyPingSequence.swift index 0e414e2..902ffc8 100644 --- a/Sources/pingx/Pinger/Sequence/AnyPingSequence.swift +++ b/Sources/pingx/Pinger/Sequence/AnyPingSequence.swift @@ -46,7 +46,7 @@ public struct AnyPingSequence: PingSequenceProtocol { } self._next = { try await iterator.next() } } - + public func next() async throws -> PingResult? { try await _next() } diff --git a/Sources/pingx/Pinger/Sequence/PingSequence.swift b/Sources/pingx/Pinger/Sequence/PingSequence.swift index 859c220..07bb6ab 100644 --- a/Sources/pingx/Pinger/Sequence/PingSequence.swift +++ b/Sources/pingx/Pinger/Sequence/PingSequence.swift @@ -31,7 +31,7 @@ struct PingSequence: PingSequenceProtocol { private let pinger: AsyncPinger private var request: Request private var shouldDelayNextPing = false - + init( configuration: PingConfiguration, pinger: AsyncPinger, @@ -44,28 +44,28 @@ struct PingSequence: PingSequenceProtocol { mutating func next() async throws -> PingResult? { guard request.demand != .none else { return nil } + try Task.checkCancellation() if shouldDelayNextPing { try await Task.sleep(nanoseconds: UInt64(configuration.intervalBetweenRequests.nanoseconds)) - try Task.checkCancellation() } else { shouldDelayNextPing = true } - - let result = await performPingWithTimeout() - - if case .cancelled = result?.error { + + guard let result = await performPingWithTimeout() else { return nil } + + if case .cancelled = result.error { request.setDemand(.none) return nil - } else { - request.decreaseDemand() - request.incrementSequenceNumber() } - - return result?.mapToPingResult() + + request.decreaseDemand() + request.incrementSequenceNumber() + + return result.mapToPingResult() } - + private func performPingWithTimeout() async -> AsyncPingerResult? { await withTaskGroup( of: AsyncPingerResult.self, @@ -75,10 +75,10 @@ struct PingSequence: PingSequenceProtocol { do { try await Task.sleep(nanoseconds: UInt64(request.timeoutInterval.nanoseconds)) } catch {} - + return .failure(.timeout) } - + taskGroup.addTask { return await withCheckedContinuation { continutaion in pinger?.ping(request) { result in @@ -86,16 +86,16 @@ struct PingSequence: PingSequenceProtocol { } } } - + defer { taskGroup.cancelAll() pinger?.cancel(requestId: request.identifier) } - + return await taskGroup.next() } } - + func makeAsyncIterator() -> PingSequence { self } } diff --git a/Tests/pingxTests/Converter/IPv4AddressConverterTests.swift b/Tests/pingxTests/Converter/IPv4AddressConverterTests.swift index 9e70043..12d5d8c 100644 --- a/Tests/pingxTests/Converter/IPv4AddressConverterTests.swift +++ b/Tests/pingxTests/Converter/IPv4AddressConverterTests.swift @@ -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) { diff --git a/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift b/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift index e40c8a6..d5c094c 100644 --- a/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift +++ b/Tests/pingxTests/Mocks/Generated/ICMPHeaderFactory+AutoMockable.generated.swift @@ -15,20 +15,20 @@ final class ICMPHeaderFactoryMock: ICMPHeaderFactoryProtocol { var makeCalled: Bool { return makeCallsCount > 0 } - var makeReceivedArguments: (type: ICMPType, requestIdentifier: Request.Identifier, sequenceNumber: UInt16)? - var makeReceivedInvocations: [(type: ICMPType, requestIdentifier: Request.Identifier, sequenceNumber: UInt16)] = [] + var makeReceivedRequest: (Request)? + var makeReceivedInvocations: [(Request)] = [] var makeReturnValue: ICMPHeader! - var makeClosure: ((ICMPType, Request.Identifier, UInt16) throws -> ICMPHeader)? + var makeClosure: ((Request) throws -> ICMPHeader)? - func make(type: ICMPType, requestIdentifier: Request.Identifier, sequenceNumber: UInt16) throws -> ICMPHeader { + func make(from request: Request) throws -> ICMPHeader { makeCallsCount += 1 - makeReceivedArguments = (type: type, requestIdentifier: requestIdentifier, sequenceNumber: sequenceNumber) - makeReceivedInvocations.append((type: type, requestIdentifier: requestIdentifier, sequenceNumber: sequenceNumber)) + makeReceivedRequest = request + makeReceivedInvocations.append(request) if let error = makeThrowableError { throw error } if let makeClosure = makeClosure { - return try makeClosure(type, requestIdentifier, sequenceNumber) + return try makeClosure(request) } else { return makeReturnValue } diff --git a/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift b/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift index 4c76e77..7500f05 100644 --- a/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift +++ b/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift @@ -31,65 +31,65 @@ import XCTest @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) } @@ -118,18 +118,18 @@ private extension ICMPPackageExtractorTests { ) ) ) - + 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 = withUnsafeBytes(of: &icmpPacket) { Data($0) } - + return data } - + func makeErrorData( for request: Request = .sample(), icmpHeader: ICMPHeader? = nil, @@ -142,7 +142,7 @@ private extension ICMPPackageExtractorTests { destinationAddress: request.destination ) let data: Data - + if let icmpHeader { var icmp = ICMPPacket.sample(ipHeader: ipHeader, icmpHeader: icmpHeader) data = withUnsafeBytes(of: &icmp) { Data($0) } @@ -152,7 +152,7 @@ private extension ICMPPackageExtractorTests { } else { data = Data() } - + return data } } diff --git a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift index 72e75a2..032440d 100644 --- a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift +++ b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift @@ -35,14 +35,14 @@ struct AsyncPingerTests { 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() @@ -51,7 +51,7 @@ struct AsyncPingerTests { self.pinger = makeAsyncPinger() } - + private func makeAsyncPinger( configuration: PingConfiguration = PingConfiguration(intervalBetweenRequests: .milliseconds(0)) ) -> AsyncPinger { @@ -62,30 +62,30 @@ struct AsyncPingerTests { 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() @@ -98,7 +98,7 @@ struct AsyncPingerTests { emits: [.failure(.socketFailed)] ) } - + @Test("When packet creation failed, it emits pingError.internalError") func send_whenPacketCreationFailed_emitsPacketCreationError() async throws { let request = Request.sample() @@ -106,13 +106,14 @@ struct AsyncPingerTests { 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() @@ -126,34 +127,35 @@ struct AsyncPingerTests { #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() @@ -169,7 +171,7 @@ struct AsyncPingerTests { destination: request.destination, sequenceNumber: request.sequenceNumber ) - + try await checkThat( sequence: sequence, emits: [.success(response)], @@ -179,7 +181,7 @@ struct AsyncPingerTests { } ) } - + @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)) @@ -189,7 +191,7 @@ struct AsyncPingerTests { icmpPacketExtractor.extractReturnValue = icmpPacket let sequence = pinger.ping(request: request) - + try await checkThat( sequence: sequence, emits: [], @@ -207,7 +209,7 @@ struct AsyncPingerTests { icmpPacketExtractor.extractThrowableError = error let sequence = pinger.ping(request: request) - + try await checkThat( sequence: sequence, emits: [], @@ -226,7 +228,7 @@ struct AsyncPingerTests { icmpPacketExtractor.extractThrowableError = error let sequence = pinger.ping(request: request) - + try await checkThat( sequence: sequence, emits: [.failure(.responseStructureInconsistent)], @@ -236,14 +238,14 @@ struct AsyncPingerTests { } ) } - + @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: [], @@ -253,31 +255,31 @@ struct AsyncPingerTests { } ) } - + @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] @@ -289,7 +291,7 @@ struct AsyncPingerTests { let responses = (0..() @@ -70,14 +70,14 @@ struct PingerTests { for pingResult in pingResults { await pingSequence.send(pingResult) } - + await expectToEventuallyBeCalled( actualCallsCount: completion.count, expectedCallsCount: 3 ) #expect(completion.parameters.elementsEqual(pingResults, by: { $0.equals($1) })) } - + @Test("When sequence completes, cancels request by id") func ping_whenSequenceCompletes_cancelsRequestById() async { let request = Request.sample() @@ -91,25 +91,25 @@ struct PingerTests { ) #expect(asyncPinger.cancelReceivedInvocations == [request.identifier]) } - + @Test("When request is cancelled, cancels request by id") func cancel_cancelsRequestById() async { let request = Request.sample() - + pinger.cancel(requestId: request.identifier) - + #expect(asyncPinger.cancelReceivedInvocations == [request.identifier]) } - + @Test("When pinger is deinitialized, cancels all active requests") mutating func cancel_cancelsAllActiveRequests() async { let request1 = Request.sample(id: .sample(id: 0)) let request2 = Request.sample(id: .sample(id: 1)) - + pinger.ping(request: request1) pinger.ping(request: request2) pinger = nil - + #expect( asyncPinger.cancelReceivedInvocations.sorted(by: { $0.id < $1.id }) == [ request1.identifier, request2.identifier diff --git a/Tests/pingxTests/Tests/Request/DemandTests.swift b/Tests/pingxTests/Tests/Request/DemandTests.swift index e7d94e3..92b51ef 100644 --- a/Tests/pingxTests/Tests/Request/DemandTests.swift +++ b/Tests/pingxTests/Tests/Request/DemandTests.swift @@ -41,7 +41,7 @@ struct DemandTests { func demand_initialization(demand: Demand, expectedValue: UInt?) { #expect(demand.max == expectedValue) } - + @Test( "Tests demand substraction", arguments: [ @@ -59,7 +59,7 @@ struct DemandTests { func demand_substraction(lValue: Demand, rValue: Demand, result: Demand) { #expect((lValue - rValue) == result) } - + @Test( "Tests demand addition", arguments: [ diff --git a/Tests/pingxTests/Tests/Request/RequestTests.swift b/Tests/pingxTests/Tests/Request/RequestTests.swift index 0ec6fa8..a97fc1b 100644 --- a/Tests/pingxTests/Tests/Request/RequestTests.swift +++ b/Tests/pingxTests/Tests/Request/RequestTests.swift @@ -42,7 +42,7 @@ struct RequestTests { expectedDemand: Request.Demand ) { var request = Request.sample(demand: initialDemand) - + request.decreaseDemand() #expect(request.demand == expectedDemand) @@ -62,7 +62,7 @@ struct RequestTests { expectedSequenceNumber: UInt16 ) { var request = Request.sample(sequenceNumber: initialSequenceNumber) - + request.incrementSequenceNumber() #expect(request.sequenceNumber == expectedSequenceNumber) From 71f414f984d7b7ce0d85ee401a9579c2829735c4 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 29 Jun 2025 12:22:36 +0200 Subject: [PATCH 14/25] [IMP-3] Minor fix --- Example/pingx/Views/AsyncPingView/AsyncPingView.swift | 2 +- .../pingx/Views/CallbackPingView/CallbackPingView.swift | 2 +- .../Views/CallbackPingView/CallbackPingViewModel.swift | 7 ++++--- Sources/pingx/Pinger/Pingers/Pinger.swift | 1 - 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Example/pingx/Views/AsyncPingView/AsyncPingView.swift b/Example/pingx/Views/AsyncPingView/AsyncPingView.swift index de5b964..53e383a 100644 --- a/Example/pingx/Views/AsyncPingView/AsyncPingView.swift +++ b/Example/pingx/Views/AsyncPingView/AsyncPingView.swift @@ -25,7 +25,7 @@ import SwiftUI struct AsyncPingView: View { - @ObservedObject private var viewModel = AsyncPingViewModel() + @StateObject private var viewModel = AsyncPingViewModel() var body: some View { VStack { diff --git a/Example/pingx/Views/CallbackPingView/CallbackPingView.swift b/Example/pingx/Views/CallbackPingView/CallbackPingView.swift index 98a4941..22b9dd8 100644 --- a/Example/pingx/Views/CallbackPingView/CallbackPingView.swift +++ b/Example/pingx/Views/CallbackPingView/CallbackPingView.swift @@ -26,7 +26,7 @@ import SwiftUI import pingx struct CallbackPingView: View { - @ObservedObject private var viewModel = CallbackPingViewModel() + @StateObject private var viewModel = CallbackPingViewModel() var body: some View { VStack { diff --git a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift index cf1f4c0..e7f6854 100644 --- a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift +++ b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift @@ -47,10 +47,11 @@ final class CallbackPingViewModel: ObservableObject { func startPinging() { pingResultDisplayModels.removeAll() - + + let demandCount = 5 let request = Request( destination: Constants.destinationAddress, - demand: .max(5) + demand: .max(UInt(demandCount)) ) isPingActive = true @@ -60,7 +61,7 @@ final class CallbackPingViewModel: ObservableObject { let displayModel = PingResultDisplayModel(pingResult: result) self?.pingResultDisplayModels.append(displayModel) - if request.demand == .none { + if self?.pingResultDisplayModels.count == demandCount { self?.isPingActive = false } } diff --git a/Sources/pingx/Pinger/Pingers/Pinger.swift b/Sources/pingx/Pinger/Pingers/Pinger.swift index c4df718..a57b4e0 100644 --- a/Sources/pingx/Pinger/Pingers/Pinger.swift +++ b/Sources/pingx/Pinger/Pingers/Pinger.swift @@ -49,7 +49,6 @@ public final class Pinger: PingerProtocol { private func cancelAllActiveRequests() { activeTasks.keys.forEach { cancel(requestId: $0) } - activeTasks.removeAll() } public func ping( From 49e771baf3ad5af828b5fd8c8898a6d23c0c210a Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 29 Jun 2025 16:24:19 +0200 Subject: [PATCH 15/25] [IMP-3] Minor fix --- Example/pingx.xcodeproj/project.pbxproj | 4 ++ .../AsyncPingView/AsyncPingViewModel.swift | 2 + .../ICMPHeaderFactory/ICMPHeaderFactory.swift | 7 +-- .../PingxIdentifier.swift} | 15 +++-- .../pingx/Model/Packets/ICMP/ICMPHeader.swift | 9 ++- Sources/pingx/Model/Payload/Payload.swift | 8 +-- Sources/pingx/Model/Request/Request.swift | 14 +---- Sources/pingx/Model/Response/Response.swift | 2 +- .../pingx/Pinger/Pingers/AsyncPinger.swift | 11 ++-- Sources/pingx/Pinger/Pingers/Pinger.swift | 4 +- .../pingx/Pinger/Sequence/PingSequence.swift | 2 +- Tests/pingxTests/Samples/Payload+Sample.swift | 20 +------ .../Samples/PingxIdentifier+Sample.swift | 46 ++++++++++++++++ Tests/pingxTests/Samples/Request+Sample.swift | 18 ------ .../pingxTests/Tests/Pinger/PingerTests.swift | 55 ++++++++++++------- 15 files changed, 118 insertions(+), 99 deletions(-) rename Sources/pingx/Model/{Packets/Protocols/RawDataProviding.swift => Identifier/PingxIdentifier.swift} (83%) create mode 100644 Tests/pingxTests/Samples/PingxIdentifier+Sample.swift diff --git a/Example/pingx.xcodeproj/project.pbxproj b/Example/pingx.xcodeproj/project.pbxproj index 821bf2e..84f6746 100644 --- a/Example/pingx.xcodeproj/project.pbxproj +++ b/Example/pingx.xcodeproj/project.pbxproj @@ -315,6 +315,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -350,6 +351,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -415,6 +417,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = minimal; }; name = Debug; }; @@ -470,6 +473,7 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = minimal; VALIDATE_PRODUCT = YES; }; name = Release; diff --git a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift index f77b6c2..4af3dc5 100644 --- a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift +++ b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift @@ -58,6 +58,8 @@ final class AsyncPingViewModel: ObservableObject { 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) diff --git a/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift index 5a1b8bb..cf78c50 100644 --- a/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift +++ b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift @@ -35,12 +35,7 @@ struct ICMPHeaderFactory: ICMPHeaderFactoryProtocol { type: request.type, identifier: request.identifier.id, sequenceNumber: request.sequenceNumber, - payload: Payload( - identifier: Payload.Identifier( - id: request.identifier.id, - uniqueToken: request.identifier.uniqueToken - ) - ) + payload: Payload(identifier: request.identifier) ) let checksum = try ICMPChecksum()(icmpHeader: icmpHeader) diff --git a/Sources/pingx/Model/Packets/Protocols/RawDataProviding.swift b/Sources/pingx/Model/Identifier/PingxIdentifier.swift similarity index 83% rename from Sources/pingx/Model/Packets/Protocols/RawDataProviding.swift rename to Sources/pingx/Model/Identifier/PingxIdentifier.swift index 9d8b66d..7679303 100644 --- a/Sources/pingx/Model/Packets/Protocols/RawDataProviding.swift +++ b/Sources/pingx/Model/Identifier/PingxIdentifier.swift @@ -24,12 +24,15 @@ import Foundation -protocol RawDataProviding { - var data: Data { get } -} +public struct PingxIdentifier: Hashable, Sendable { + let id: UInt16 + let uniqueToken: UUID -extension RawDataProviding { - var data: Data { - withUnsafeBytes(of: self) { Data($0) } + init( + id: UInt16, + uniqueToken: UUID = UUID() + ) { + self.id = id + self.uniqueToken = uniqueToken } } diff --git a/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift b/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift index f2b27b1..9dc6733 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: Equatable, RawDataProviding { +struct ICMPHeader: Equatable { // MARK: Properties @@ -70,3 +70,10 @@ struct ICMPHeader: Equatable, RawDataProviding { self.checksum = checksum } } + +extension ICMPHeader { + var data: Data { + var packet = self + return Data(bytes: &packet, count: MemoryLayout.size) + } +} diff --git a/Sources/pingx/Model/Payload/Payload.swift b/Sources/pingx/Model/Payload/Payload.swift index 04873ee..a263496 100644 --- a/Sources/pingx/Model/Payload/Payload.swift +++ b/Sources/pingx/Model/Payload/Payload.swift @@ -25,20 +25,16 @@ import Foundation struct Payload: Equatable { - struct Identifier: Equatable { - let id: UInt16 - let uniqueToken: UUID - } // MARK: Properties - let identifier: Identifier + let identifier: PingxIdentifier let timestamp: CFAbsoluteTime // MARK: Initializer init( - identifier: Identifier, + identifier: PingxIdentifier, timestamp: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() ) { self.identifier = identifier diff --git a/Sources/pingx/Model/Request/Request.swift b/Sources/pingx/Model/Request/Request.swift index e68803d..5240f8a 100644 --- a/Sources/pingx/Model/Request/Request.swift +++ b/Sources/pingx/Model/Request/Request.swift @@ -25,18 +25,8 @@ import Foundation public struct Request: Hashable, Sendable { - public struct Identifier: Hashable, Sendable { - let id: UInt16 - let uniqueToken: UUID - - init( - id: UInt16, - uniqueToken: UUID = UUID() - ) { - self.id = id - self.uniqueToken = uniqueToken - } - } + + public typealias Identifier = PingxIdentifier // MARK: Properties diff --git a/Sources/pingx/Model/Response/Response.swift b/Sources/pingx/Model/Response/Response.swift index 4a4f762..26b4398 100644 --- a/Sources/pingx/Model/Response/Response.swift +++ b/Sources/pingx/Model/Response/Response.swift @@ -24,7 +24,7 @@ import Foundation -public struct Response { +public struct Response: Sendable { // MARK: Properties diff --git a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift index 283e4ed..9c5d41c 100644 --- a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift +++ b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift @@ -27,7 +27,6 @@ import Foundation // sourcery: AutoMockable public protocol AsyncPingerProtocol: AnyObject { func ping(request: Request) -> AnyPingSequence - func cancel(requestId: Request.Identifier) } public final class AsyncPinger: AsyncPingerProtocol { @@ -75,17 +74,13 @@ public final class AsyncPinger: AsyncPingerProtocol { ) ) } - - public func cancel(requestId: Request.Identifier) { - invokeCompletion(identifier: requestId, result: .failure(.cancelled)) - } } // MARK: - Internal API extension AsyncPinger { func ping( - _ request: Request, + request: Request, completion: @escaping (AsyncPingerResult) -> Void ) { completions[request.identifier] = completion @@ -112,6 +107,10 @@ extension AsyncPinger { invokeCompletion(identifier: request.identifier, result: .failure(error)) } } + + func cancel(requestId: Request.Identifier) { + invokeCompletion(identifier: requestId, result: .failure(.cancelled)) + } } // MARK: - Private API diff --git a/Sources/pingx/Pinger/Pingers/Pinger.swift b/Sources/pingx/Pinger/Pingers/Pinger.swift index a57b4e0..f08d264 100644 --- a/Sources/pingx/Pinger/Pingers/Pinger.swift +++ b/Sources/pingx/Pinger/Pingers/Pinger.swift @@ -58,7 +58,7 @@ public final class Pinger: PingerProtocol { let task = Task { [weak self] in let sequence = self?.asyncPinger.ping(request: request) - while !Task.isCancelled, let result = try? await sequence?.next() as? PingResult { + while let result = try? await sequence?.next() as? PingResult, !Task.isCancelled { completion(result) } @@ -69,8 +69,6 @@ public final class Pinger: PingerProtocol { } public func cancel(requestId: Request.Identifier) { - asyncPinger.cancel(requestId: requestId) - let task = activeTasks.removeValue(forKey: requestId) task?.cancel() } diff --git a/Sources/pingx/Pinger/Sequence/PingSequence.swift b/Sources/pingx/Pinger/Sequence/PingSequence.swift index 07bb6ab..0980c3a 100644 --- a/Sources/pingx/Pinger/Sequence/PingSequence.swift +++ b/Sources/pingx/Pinger/Sequence/PingSequence.swift @@ -81,7 +81,7 @@ struct PingSequence: PingSequenceProtocol { taskGroup.addTask { return await withCheckedContinuation { continutaion in - pinger?.ping(request) { result in + pinger?.ping(request: request) { result in continutaion.resume(returning: result) } } diff --git a/Tests/pingxTests/Samples/Payload+Sample.swift b/Tests/pingxTests/Samples/Payload+Sample.swift index 6489533..d286bea 100644 --- a/Tests/pingxTests/Samples/Payload+Sample.swift +++ b/Tests/pingxTests/Samples/Payload+Sample.swift @@ -28,7 +28,7 @@ import Foundation extension Payload { static func sample( - identifier: Payload.Identifier = .sample(), + identifier: PingxIdentifier = .sample(), timestamp: CFAbsoluteTime = .zero ) -> Payload { Payload( @@ -37,21 +37,3 @@ extension Payload { ) } } - -extension Payload.Identifier { - 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 - )) - ) -> Payload.Identifier { - Payload.Identifier( - id: id, - uniqueToken: uniqueToken - ) - } -} diff --git a/Tests/pingxTests/Samples/PingxIdentifier+Sample.swift b/Tests/pingxTests/Samples/PingxIdentifier+Sample.swift new file mode 100644 index 0000000..4b81139 --- /dev/null +++ b/Tests/pingxTests/Samples/PingxIdentifier+Sample.swift @@ -0,0 +1,46 @@ +// +// 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 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 + ) + } +} diff --git a/Tests/pingxTests/Samples/Request+Sample.swift b/Tests/pingxTests/Samples/Request+Sample.swift index 3c79e60..b6ee16a 100644 --- a/Tests/pingxTests/Samples/Request+Sample.swift +++ b/Tests/pingxTests/Samples/Request+Sample.swift @@ -43,21 +43,3 @@ extension Request { ) } } - -extension Request.Identifier { - 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 - )) - ) -> Request.Identifier { - Request.Identifier( - id: id, - uniqueToken: uniqueToken - ) - } -} diff --git a/Tests/pingxTests/Tests/Pinger/PingerTests.swift b/Tests/pingxTests/Tests/Pinger/PingerTests.swift index 793194a..9592615 100644 --- a/Tests/pingxTests/Tests/Pinger/PingerTests.swift +++ b/Tests/pingxTests/Tests/Pinger/PingerTests.swift @@ -78,43 +78,42 @@ struct PingerTests { #expect(completion.parameters.elementsEqual(pingResults, by: { $0.equals($1) })) } - @Test("When sequence completes, cancels request by id") - func ping_whenSequenceCompletes_cancelsRequestById() async { + @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 expectToEventuallyBeCalled( - actualCallsCount: asyncPinger.cancelCallsCount, - expectedCallsCount: 1 - ) - #expect(asyncPinger.cancelReceivedInvocations == [request.identifier]) + await pingSequence.expectNextNotToEventuallyBeCalled() } - @Test("When request is cancelled, cancels request by id") - func cancel_cancelsRequestById() async { + @Test("When request is cancelled, does not emit events after cancellation") + func cancel_doesNotEmitEvents() async { + let completion = MockFunc() let request = Request.sample() + pinger.ping(request: request, completion: completion) + await pingSequence.expectNextToEventuallyBeCalled() + pinger.cancel(requestId: request.identifier) + await pingSequence.send(.success(.sample())) - #expect(asyncPinger.cancelReceivedInvocations == [request.identifier]) + await expectNotToEventuallyBeCalled(actualCallsCount: completion.count) } @Test("When pinger is deinitialized, cancels all active requests") - mutating func cancel_cancelsAllActiveRequests() async { - let request1 = Request.sample(id: .sample(id: 0)) - let request2 = Request.sample(id: .sample(id: 1)) + mutating func deinit_cancelsAllActiveRequests() async { + let completion = MockFunc() + let request = Request.sample() + + pinger.ping(request: request) + await pingSequence.expectNextToEventuallyBeCalled() - pinger.ping(request: request1) - pinger.ping(request: request2) pinger = nil + await pingSequence.send(.success(.sample())) - #expect( - asyncPinger.cancelReceivedInvocations.sorted(by: { $0.id < $1.id }) == [ - request1.identifier, request2.identifier - ] - ) + await expectNotToEventuallyBeCalled(actualCallsCount: completion.count) } } @@ -129,3 +128,19 @@ private extension Pinger { ) } } + +private extension MockPingSequence { + func expectNextToEventuallyBeCalled() async { + await expectToEventuallyBeCalled( + actualCallsCount: self.nextCallsCount, + expectedCallsCount: nextCallsCount + 1 + ) + } + + func expectNextNotToEventuallyBeCalled() async { + await expectNotToEventuallyBeCalled( + actualCallsCount: self.nextCallsCount, + expectedCallsCount: nextCallsCount + 1 + ) + } +} From 333e2fb87da52a57c322b82654a482ce26a975d1 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 29 Jun 2025 16:30:38 +0200 Subject: [PATCH 16/25] Update README.md --- README.md | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 76585ca..bbb7aa9 100644 --- a/README.md +++ b/README.md @@ -55,24 +55,24 @@ The Request class represents a single ping request configuration. It encapsulate ```swift let destination = IPv4Address(address: (8, 8, 8, 8)) let request = Request( - destination: destination, // Destination - timeoutInterval: 1500, // 1.5 seconds timeout (1 second by default) - 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 + 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 request = Request(destination: destination) - let pinger = AsyncPinger() +let request = Request(destination: destination) let sequence = pinger.ping(request: request) for try await result in sequence { @@ -82,15 +82,29 @@ for try await result in sequence { ### 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)") +} +``` + +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 From fcaaf3f2e048121a6589669f432c20c26a9b49a5 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 29 Jun 2025 21:32:16 +0200 Subject: [PATCH 17/25] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index bbb7aa9..33eaf4a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ let destination = try converter.convert(address: "8.8.8.8") 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 destination = IPv4Address(address: (8, 8, 8, 8)) let request = Request( destination: destination, // Destination From e955d5fc3ab86b95f3eea61e3ab9a7f0af8ba1ee Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sat, 5 Jul 2025 12:56:07 +0200 Subject: [PATCH 18/25] [IMP-3] Update payload --- Example/pingx.xcodeproj/project.pbxproj | 8 +++++-- .../AsyncPingView/AsyncPingViewModel.swift | 1 + .../CallbackPingViewModel.swift | 1 + Sources/pingx/Checksum/ICMPChecksum.swift | 12 +---------- .../ICMPHeaderFactory/ICMPHeaderFactory.swift | 2 +- Sources/pingx/Model/Payload/Payload.swift | 21 ++++++++++++++++--- Sources/pingx/Model/Socket/PingxSocket.swift | 4 ++-- .../Pinger/Helpers/ICMPPackageExtractor.swift | 2 +- .../pingx/Pinger/Pingers/AsyncPinger.swift | 14 ++++++------- Tests/pingxTests/Samples/Payload+Sample.swift | 9 ++++++-- .../ICMPPackageExtractorTests.swift | 5 +---- 11 files changed, 46 insertions(+), 33 deletions(-) diff --git a/Example/pingx.xcodeproj/project.pbxproj b/Example/pingx.xcodeproj/project.pbxproj index 84f6746..782e4ae 100644 --- a/Example/pingx.xcodeproj/project.pbxproj +++ b/Example/pingx.xcodeproj/project.pbxproj @@ -244,10 +244,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-pingx_Example/Pods-pingx_Example-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-pingx_Example/Pods-pingx_Example-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-pingx_Example/Pods-pingx_Example-frameworks.sh\"\n"; @@ -315,7 +319,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -351,7 +355,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift index 4af3dc5..3112fec 100644 --- a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift +++ b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift @@ -24,6 +24,7 @@ import pingx +@MainActor final class AsyncPingViewModel: ObservableObject { private enum Constants { static var destinationAddress: IPv4Address { IPv4Address(address: (8, 8, 8, 8)) } diff --git a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift index e7f6854..bb5ab53 100644 --- a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift +++ b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift @@ -24,6 +24,7 @@ import pingx +@MainActor final class CallbackPingViewModel: ObservableObject { private enum Constants { static var destinationAddress: IPv4Address { IPv4Address(address: (8, 8, 8, 8)) } diff --git a/Sources/pingx/Checksum/ICMPChecksum.swift b/Sources/pingx/Checksum/ICMPChecksum.swift index 9530af1..9806a8c 100644 --- a/Sources/pingx/Checksum/ICMPChecksum.swift +++ b/Sources/pingx/Checksum/ICMPChecksum.swift @@ -28,7 +28,7 @@ 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) + let payload = icmpHeader.payload.bytes 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)) } @@ -50,13 +50,3 @@ extension ICMPChecksum { case outOfBounds } } - -private extension ICMPChecksum { - func arrayPayload(_ payload: Payload) -> [UInt8] { - var bytes: [UInt8] = [] - withUnsafeBytes(of: payload.identifier) { bytes.append(contentsOf: $0) } - withUnsafeBytes(of: payload.timestamp) { bytes.append(contentsOf: $0) } - - return bytes - } -} diff --git a/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift index cf78c50..2669d0c 100644 --- a/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift +++ b/Sources/pingx/Factory/ICMPHeaderFactory/ICMPHeaderFactory.swift @@ -35,7 +35,7 @@ struct ICMPHeaderFactory: ICMPHeaderFactoryProtocol { type: request.type, identifier: request.identifier.id, sequenceNumber: request.sequenceNumber, - payload: Payload(identifier: request.identifier) + payload: Payload(rawUniqueToken: request.identifier.uniqueToken.uuid) ) let checksum = try ICMPChecksum()(icmpHeader: icmpHeader) diff --git a/Sources/pingx/Model/Payload/Payload.swift b/Sources/pingx/Model/Payload/Payload.swift index a263496..acaedb6 100644 --- a/Sources/pingx/Model/Payload/Payload.swift +++ b/Sources/pingx/Model/Payload/Payload.swift @@ -28,16 +28,31 @@ struct Payload: Equatable { // MARK: Properties - let identifier: PingxIdentifier + let rawUniqueToken: uuid_t let timestamp: CFAbsoluteTime // MARK: Initializer init( - identifier: PingxIdentifier, + rawUniqueToken: uuid_t, timestamp: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() ) { - self.identifier = identifier + self.rawUniqueToken = rawUniqueToken self.timestamp = timestamp } + + public 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/Socket/PingxSocket.swift b/Sources/pingx/Model/Socket/PingxSocket.swift index 2a4c2f4..c3ee000 100644 --- a/Sources/pingx/Model/Socket/PingxSocket.swift +++ b/Sources/pingx/Model/Socket/PingxSocket.swift @@ -25,11 +25,11 @@ import Foundation // sourcery: AutoMockable -protocol PingxSocketProtocol { +protocol PingxSocketProtocol: Sendable { func send(address: CFData, data: CFData, timeout: CFTimeInterval) -> CFSocketError } -final class PingxSocket: PingxSocketProtocol { +final class PingxSocket: PingxSocketProtocol, @unchecked Sendable { // MARK: Properties diff --git a/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift b/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift index 4d24b1d..0fe2bd6 100644 --- a/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift +++ b/Sources/pingx/Pinger/Helpers/ICMPPackageExtractor.swift @@ -50,7 +50,7 @@ struct ICMPPacketExtractor: ICMPPacketExtractorProtocol { } private extension ICMPPacketExtractor { - private func validateICMPPackage(_ icmpPackage: ICMPPacket) throws(ICMPResponseValidationError) { + func validateICMPPackage(_ icmpPackage: ICMPPacket) throws(ICMPResponseValidationError) { guard icmpPackage.icmpHeader.type == ICMPType.echoReply.rawValue else { throw ICMPResponseValidationError.invalidType(icmpPackage.icmpHeader) } diff --git a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift index 9c5d41c..7027f00 100644 --- a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift +++ b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift @@ -25,11 +25,11 @@ import Foundation // sourcery: AutoMockable -public protocol AsyncPingerProtocol: AnyObject { +public protocol AsyncPingerProtocol: AnyObject, Sendable { func ping(request: Request) -> AnyPingSequence } -public final class AsyncPinger: AsyncPingerProtocol { +public final class AsyncPinger: AsyncPingerProtocol, @unchecked Sendable { // MARK: Properties @@ -164,10 +164,10 @@ private extension Result { var identifier: Request.Identifier? { switch self { case .success(let icmpPacket): - return icmpPacket.icmpHeader.payload.toRequestIdentifier() + return icmpPacket.icmpHeader.toRequestIdentifier() case .failure(.responseStructureInconsistent(let validationError)): return validationError.icmpHeader.map { icmpHeader in - icmpHeader.payload.toRequestIdentifier() + icmpHeader.toRequestIdentifier() } case .failure: return nil @@ -175,11 +175,11 @@ private extension Result { } } -private extension Payload { +private extension ICMPHeader { func toRequestIdentifier() -> Request.Identifier { Request.Identifier( - id: identifier.id, - uniqueToken: identifier.uniqueToken + id: identifier, + uniqueToken: UUID(uuid: payload.rawUniqueToken) ) } } diff --git a/Tests/pingxTests/Samples/Payload+Sample.swift b/Tests/pingxTests/Samples/Payload+Sample.swift index d286bea..01d8650 100644 --- a/Tests/pingxTests/Samples/Payload+Sample.swift +++ b/Tests/pingxTests/Samples/Payload+Sample.swift @@ -28,11 +28,16 @@ import Foundation extension Payload { static func sample( - identifier: PingxIdentifier = .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( - identifier: identifier, + rawUniqueToken: rawUniqueToken, timestamp: timestamp ) } diff --git a/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift b/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift index 7500f05..72b3bc4 100644 --- a/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift +++ b/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift @@ -112,10 +112,7 @@ private extension ICMPPackageExtractorTests { identifier: request.identifier.id, sequenceNumber: .zero, payload: .sample( - identifier: .sample( - id: request.identifier.id, - uniqueToken: request.identifier.uniqueToken - ) + rawUniqueToken: request.identifier.uniqueToken.uuid ) ) From 0637a944177fcc8b5f6e1e4ce50168faf0bc5604 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 6 Jul 2025 12:35:02 +0200 Subject: [PATCH 19/25] [IMP-3] Update pinger tests --- Sources/pingx/Model/Atomic/Atomic.swift | 2 +- .../Extenstions/Assertion+Extensions.swift | 10 ++- Tests/pingxTests/Mocks/MockPingSequence.swift | 21 +------ .../pingxTests/Mocks/ThreadSafeMockFunc.swift | 63 +++++++++++++++++++ .../pingxTests/Tests/Pinger/PingerTests.swift | 40 ++++++------ 5 files changed, 92 insertions(+), 44 deletions(-) create mode 100644 Tests/pingxTests/Mocks/ThreadSafeMockFunc.swift diff --git a/Sources/pingx/Model/Atomic/Atomic.swift b/Sources/pingx/Model/Atomic/Atomic.swift index 5339717..cc63813 100644 --- a/Sources/pingx/Model/Atomic/Atomic.swift +++ b/Sources/pingx/Model/Atomic/Atomic.swift @@ -29,7 +29,7 @@ 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 { diff --git a/Tests/pingxTests/Extenstions/Assertion+Extensions.swift b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift index 0b531df..de1982d 100644 --- a/Tests/pingxTests/Extenstions/Assertion+Extensions.swift +++ b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift @@ -35,8 +35,7 @@ func expectTo( let task = Task { while !expression() { - try Task.checkCancellation() - try? await Task.sleep(nanoseconds: 20_000_000) // 20ms + try await Task.sleep(nanoseconds: 20_000_000) // 20ms } expectation.fulfill() @@ -63,8 +62,7 @@ func expectNotTo( let task = Task { while !expression() { - try Task.checkCancellation() - try? await Task.sleep(nanoseconds: 20_000_000) // 20ms + try await Task.sleep(nanoseconds: 20_000_000) // 20ms } expectation.fulfill() @@ -89,7 +87,7 @@ func expectToEventuallyBeCalled( await expectTo( expression: { actualCallsCount() == expectedCallsCount }, timeout: timeout, - description: "Wait for actualCallsCount to reach \(expectedCallsCount)", + description: "Wait for actualCallsCount to reach \(expectedCallsCount), got: \((actualCallsCount()))", sourceLocation: sourceLocation ) } @@ -103,7 +101,7 @@ func expectNotToEventuallyBeCalled( await expectNotTo( expression: { actualCallsCount() >= expectedCallsCount }, timeout: timeout, - description: "Wait for actualCallsCount to not reach \(expectedCallsCount)", + description: "Wait for actualCallsCount to not reach \(expectedCallsCount), got: \((actualCallsCount()))", sourceLocation: sourceLocation ) } diff --git a/Tests/pingxTests/Mocks/MockPingSequence.swift b/Tests/pingxTests/Mocks/MockPingSequence.swift index 957b80a..47a80fe 100644 --- a/Tests/pingxTests/Mocks/MockPingSequence.swift +++ b/Tests/pingxTests/Mocks/MockPingSequence.swift @@ -42,30 +42,13 @@ actor MockPingSequence: PingSequenceProtocol { } extension MockPingSequence { - func send( - _ result: PingResult, - sourceLocation: SourceLocation = #_sourceLocation - ) async { - await waitForContinuationIfNeeeded(sourceLocation: sourceLocation) + func send(_ result: PingResult) async { continuation?.resume(returning: result) continuation = nil } - func finish( - sourceLocation: SourceLocation = #_sourceLocation - ) async { - await waitForContinuationIfNeeeded(sourceLocation: sourceLocation) + func finish() async { continuation?.resume(returning: nil) continuation = nil } - - private func waitForContinuationIfNeeeded( - sourceLocation: SourceLocation - ) async { - guard continuation == nil else { return } - await expectNotToEventuallyBeNil( - actualValue: self.continuation, - sourceLocation: sourceLocation - ) - } } 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/Tests/Pinger/PingerTests.swift b/Tests/pingxTests/Tests/Pinger/PingerTests.swift index 9592615..65f3c5e 100644 --- a/Tests/pingxTests/Tests/Pinger/PingerTests.swift +++ b/Tests/pingxTests/Tests/Pinger/PingerTests.swift @@ -46,7 +46,6 @@ struct PingerTests { ) } - @Test("When ping is called, starts pinging using the async pinger") func ping_callsASyncPinger() async { pinger.ping() @@ -59,15 +58,16 @@ struct PingerTests { @Test("When the sequence emits events, calls completion with received events") func ping_whenSequenceEmitsEvent_callsCompletionWithReceivedEvent() async { - let completion = MockFunc() + let completion = ThreadSafeMockFunc() let pingResults: [PingResult] = [ .success(.sample()), .failure(.timeout), .success(.sample()) ] - pinger.ping(completion: completion) - for pingResult in pingResults { + pinger.ping(completion: completion.call) + for (index, pingResult) in pingResults.enumerated() { + await pingSequence.expectNextToEventuallyBeCalled(count: index + 1) await pingSequence.send(pingResult) } @@ -85,15 +85,15 @@ struct PingerTests { pinger.ping(request: request) await pingSequence.finish() - await pingSequence.expectNextNotToEventuallyBeCalled() + await pingSequence.expectNextNotToEventuallyBeCalled(count: 2) } @Test("When request is cancelled, does not emit events after cancellation") func cancel_doesNotEmitEvents() async { - let completion = MockFunc() + let completion = ThreadSafeMockFunc() let request = Request.sample() - pinger.ping(request: request, completion: completion) + pinger.ping(request: request, completion: completion.call) await pingSequence.expectNextToEventuallyBeCalled() pinger.cancel(requestId: request.identifier) @@ -104,8 +104,8 @@ struct PingerTests { @Test("When pinger is deinitialized, cancels all active requests") mutating func deinit_cancelsAllActiveRequests() async { - let completion = MockFunc() - let request = Request.sample() + let completion = ThreadSafeMockFunc() + let request = Request.sample(demand: .unlimited) pinger.ping(request: request) await pingSequence.expectNextToEventuallyBeCalled() @@ -118,29 +118,33 @@ struct PingerTests { } private extension Pinger { - func ping( - request: Request = .sample(), - completion: MockFunc = MockFunc() - ) { + func ping(request: Request = .sample()) { ping( request: request, - completion: completion.call + completion: { _ in } + ) + } + + func ping(completion: @escaping (PingResult) -> Void) { + ping( + request: .sample(), + completion: completion ) } } private extension MockPingSequence { - func expectNextToEventuallyBeCalled() async { + func expectNextToEventuallyBeCalled(count: Int = 1) async { await expectToEventuallyBeCalled( actualCallsCount: self.nextCallsCount, - expectedCallsCount: nextCallsCount + 1 + expectedCallsCount: count ) } - func expectNextNotToEventuallyBeCalled() async { + func expectNextNotToEventuallyBeCalled(count: Int = 1) async { await expectNotToEventuallyBeCalled( actualCallsCount: self.nextCallsCount, - expectedCallsCount: nextCallsCount + 1 + expectedCallsCount: count ) } } From f893e2be7a8700090790ce241572b05b087325b1 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 13 Jul 2025 15:30:59 +0200 Subject: [PATCH 20/25] [IMP-3] Fix misuse issue --- Example/Podfile | 2 +- Example/Podfile.lock | 2 +- Example/pingx.xcodeproj/project.pbxproj | 8 +-- .../Model/Identifier/PingxIdentifier.swift | 2 +- Sources/pingx/Model/Request/Request.swift | 2 +- .../Helpers/SafeCheckedContinuation.swift | 53 +++++++++++++++++++ .../pingx/Pinger/Pingers/AsyncPinger.swift | 4 ++ Sources/pingx/Pinger/Pingers/Pinger.swift | 2 + .../pingx/Pinger/Sequence/PingSequence.swift | 38 +++++-------- .../Extenstions/Assertion+Extensions.swift | 2 +- Tests/pingxTests/Samples/Request+Sample.swift | 2 +- .../pingxTests/Tests/Pinger/PingerTests.swift | 2 +- 12 files changed, 82 insertions(+), 37 deletions(-) create mode 100644 Sources/pingx/Pinger/Helpers/SafeCheckedContinuation.swift 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 a7d1a97..dcda7f5 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -11,6 +11,6 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: 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 782e4ae..0391ba6 100644 --- a/Example/pingx.xcodeproj/project.pbxproj +++ b/Example/pingx.xcodeproj/project.pbxproj @@ -244,14 +244,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-pingx_Example/Pods-pingx_Example-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-pingx_Example/Pods-pingx_Example-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-pingx_Example/Pods-pingx_Example-frameworks.sh\"\n"; @@ -421,7 +417,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; @@ -477,7 +473,7 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_STRICT_CONCURRENCY = complete; VALIDATE_PRODUCT = YES; }; name = Release; diff --git a/Sources/pingx/Model/Identifier/PingxIdentifier.swift b/Sources/pingx/Model/Identifier/PingxIdentifier.swift index 7679303..2ef1652 100644 --- a/Sources/pingx/Model/Identifier/PingxIdentifier.swift +++ b/Sources/pingx/Model/Identifier/PingxIdentifier.swift @@ -29,7 +29,7 @@ public struct PingxIdentifier: Hashable, Sendable { let uniqueToken: UUID init( - id: UInt16, + id: UInt16 = CFSwapInt16HostToBig(UInt16.random(in: 0..: @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/Pingers/AsyncPinger.swift b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift index 7027f00..648741a 100644 --- a/Sources/pingx/Pinger/Pingers/AsyncPinger.swift +++ b/Sources/pingx/Pinger/Pingers/AsyncPinger.swift @@ -111,6 +111,10 @@ extension AsyncPinger { func cancel(requestId: Request.Identifier) { invokeCompletion(identifier: requestId, result: .failure(.cancelled)) } + + func removeCompletion(for requestId: Request.Identifier) { + completions.removeValue(forKey: requestId) + } } // MARK: - Private API diff --git a/Sources/pingx/Pinger/Pingers/Pinger.swift b/Sources/pingx/Pinger/Pingers/Pinger.swift index f08d264..7362945 100644 --- a/Sources/pingx/Pinger/Pingers/Pinger.swift +++ b/Sources/pingx/Pinger/Pingers/Pinger.swift @@ -55,6 +55,8 @@ public final class Pinger: PingerProtocol { request: Request, completion: @escaping (PingResult) -> Void ) { + cancel(requestId: request.identifier) + let task = Task { [weak self] in let sequence = self?.asyncPinger.ping(request: request) diff --git a/Sources/pingx/Pinger/Sequence/PingSequence.swift b/Sources/pingx/Pinger/Sequence/PingSequence.swift index 0980c3a..e9e3de4 100644 --- a/Sources/pingx/Pinger/Sequence/PingSequence.swift +++ b/Sources/pingx/Pinger/Sequence/PingSequence.swift @@ -45,8 +45,6 @@ struct PingSequence: PingSequenceProtocol { mutating func next() async throws -> PingResult? { guard request.demand != .none else { return nil } - try Task.checkCancellation() - if shouldDelayNextPing { try await Task.sleep(nanoseconds: UInt64(configuration.intervalBetweenRequests.nanoseconds)) } else { @@ -55,6 +53,8 @@ struct PingSequence: PingSequenceProtocol { guard let result = await performPingWithTimeout() else { return nil } + try Task.checkCancellation() + if case .cancelled = result.error { request.setDemand(.none) return nil @@ -67,32 +67,22 @@ struct PingSequence: PingSequenceProtocol { } private func performPingWithTimeout() async -> AsyncPingerResult? { - await withTaskGroup( - of: AsyncPingerResult.self, - returning: Optional.self - ) { [weak pinger, request] taskGroup in - taskGroup.addTask { - do { - try await Task.sleep(nanoseconds: UInt64(request.timeoutInterval.nanoseconds)) - } catch {} - - return .failure(.timeout) - } + await withCheckedContinuation { [weak pinger, request] continuation in + let safeContinuation = SafeCheckedContinuation(continuation: continuation) - taskGroup.addTask { - return await withCheckedContinuation { continutaion in - pinger?.ping(request: request) { result in - continutaion.resume(returning: result) - } - } - } + let timeoutTask = Task { + try? await Task.sleep(nanoseconds: UInt64(request.timeoutInterval.nanoseconds)) - defer { - taskGroup.cancelAll() - pinger?.cancel(requestId: request.identifier) + guard !Task.isCancelled else { return } + + pinger?.removeCompletion(for: request.identifier) + safeContinuation.resume(returning: .failure(.timeout)) } - return await taskGroup.next() + pinger?.ping(request: request) { result in + timeoutTask.cancel() + safeContinuation.resume(returning: result) + } } } diff --git a/Tests/pingxTests/Extenstions/Assertion+Extensions.swift b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift index de1982d..b20d314 100644 --- a/Tests/pingxTests/Extenstions/Assertion+Extensions.swift +++ b/Tests/pingxTests/Extenstions/Assertion+Extensions.swift @@ -114,7 +114,7 @@ func expectNotToEventuallyBeNil( await expectTo( expression: { actualValue() != nil }, timeout: timeout, - description: "Wait for actualCallsCount not to be nil", + description: "Wait for actualValue not to be nil", sourceLocation: sourceLocation ) } diff --git a/Tests/pingxTests/Samples/Request+Sample.swift b/Tests/pingxTests/Samples/Request+Sample.swift index b6ee16a..cfb6f8a 100644 --- a/Tests/pingxTests/Samples/Request+Sample.swift +++ b/Tests/pingxTests/Samples/Request+Sample.swift @@ -30,7 +30,7 @@ extension Request { static func sample( id: Request.Identifier = .sample(), destination: IPv4Address = .sample(), - timeoutInterval: Interval = .seconds(1), + timeoutInterval: Interval = .milliseconds(50), demand: Demand = .max(1), sequenceNumber: UInt16 = .zero ) -> Request { diff --git a/Tests/pingxTests/Tests/Pinger/PingerTests.swift b/Tests/pingxTests/Tests/Pinger/PingerTests.swift index 65f3c5e..be54336 100644 --- a/Tests/pingxTests/Tests/Pinger/PingerTests.swift +++ b/Tests/pingxTests/Tests/Pinger/PingerTests.swift @@ -47,7 +47,7 @@ struct PingerTests { } @Test("When ping is called, starts pinging using the async pinger") - func ping_callsASyncPinger() async { + func ping_callsAsyncPinger() async { pinger.ping() await expectToEventuallyBeCalled( From 5187338820e64837753d0f0538ba938d7345aa6f Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 13 Jul 2025 15:56:59 +0200 Subject: [PATCH 21/25] [IMP-3] Minor fix --- Example/pingx.xcodeproj/project.pbxproj | 2 +- .../PingResultDisplayModel.swift | 6 +----- .../AsyncPingView/AsyncPingViewModel.swift | 4 ++-- .../CallbackPingViewModel.swift | 10 +++++----- Sources/pingx/Checksum/ICMPChecksum.swift | 6 +++--- .../Api/IPv4AddressStringConverter.swift | 2 -- Sources/pingx/Model/IPHeader/IPHeader.swift | 15 --------------- .../pingx/Model/Packets/ICMP/ICMPHeader.swift | 11 ----------- Sources/pingx/Model/Payload/Payload.swift | 2 +- Sources/pingx/Model/Request/Request.swift | 7 +++++-- .../pingx/Pinger/Error/AsyncPingerError.swift | 2 +- Sources/pingx/Pinger/Pingers/Pinger.swift | 4 +++- .../ICMPPackageExtractorTests.swift | 19 +++++++++---------- 13 files changed, 31 insertions(+), 59 deletions(-) rename Example/pingx/{Views => }/DisplayModels/PingResultDisplayModel.swift (93%) diff --git a/Example/pingx.xcodeproj/project.pbxproj b/Example/pingx.xcodeproj/project.pbxproj index 0391ba6..271c9f9 100644 --- a/Example/pingx.xcodeproj/project.pbxproj +++ b/Example/pingx.xcodeproj/project.pbxproj @@ -61,7 +61,6 @@ 146766482DE31BC200B89B10 /* Views */ = { isa = PBXGroup; children = ( - 145E71752E0FFF5400F92C01 /* DisplayModels */, 146766532DE322AE00B89B10 /* AsyncPingView */, 1467664A2DE31BD400B89B10 /* HomeView */, 146766492DE31BCD00B89B10 /* CallbackPingView */, @@ -116,6 +115,7 @@ 146872502D23313B00373862 /* pingx */ = { isa = PBXGroup; children = ( + 145E71752E0FFF5400F92C01 /* DisplayModels */, 146766482DE31BC200B89B10 /* Views */, 146872472D23313B00373862 /* AppDelegate.swift */, 146872482D23313B00373862 /* Images.xcassets */, diff --git a/Example/pingx/Views/DisplayModels/PingResultDisplayModel.swift b/Example/pingx/DisplayModels/PingResultDisplayModel.swift similarity index 93% rename from Example/pingx/Views/DisplayModels/PingResultDisplayModel.swift rename to Example/pingx/DisplayModels/PingResultDisplayModel.swift index b9d02bd..8d95610 100644 --- a/Example/pingx/Views/DisplayModels/PingResultDisplayModel.swift +++ b/Example/pingx/DisplayModels/PingResultDisplayModel.swift @@ -25,11 +25,7 @@ import pingx struct PingResultDisplayModel { - private let pingResult: PingResult - - init(pingResult: PingResult) { - self.pingResult = pingResult - } + let pingResult: PingResult func makeUserFriendlyMessage() -> String { switch pingResult { diff --git a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift index 3112fec..8cc3cb3 100644 --- a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift +++ b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift @@ -30,8 +30,8 @@ final class AsyncPingViewModel: ObservableObject { static var destinationAddress: IPv4Address { IPv4Address(address: (8, 8, 8, 8)) } } - @Published var isPingActive = false - @Published var pingResultDisplayModels: [PingResultDisplayModel] = [] + @Published private(set) var isPingActive = false + @Published private(set) var pingResultDisplayModels: [PingResultDisplayModel] = [] private let pinger: AsyncPingerProtocol private var pingTask: Task? = nil diff --git a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift index bb5ab53..01a5b65 100644 --- a/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift +++ b/Example/pingx/Views/CallbackPingView/CallbackPingViewModel.swift @@ -47,6 +47,7 @@ final class CallbackPingViewModel: ObservableObject { } func startPinging() { + isPingActive = true pingResultDisplayModels.removeAll() let demandCount = 5 @@ -54,8 +55,7 @@ final class CallbackPingViewModel: ObservableObject { destination: Constants.destinationAddress, demand: .max(UInt(demandCount)) ) - - isPingActive = true + activeRequest = request pinger.ping(request: request) { [weak self] result in DispatchQueue.main.async { @@ -72,9 +72,9 @@ final class CallbackPingViewModel: ObservableObject { func stopPinging() { isPingActive = false - guard let activeRequest else { return } - pinger.cancel(requestId: activeRequest.identifier) + guard let activeRequestId = activeRequest?.identifier else { return } + pinger.cancel(requestId: activeRequestId) - self.activeRequest = nil + activeRequest = nil } } diff --git a/Sources/pingx/Checksum/ICMPChecksum.swift b/Sources/pingx/Checksum/ICMPChecksum.swift index 9806a8c..84addcd 100644 --- a/Sources/pingx/Checksum/ICMPChecksum.swift +++ b/Sources/pingx/Checksum/ICMPChecksum.swift @@ -28,10 +28,10 @@ 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 = icmpHeader.payload.bytes + let payloadBytes = icmpHeader.payload.bytes - 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)) } + for i in stride(from: 0, to: payloadBytes.count, by: 2) { + sum += Data([payloadBytes[i], payloadBytes[i + 1]]).withUnsafeBytes { UInt64($0.load(as: UInt16.self)) } } sum = (sum >> 16) + (sum & 0xFFFF) diff --git a/Sources/pingx/Converter/Api/IPv4AddressStringConverter.swift b/Sources/pingx/Converter/Api/IPv4AddressStringConverter.swift index 727eabb..d1598da 100644 --- a/Sources/pingx/Converter/Api/IPv4AddressStringConverter.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/Model/IPHeader/IPHeader.swift b/Sources/pingx/Model/IPHeader/IPHeader.swift index a0ee37e..9f493ff 100644 --- a/Sources/pingx/Model/IPHeader/IPHeader.swift +++ b/Sources/pingx/Model/IPHeader/IPHeader.swift @@ -62,19 +62,4 @@ struct IPHeader: Equatable { self.sourceAddress = sourceAddress self.destinationAddress = destinationAddress } - - // MARK: Static - - static func == (lhs: IPHeader, rhs: IPHeader) -> Bool { - lhs.versionAndHeaderLength == rhs.versionAndHeaderLength && - lhs.serviceType == rhs.serviceType && - lhs.totalLength == rhs.totalLength && - lhs.identifier == rhs.identifier && - lhs.flagsAndFragmentOffset == rhs.flagsAndFragmentOffset && - lhs.timeToLive == rhs.timeToLive && - lhs.`protocol` == rhs.`protocol` && - lhs.headerChecksum == rhs.headerChecksum && - lhs.sourceAddress == rhs.sourceAddress && - lhs.destinationAddress == rhs.destinationAddress - } } diff --git a/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift b/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift index 9dc6733..8bc64ae 100644 --- a/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift +++ b/Sources/pingx/Model/Packets/ICMP/ICMPHeader.swift @@ -53,17 +53,6 @@ struct ICMPHeader: Equatable { self.payload = payload } - // MARK: Static - - static func == (lhs: ICMPHeader, rhs: ICMPHeader) -> Bool { - lhs.type == rhs.type && - lhs.code == rhs.code && - lhs.checksum == rhs.checksum && - lhs.identifier == rhs.identifier && - lhs.sequenceNumber == rhs.sequenceNumber && - lhs.payload == rhs.payload - } - // MARK: Methods mutating func setChecksum(_ checksum: UInt16) { diff --git a/Sources/pingx/Model/Payload/Payload.swift b/Sources/pingx/Model/Payload/Payload.swift index acaedb6..95e98bb 100644 --- a/Sources/pingx/Model/Payload/Payload.swift +++ b/Sources/pingx/Model/Payload/Payload.swift @@ -41,7 +41,7 @@ struct Payload: Equatable { self.timestamp = timestamp } - public static func == (lhs: Payload, rhs: Payload) -> Bool { + static func == (lhs: Payload, rhs: Payload) -> Bool { lhs.bytes == rhs.bytes } } diff --git a/Sources/pingx/Model/Request/Request.swift b/Sources/pingx/Model/Request/Request.swift index 28c2c00..f0a072d 100644 --- a/Sources/pingx/Model/Request/Request.swift +++ b/Sources/pingx/Model/Request/Request.swift @@ -45,7 +45,7 @@ public struct Request: Hashable, Sendable { /// The desired quantity of ping requests to be sent. public private(set) var demand: Request.Demand - /// A sequence number to help in matching Echo and Echo Reply messages. + /// A sequence number to help in matching Echo and Echo Reply messages. private(set) var sequenceNumber: UInt16 // MARK: Initializer @@ -79,7 +79,10 @@ public struct Request: Hashable, Sendable { // MARK: Methods public static func == (lhs: Request, rhs: Request) -> Bool { - lhs.identifier == rhs.identifier && lhs.destination == rhs.destination + lhs.identifier == rhs.identifier && + lhs.type == rhs.type && + lhs.destination == rhs.destination && + lhs.timeoutInterval == rhs.timeoutInterval } public func hash(into hasher: inout Hasher) { diff --git a/Sources/pingx/Pinger/Error/AsyncPingerError.swift b/Sources/pingx/Pinger/Error/AsyncPingerError.swift index 478b794..4fa275a 100644 --- a/Sources/pingx/Pinger/Error/AsyncPingerError.swift +++ b/Sources/pingx/Pinger/Error/AsyncPingerError.swift @@ -27,7 +27,7 @@ import Foundation enum AsyncPingerError: CustomNSError { static let errorDomain: String = "pingx.AsyncPingerError" - public var errorCode: Int { + var errorCode: Int { switch self { case .cancelled: 101 diff --git a/Sources/pingx/Pinger/Pingers/Pinger.swift b/Sources/pingx/Pinger/Pingers/Pinger.swift index 7362945..f9cce58 100644 --- a/Sources/pingx/Pinger/Pingers/Pinger.swift +++ b/Sources/pingx/Pinger/Pingers/Pinger.swift @@ -39,7 +39,9 @@ public final class Pinger: PingerProtocol { configuration: PingConfiguration = .default ) { self.init( - asyncPinger: AsyncPinger(configuration: configuration) + asyncPinger: AsyncPinger( + configuration: configuration + ) ) } diff --git a/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift b/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift index 72b3bc4..6a874e2 100644 --- a/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift +++ b/Tests/pingxTests/Tests/ICMPPackageExtractor/ICMPPackageExtractorTests.swift @@ -121,8 +121,8 @@ private extension ICMPPackageExtractorTests { } icmpHeader.setChecksum(checksum) - var icmpPacket = ICMPPacket(ipHeader: ipHeader, icmpHeader: icmpHeader) - let data = withUnsafeBytes(of: &icmpPacket) { Data($0) } + let icmpPacket = ICMPPacket(ipHeader: ipHeader, icmpHeader: icmpHeader) + let data = withUnsafeBytes(of: icmpPacket) { Data($0) } return data } @@ -138,19 +138,18 @@ private extension ICMPPackageExtractorTests { sourceAddress: request.destination, destinationAddress: request.destination ) - let data: Data if let icmpHeader { - var icmp = ICMPPacket.sample(ipHeader: ipHeader, icmpHeader: icmpHeader) - data = withUnsafeBytes(of: &icmp) { Data($0) } - } else if shouldAddIpHeader { + let icmpPacket = ICMPPacket.sample(ipHeader: ipHeader, icmpHeader: icmpHeader) + return withUnsafeBytes(of: icmpPacket) { Data($0) } + } + + if shouldAddIpHeader { var ipHeader = ipHeader - data = Data(bytes: &ipHeader, count: MemoryLayout.size) - } else { - data = Data() + return Data(bytes: &ipHeader, count: MemoryLayout.size) } - return data + return Data() } } From f4b7f0b85ce50bb9ebbaf2971fc9fb582c3b05fc Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 13 Jul 2025 16:05:54 +0200 Subject: [PATCH 22/25] [IMP-3] Fix lint issues --- .../pingx/Views/AsyncPingView/AsyncPingViewModel.swift | 4 ++-- Sources/pingx/Checksum/ICMPChecksum.swift | 7 +++++-- .../pingx/Factory/SocketFactory/SocketFactory.swift | 10 ++++------ Tests/pingxTests/Tests/Request/DemandTests.swift | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift index 8cc3cb3..8fe89e9 100644 --- a/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift +++ b/Example/pingx/Views/AsyncPingView/AsyncPingViewModel.swift @@ -34,7 +34,7 @@ final class AsyncPingViewModel: ObservableObject { @Published private(set) var pingResultDisplayModels: [PingResultDisplayModel] = [] private let pinger: AsyncPingerProtocol - private var pingTask: Task? = nil + private var pingTask: Task? init(pinger: AsyncPingerProtocol) { self.pinger = pinger @@ -59,7 +59,7 @@ final class AsyncPingViewModel: ObservableObject { let sequence = pinger.ping(request: request) for try await result in sequence { - guard !Task.isCancelled else { break } + guard !Task.isCancelled else { break } DispatchQueue.main.async { [weak self] in let displayModel = PingResultDisplayModel(pingResult: result) diff --git a/Sources/pingx/Checksum/ICMPChecksum.swift b/Sources/pingx/Checksum/ICMPChecksum.swift index 84addcd..a0821ad 100644 --- a/Sources/pingx/Checksum/ICMPChecksum.swift +++ b/Sources/pingx/Checksum/ICMPChecksum.swift @@ -30,8 +30,11 @@ struct ICMPChecksum { var sum = UInt64(typecode) + UInt64(icmpHeader.identifier) + UInt64(icmpHeader.sequenceNumber) let payloadBytes = icmpHeader.payload.bytes - for i in stride(from: 0, to: payloadBytes.count, by: 2) { - sum += Data([payloadBytes[i], payloadBytes[i + 1]]).withUnsafeBytes { UInt64($0.load(as: UInt16.self)) } + 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) diff --git a/Sources/pingx/Factory/SocketFactory/SocketFactory.swift b/Sources/pingx/Factory/SocketFactory/SocketFactory.swift index 684f99e..131f59a 100644 --- a/Sources/pingx/Factory/SocketFactory/SocketFactory.swift +++ b/Sources/pingx/Factory/SocketFactory/SocketFactory.swift @@ -72,12 +72,10 @@ struct SocketFactory: SocketFactoryProtocol { throw AsyncPingerError.socketCreationError } - guard let socketSource = CFSocketCreateRunLoopSource( - kCFAllocatorDefault, - socket, - .zero - ) else { throw AsyncPingerError.socketCreationError } - + guard let socketSource = CFSocketCreateRunLoopSource(kCFAllocatorDefault, socket, .zero) else { + throw AsyncPingerError.socketCreationError + } + CFRunLoopAddSource( CFRunLoopGetMain(), socketSource, diff --git a/Tests/pingxTests/Tests/Request/DemandTests.swift b/Tests/pingxTests/Tests/Request/DemandTests.swift index 92b51ef..ceca360 100644 --- a/Tests/pingxTests/Tests/Request/DemandTests.swift +++ b/Tests/pingxTests/Tests/Request/DemandTests.swift @@ -35,7 +35,7 @@ struct DemandTests { arguments: [ (demand: Demand.none, expectedValue: UInt(0)), (demand: Demand.unlimited, expectedValue: nil), - (demand: Demand.max(2), expectedValue: UInt(2)), + (demand: Demand.max(2), expectedValue: UInt(2)) ] ) func demand_initialization(demand: Demand, expectedValue: UInt?) { From 122b5b298dd048d95fa6d40864840c6e66fe55a6 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 13 Jul 2025 16:07:19 +0200 Subject: [PATCH 23/25] Update swift.yml --- .github/workflows/swift.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 06d44a4..7f3fa81 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.4 - 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.4 - 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.4,name=iPhone 16 Pro" - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4.0.1 with: From 0629fdb8a167b8a24cb32ee7082ffa7c51db14c6 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 13 Jul 2025 16:08:25 +0200 Subject: [PATCH 24/25] Update swift.yml --- .github/workflows/swift.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 7f3fa81..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.4 + 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.4 + 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.4,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: From 92bc0a2493ccd11873b50bf72db465af985afe64 Mon Sep 17 00:00:00 2001 From: Ilya Baryka Date: Sun, 13 Jul 2025 16:14:34 +0200 Subject: [PATCH 25/25] [IMP-3] Increase timeout --- Tests/pingxTests/Samples/Request+Sample.swift | 2 +- Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Tests/pingxTests/Samples/Request+Sample.swift b/Tests/pingxTests/Samples/Request+Sample.swift index cfb6f8a..b6ee16a 100644 --- a/Tests/pingxTests/Samples/Request+Sample.swift +++ b/Tests/pingxTests/Samples/Request+Sample.swift @@ -30,7 +30,7 @@ extension Request { static func sample( id: Request.Identifier = .sample(), destination: IPv4Address = .sample(), - timeoutInterval: Interval = .milliseconds(50), + timeoutInterval: Interval = .seconds(1), demand: Demand = .max(1), sequenceNumber: UInt16 = .zero ) -> Request { diff --git a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift index 032440d..9debe8b 100644 --- a/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift +++ b/Tests/pingxTests/Tests/Pinger/AsyncPingerTests.swift @@ -312,8 +312,7 @@ struct AsyncPingerTests { ) socketFactory.makeReceivedCommand?.closure(Data()) } - }, - timeout: .milliseconds(150) + } ) } @@ -348,8 +347,7 @@ struct AsyncPingerTests { actualCallsCount: socket.sendCallsCount, expectedCallsCount: 2 ) - }, - timeout: .milliseconds(100) + } ) } @@ -400,7 +398,7 @@ private extension AsyncPingerTests { sequence: some PingSequenceProtocol, emits expectedValues: [PingResult], after operation: (() async -> Void)? = nil, - timeout: Interval = .milliseconds(50), + timeout: Interval = .seconds(1), sourceLocation: SourceLocation = #_sourceLocation ) async throws { let collectingValuesTask = Task {