diff --git a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj index b072faccf9..419b659897 100644 --- a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj +++ b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj @@ -126,6 +126,9 @@ C0D3F3601F9F3AD80055C7CF /* InstallableKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F35F1F9F3AD80055C7CF /* InstallableKeyboard.swift */; }; C0E30C8C1FC40D0400C80416 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E30C8B1FC40D0400C80416 /* Storage.swift */; }; C0EF3E7B1F95B65300CE9BD4 /* KeymanWebDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0EF3E7A1F95B65300CE9BD4 /* KeymanWebDelegate.swift */; }; + CE07E3D2247E3FB100DFA9D2 /* HTTPDownloaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE07E3D1247E3FB100DFA9D2 /* HTTPDownloaderTests.swift */; }; + CE07E3D6247E41DB00DFA9D2 /* URLSessionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE07E3D5247E41DB00DFA9D2 /* URLSessionMock.swift */; }; + CE07E3D8247E43CB00DFA9D2 /* URLSessionDownloadTaskMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE07E3D7247E43CB00DFA9D2 /* URLSessionDownloadTaskMock.swift */; }; CE17ABDE23069E76005FBB14 /* LanguageResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE17ABDD23069E76005FBB14 /* LanguageResource.swift */; }; CE1E1EC12303C8CC001C7BE0 /* ResourceDownloadStatusToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1E1EC02303C8CC001C7BE0 /* ResourceDownloadStatusToolbar.swift */; }; CE1F67A32304EB3800FF6972 /* ResourceDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F67A22304EB3800FF6972 /* ResourceDownloadManager.swift */; }; @@ -174,6 +177,7 @@ CE808A4D236697D500713E6B /* Reachability.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A0FC9FC22D66D9E00D33F86 /* Reachability.framework */; }; CE808A4F236697D800713E6B /* XCGLogger.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1687ACCC1FD8DE5300926D69 /* XCGLogger.framework */; }; CE808A532366980A00713E6B /* Zip.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 165C8B8B1FD8220600D4A78D /* Zip.framework */; }; + CE88042F247F4B97009BCA1A /* ExpectationDownloaderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE88042E247F4B97009BCA1A /* ExpectationDownloaderDelegate.swift */; }; CE8EDEB123F53D1A009E1FF6 /* FileManagementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A079DD1223194B100581263 /* FileManagementTests.swift */; }; CE8EDEB323F53F96009E1FF6 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8EDEB223F53F96009E1FF6 /* VersionTests.swift */; }; CE976D4F23FA380700FFDF3A /* Reachability.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A0FC9FC22D66D9E00D33F86 /* Reachability.framework */; }; @@ -420,6 +424,9 @@ C0ED71B21F6BB0B1002A2FD6 /* HTTPDownloadRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPDownloadRequest.swift; sourceTree = ""; }; C0ED71B41F6BBFAF002A2FD6 /* HTTPDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPDownloader.swift; sourceTree = ""; }; C0EF3E7A1F95B65300CE9BD4 /* KeymanWebDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeymanWebDelegate.swift; sourceTree = ""; }; + CE07E3D1247E3FB100DFA9D2 /* HTTPDownloaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPDownloaderTests.swift; sourceTree = ""; }; + CE07E3D5247E41DB00DFA9D2 /* URLSessionMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionMock.swift; sourceTree = ""; }; + CE07E3D7247E43CB00DFA9D2 /* URLSessionDownloadTaskMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionDownloadTaskMock.swift; sourceTree = ""; }; CE17ABDD23069E76005FBB14 /* LanguageResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageResource.swift; sourceTree = ""; }; CE1E1EC02303C8CC001C7BE0 /* ResourceDownloadStatusToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceDownloadStatusToolbar.swift; sourceTree = ""; }; CE1F67A22304EB3800FF6972 /* ResourceDownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceDownloadManager.swift; sourceTree = ""; }; @@ -443,6 +450,7 @@ CE7A26D723CEEC630005955C /* Colors+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Colors+Extension.swift"; sourceTree = ""; }; CE7A26DA23CEEF640005955C /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; CE7ADD6523DE89FC00BC9A00 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = ""; }; + CE88042E247F4B97009BCA1A /* ExpectationDownloaderDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpectationDownloaderDelegate.swift; sourceTree = ""; }; CE8EDEB223F53F96009E1FF6 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = ""; }; CE9B440B23FE49F000499CAB /* Migrations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Migrations.swift; sourceTree = ""; }; CE9B440D23FE4E7400499CAB /* 12.0 Ad-hoc Migration.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = "12.0 Ad-hoc Migration.bundle"; sourceTree = ""; }; @@ -684,6 +692,7 @@ CE9CD88123FCC4C7002BF2F8 /* TestUtils */, CE37C38923FCC9AF007031C6 /* resources */, 9A079DD1223194B100581263 /* FileManagementTests.swift */, + CE07E3D1247E3FB100DFA9D2 /* HTTPDownloaderTests.swift */, 9A079DD3223194B100581263 /* Info.plist */, CE8EDEB223F53F96009E1FF6 /* VersionTests.swift */, CE4459CD23FBBA8D003151FD /* AppDelegate.swift */, @@ -784,6 +793,16 @@ path = KeyboardRepository; sourceTree = ""; }; + CE07E3D3247E418800DFA9D2 /* Downloading */ = { + isa = PBXGroup; + children = ( + CE07E3D5247E41DB00DFA9D2 /* URLSessionMock.swift */, + CE07E3D7247E43CB00DFA9D2 /* URLSessionDownloadTaskMock.swift */, + CE88042E247F4B97009BCA1A /* ExpectationDownloaderDelegate.swift */, + ); + path = Downloading; + sourceTree = ""; + }; CE25CCBB1F1DA72A005AA2BC /* HTTPRequest */ = { isa = PBXGroup; children = ( @@ -829,6 +848,7 @@ CE9CD88123FCC4C7002BF2F8 /* TestUtils */ = { isa = PBXGroup; children = ( + CE07E3D3247E418800DFA9D2 /* Downloading */, CE9CD87F23FCC1CA002BF2F8 /* TestUtils.swift */, CE9CD88223FCC4E3002BF2F8 /* UserDefaults.swift */, CE37C38C23FCD41E007031C6 /* Keyboards.swift */, @@ -1043,6 +1063,7 @@ C06D37281F81F4E100F61AE0 /* Headers */, C06D37291F81F4E100F61AE0 /* Resources */, CEC0C66F2410B005003E1BCD /* ShellScript */, + CE880430247F6EB6009BCA1A /* ShellScript */, ); buildRules = ( ); @@ -1268,6 +1289,24 @@ shellPath = /bin/sh; shellScript = "#if which swiftlint >/dev/null; then\n# swiftlint --config ../../.swiftlint.yml\n#else\n# echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\n#fi\n"; }; + CE880430247F6EB6009BCA1A /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n. \"$KEYMAN_ROOT/resources/build/xcode-utils.sh\"\n\nphaseSetBundleVersions\n"; + showEnvVarsInLog = 0; + }; CEC0C66F2410B005003E1BCD /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1285,6 +1324,7 @@ runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n. \"$KEYMAN_ROOT/resources/build/xcode-utils.sh\"\n\nif [ ${UPLOAD_SENTRY:-false} = true ]; then\n # Calls resource script to perform the dSYM upload\n phaseSentryDsymUpload \"keyman-ios\"\nfi\n"; + showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -1302,11 +1342,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CE07E3D2247E3FB100DFA9D2 /* HTTPDownloaderTests.swift in Sources */, CE9B440C23FE49F000499CAB /* Migrations.swift in Sources */, + CE88042F247F4B97009BCA1A /* ExpectationDownloaderDelegate.swift in Sources */, + CE07E3D8247E43CB00DFA9D2 /* URLSessionDownloadTaskMock.swift in Sources */, CE37C39123FCD617007031C6 /* LexicalModels.swift in Sources */, CE37C38D23FCD41E007031C6 /* Keyboards.swift in Sources */, CE9CD88323FCC4E3002BF2F8 /* UserDefaults.swift in Sources */, CEF888D523FE786C00667693 /* KeyboardScaleTests.swift in Sources */, + CE07E3D6247E41DB00DFA9D2 /* URLSessionMock.swift in Sources */, CE8EDEB323F53F96009E1FF6 /* VersionTests.swift in Sources */, CEDFEF8F23FE43B700BECF39 /* MigrationTests.swift in Sources */, CE9CD88023FCC1CA002BF2F8 /* TestUtils.swift in Sources */, diff --git a/ios/engine/KMEI/KeymanEngine/Classes/HTTPDownloadRequest.swift b/ios/engine/KMEI/KeymanEngine/Classes/HTTPDownloadRequest.swift index 23ca0e0815..d5b2a81fe0 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/HTTPDownloadRequest.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/HTTPDownloadRequest.swift @@ -8,7 +8,7 @@ import Foundation -enum DownloadType { +enum DownloadType: String { case downloadFile } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/HTTPDownloader.swift b/ios/engine/KMEI/KeymanEngine/Classes/HTTPDownloader.swift index 7e070a612c..590491791a 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/HTTPDownloader.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/HTTPDownloader.swift @@ -24,10 +24,10 @@ class HTTPDownloader: NSObject { var downloadSession: URLSession! public var userInfo: [String: Any] = [:] - init(_ handler: HTTPDownloadDelegate?) { + init(_ handler: HTTPDownloadDelegate?, session: URLSession = .shared) { super.init() self.handler = handler - downloadSession = URLSession.shared + downloadSession = session } func addRequest(_ request: HTTPDownloadRequest) { diff --git a/ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift b/ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift new file mode 100644 index 0000000000..fde140c1b8 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift @@ -0,0 +1,143 @@ +// +// HTTPDownloaderTests.swift +// KeymanEngineTests +// +// Created by Joshua Horton on 5/27/20. +// Copyright © 2020 SIL International. All rights reserved. +// + +import XCTest +@testable import KeymanEngine + +class HTTPDownloaderTests: XCTestCase { + var downloader: HTTPDownloader? + var mockedURLSession: TestUtils.Downloading.URLSessionMock? + + override func setUp() { + mockedURLSession = TestUtils.Downloading.URLSessionMock() + downloader = HTTPDownloader(nil, session: mockedURLSession!) + } + + override func tearDownWithError() throws { + let queueWasCleared = mockedURLSession!.queueIsEmpty + mockedURLSession = nil + downloader = nil + + if !queueWasCleared { + throw NSError(domain: "Keyman", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "A test did not fully utilize its queued mock results!"]) + } + } + + func testSingleRequestSuccess() throws { + // Simple, easy case: our local testing copy of the Khmer Angkor KMP file. + let mockedResult = TestUtils.Downloading.DownloadMockResult(location: TestUtils.Keyboards.khmerAngkorKMP, error: nil) + mockedURLSession?.queueMockResult(mockedResult) + + let testDelegate = TestUtils.Downloading.ExpectationDownloaderDelegate() + downloader!.handler = testDelegate + + let request = HTTPDownloadRequest(url: TestUtils.Keyboards.khmerAngkorKMP) + downloader!.addRequest(request) + testDelegate.expect(method: .RequestStarted, request: request) + testDelegate.expect(method: .RequestFinished, request: request) + testDelegate.expect(method: .QueueFinished, queue: downloader!) + + XCTAssertEqual(downloader!.requestsCount, 1) + downloader!.run() + testDelegate.sequentialWait(testCase: self, timeout: 10) + } + + func testSingleRequestFailure() throws { + // A twist on the previous version - the 'download' fails. + let mockedResult = TestUtils.Downloading.DownloadMockResult(location: nil, error: NSError(domain: "KeymanTests", code: 1, userInfo: nil)) + mockedURLSession?.queueMockResult(mockedResult) + + let testDelegate = TestUtils.Downloading.ExpectationDownloaderDelegate() + downloader!.handler = testDelegate + + let request = HTTPDownloadRequest(url: TestUtils.Keyboards.khmerAngkorKMP) + downloader!.addRequest(request) + testDelegate.expect(method: .RequestStarted, request: request) + testDelegate.expect(method: .RequestFailed, request: request) + testDelegate.expect(method: .QueueFinished, queue: downloader!) + + XCTAssertEqual(downloader!.requestsCount, 1) + downloader!.run() + testDelegate.sequentialWait(testCase: self, timeout: 10) + } + + func testSequentialRequests() { + let mockedResult1 = TestUtils.Downloading.DownloadMockResult(location: TestUtils.Keyboards.khmerAngkorKMP, error: nil) + mockedURLSession?.queueMockResult(mockedResult1) + + let mockedResult2 = TestUtils.Downloading.DownloadMockResult(location: + TestUtils.LexicalModels.mtntKMP, error: nil) + mockedURLSession?.queueMockResult(mockedResult2) + + let testDelegate = TestUtils.Downloading.ExpectationDownloaderDelegate() + downloader!.handler = testDelegate + + let request1 = HTTPDownloadRequest(url: TestUtils.Keyboards.khmerAngkorKMP) + downloader!.addRequest(request1) + testDelegate.expect(method: .RequestStarted, request: request1) + testDelegate.expect(method: .RequestFinished, request: request1) + + let request2 = HTTPDownloadRequest(url: TestUtils.LexicalModels.mtntKMP) + downloader!.addRequest(request2) + testDelegate.expect(method: .RequestStarted, request: request2) + testDelegate.expect(method: .RequestFinished, request: request2) + testDelegate.expect(method: .QueueFinished, queue: downloader!) + + XCTAssertEqual(downloader!.requestsCount, 2) + downloader!.run() + // This is currently safe, as all downloads are sequential. If we ever + // enable parallel downloads, this will obviously need to change + testDelegate.sequentialWait(testCase: self, timeout: 10) + } + + func testRequestCancellation() { + let mockedResult1 = TestUtils.Downloading.DownloadMockResult(location: TestUtils.Keyboards.khmerAngkorKMP, error: nil) + mockedURLSession?.queueMockResult(mockedResult1) + + // No second mocked result - its call should be cancelled. + + let testDelegate = TestUtils.Downloading.ExpectationDownloaderDelegate() + downloader!.handler = testDelegate + + let request1 = HTTPDownloadRequest(url: TestUtils.Keyboards.khmerAngkorKMP) + downloader!.addRequest(request1) + testDelegate.expect(method: .RequestStarted, request: request1) + + let request2 = HTTPDownloadRequest(url: TestUtils.LexicalModels.mtntKMP) + downloader!.addRequest(request2) + testDelegate.expectAbsence(method: .RequestStarted, request: request2) + testDelegate.expectAbsence(method: .RequestFinished, request: request2) + testDelegate.expect(method: .QueueCancelled, queue: downloader!) + + // Placed here for chronological ordering. We expect the request to finish because of + // our mocking structure, but want to delay its completion long enough for the + // `cancelAllOperations` call below to complete. + testDelegate.expect(method: .RequestFinished, request: request1, completion: { response in + // Force a delay so that our `cancelAllOperations` call has a chance to execute; + // our mocking structure is otherwise synchronous! + sleep(1) // in seconds. + response.expectation.fulfill() + return false + }) + + // Ideally, we could add this... but because of how things are currently mocked, + // it actually does trigger - we have a .RequestFinished processing after .QueueCancelled, + // which then notes the empty queue. + // + // testDelegate.expectAbsence(method: .QueueFinished, queue: downloader!) + + XCTAssertEqual(downloader!.requestsCount, 2) + downloader!.run() + downloader!.cancelAllOperations() + // This is currently safe, as all downloads are sequential. If we ever + // enable parallel downloads, this will obviously need to change + testDelegate.sequentialWait(testCase: self, timeout: 5) + } +} diff --git a/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/ExpectationDownloaderDelegate.swift b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/ExpectationDownloaderDelegate.swift new file mode 100644 index 0000000000..00fa160f7b --- /dev/null +++ b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/ExpectationDownloaderDelegate.swift @@ -0,0 +1,138 @@ +// +// BaseTestDownloaderDelegate.swift +// KeymanEngineTests +// +// Created by Joshua Horton on 5/28/20. +// Copyright © 2020 SIL International. All rights reserved. +// + +import Foundation +import XCTest +@testable import KeymanEngine + +extension TestUtils.Downloading { + class ExpectationDownloaderDelegate: HTTPDownloadDelegate { + enum Method: String { + case RequestStarted + case RequestFinished + case RequestFailed + case QueueFinished + case QueueCancelled + } + + typealias ExpectationMetBlock = (ExpectationQueueEntry) -> Bool + + struct ExpectationQueueEntry { + let method: Method + let request: HTTPDownloadRequest? + let queue: HTTPDownloader? + let completion: ExpectationMetBlock? + let expectation: XCTestExpectation + + init(method: Method, request: HTTPDownloadRequest, completion: ExpectationMetBlock? = nil) { + self.init(method: method, request: request, queue: nil, completion: completion) + } + + init(method: Method, queue: HTTPDownloader, completion: ExpectationMetBlock? = nil) { + self.init(method: method, request: nil, queue: queue, completion: completion) + } + + private init(method: Method, request: HTTPDownloadRequest?, queue: HTTPDownloader?, completion: ExpectationMetBlock? = nil) { + self.method = method + self.request = request + self.queue = queue + self.completion = completion + + // Now to dynamically build the description test for the Expectation, then the Expectation itself + var description: String + switch method { + case .RequestStarted, .RequestFinished, .RequestFailed: + description = "\(method.rawValue) for \(request!.typeCode.rawValue) from \(request!.url)" + case .QueueFinished, .QueueCancelled: + description = "\(method.rawValue)" + } + self.expectation = XCTestExpectation(description: description) + } + } + + private var expectationQueue: [ExpectationQueueEntry] = [] + private var expectedAbsences: [ExpectationQueueEntry] = [] + + private func doExpectationMatch(method: Method, request: HTTPDownloadRequest? = nil, queue: HTTPDownloader? = nil) { + + if expectationQueue.count == 0 { + return + } else { + let head = expectationQueue[0] + if head.method == method && head.request == request && head.queue == queue { + expectationQueue.removeFirst() + + // Now to look to fulfill the expectation. + if head.completion != nil { + if head.completion!(head) { + head.expectation.fulfill() + } + } else { + head.expectation.fulfill() + } + } + } + } + + func downloadRequestStarted(_ request: HTTPDownloadRequest) { + doExpectationMatch(method: .RequestStarted, request: request) + } + + func downloadRequestFinished(_ request: HTTPDownloadRequest) { + doExpectationMatch(method: .RequestFinished, request: request) + } + + func downloadRequestFailed(_ request: HTTPDownloadRequest) { + doExpectationMatch(method: .RequestFailed, request: request) + } + + func downloadQueueFinished(_ queue: HTTPDownloader) { + doExpectationMatch(method: .QueueFinished, queue: queue) + } + + func downloadQueueCancelled(_ queue: HTTPDownloader) { + doExpectationMatch(method: .QueueCancelled, queue: queue) + } + + func expect(method: Method, request: HTTPDownloadRequest, completion: ExpectationMetBlock? = nil) { + expectationQueue.append(ExpectationQueueEntry(method: method, request: request, completion: completion)) + } + + func expect(method: Method, queue: HTTPDownloader, completion: ExpectationMetBlock? = nil) { + expectationQueue.append(ExpectationQueueEntry(method: method, queue: queue, completion: completion)) + } + + func expectAbsence(method: Method, request: HTTPDownloadRequest, completion: ExpectationMetBlock? = nil) { + let entry = ExpectationQueueEntry(method: method, request: request, completion: completion) + entry.expectation.isInverted = true + expectedAbsences.append(entry) + } + + func expectAbsence(method: Method, queue: HTTPDownloader, completion: ExpectationMetBlock? = nil) { + let entry = ExpectationQueueEntry(method: method, queue: queue, completion: completion) + entry.expectation.isInverted = true + expectedAbsences.append(entry) + } + + func sequentialWait(testCase: XCTestCase, timeout: TimeInterval = 10) { +// while expectationQueue.count > 0 { +// testCase.wait(for: [expectationQueue[0].expectation], timeout: timeout) +// } + var expectationList: [XCTestExpectation] = expectationQueue.map { entry in + return entry.expectation + } + + let absenceList: [XCTestExpectation] = expectedAbsences.map { entry in + return entry.expectation + } + + expectationList.append(contentsOf: absenceList) + testCase.wait(for: expectationList, timeout: timeout, enforceOrder: true) + } + } +} diff --git a/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionDownloadTaskMock.swift b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionDownloadTaskMock.swift new file mode 100644 index 0000000000..0bd6604443 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionDownloadTaskMock.swift @@ -0,0 +1,30 @@ +// +// URLSessionDownloadTaskMock.swift +// KeymanEngineTests +// +// Created by Joshua Horton on 5/27/20. +// Copyright © 2020 SIL International. All rights reserved. +// + +import Foundation + +extension TestUtils.Downloading { + /** + * Many thanks to https://www.swiftbysundell.com/articles/mocking-in-swift/ for the approach used here. + */ + class URLSessionDownloadTaskMock: URLSessionDownloadTask { + private let closure: () -> Void + + init(closure: @escaping () -> Void) { + self.closure = closure + } + + /* + * For mocked DownloadTasks, just use the precomputed completion closure + * provided to the constructor. + */ + override func resume() { + closure() + } + } +} diff --git a/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionMock.swift b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionMock.swift new file mode 100644 index 0000000000..46d2d8e99f --- /dev/null +++ b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionMock.swift @@ -0,0 +1,57 @@ +// +// URLSessionMock.swift +// KeymanEngineTests +// +// Created by Joshua Horton on 5/27/20. +// Copyright © 2020 SIL International. All rights reserved. +// + +import Foundation + +extension TestUtils.Downloading { + class DownloadMockResult { + let location: URL? + let error: Error? + + init(location: URL?, error: Error?) { + self.location = location + self.error = error + } + } + + /** + * Many thanks to https://www.swiftbysundell.com/articles/mocking-in-swift/ for the approach used here. + */ + class URLSessionMock: URLSession { + private var mockedResultQueue: [DownloadMockResult] + + override init() { + mockedResultQueue = [] + } + + func queueMockResult(_ result: DownloadMockResult) { + mockedResultQueue.append(result) + } + + var queueIsEmpty: Bool { + get { + return mockedResultQueue.isEmpty + } + } + + override func downloadTask(with url: URL, completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTask { + + if(mockedResultQueue.count <= 0) { + fatalError("No mocked results have been provided for this downloadTask call!") + } else { + return URLSessionDownloadTaskMock { + let response = self.mockedResultQueue.removeFirst() + + // A bit of white-box testing - we know that HTTPDownloader doesn't actually examine + // the URLResponse headers, so we don't provide any. + completionHandler(response.location, nil, response.error) + } + } + } + } +} diff --git a/ios/engine/KMEI/KeymanEngineTests/TestUtils/TestUtils.swift b/ios/engine/KMEI/KeymanEngineTests/TestUtils/TestUtils.swift index 42fd9e6066..536faa5eac 100644 --- a/ios/engine/KMEI/KeymanEngineTests/TestUtils/TestUtils.swift +++ b/ios/engine/KMEI/KeymanEngineTests/TestUtils/TestUtils.swift @@ -16,6 +16,11 @@ import Foundation * It's a common Swift pattern, used because enums can't be instantiated. */ enum TestUtils { + // Sets up an internally-nested "namespace". + enum Downloading { + + } + static let mainBundle = Bundle(for: FileManagementTests.self) // Can't use TestUtils, as it's an enum static func findSubBundle(forResource resource: String, ofType type: String) -> Bundle {