From 047afc1ec0dbe64341aeb8816c25489db96a82be Mon Sep 17 00:00:00 2001 From: jahorton Date: Thu, 28 May 2020 10:12:49 +0700 Subject: [PATCH 1/4] feat(ios): introduces HTTPDownloader unit testing --- .../KeymanEngine.xcodeproj/project.pbxproj | 24 +++ .../Classes/HTTPDownloadRequest.swift | 2 +- .../KeymanEngine/Classes/HTTPDownloader.swift | 4 +- .../HTTPDownloaderTests.swift | 138 ++++++++++++++++++ .../ExpectationDownloaderDelegate.swift | 131 +++++++++++++++++ .../URLSessionDownloadTaskMock.swift | 27 ++++ .../Downloading/URLSessionMock.swift | 54 +++++++ .../TestUtils/TestUtils.swift | 5 + 8 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift create mode 100644 ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/ExpectationDownloaderDelegate.swift create mode 100644 ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionDownloadTaskMock.swift create mode 100644 ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionMock.swift diff --git a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj index b072faccf9..5dc6411e15 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 */, @@ -1302,11 +1322,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..85ef5d1385 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift @@ -0,0 +1,138 @@ +// +// 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 completely synchronous! + log.debug("DEBUG: Expectation callback detected!") + sleep(2) // 2 seconds. + response.expectation.fulfill() + return false + }) + + 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: 10) + } +} 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..aa6ddd17d8 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/ExpectationDownloaderDelegate.swift @@ -0,0 +1,131 @@ +// +// 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 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) { + var entry = ExpectationQueueEntry(method: method, request: request, completion: completion) + entry.expectation.isInverted = true + expectationQueue.append(entry) + } + + func expectAbsence(method: Method, queue: HTTPDownloader, completion: ExpectationMetBlock? = nil) { + var entry = ExpectationQueueEntry(method: method, queue: queue, completion: completion) + entry.expectation.isInverted = true + expectationQueue.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 + } + + 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..2bcedfde07 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionDownloadTaskMock.swift @@ -0,0 +1,27 @@ +// +// URLSessionDownloadTaskMock.swift +// KeymanEngineTests +// +// Created by Joshua Horton on 5/27/20. +// Copyright © 2020 SIL International. All rights reserved. +// + +import Foundation + +extension TestUtils.Downloading { + 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..87227378c9 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionMock.swift @@ -0,0 +1,54 @@ +// +// 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 + } + } + + 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 { From f4591d3620ed7697d1e523462d31f271354f8a0e Mon Sep 17 00:00:00 2001 From: jahorton Date: Thu, 28 May 2020 10:56:33 +0700 Subject: [PATCH 2/4] feat(ios): operational queue-cancellation test --- .../KeymanEngineTests/HTTPDownloaderTests.swift | 8 +++++++- .../ExpectationDownloaderDelegate.swift | 15 +++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift b/ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift index 85ef5d1385..5d2e5bbe72 100644 --- a/ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift +++ b/ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift @@ -121,13 +121,19 @@ class HTTPDownloaderTests: XCTestCase { // `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 completely synchronous! + // our mocking structure is otherwise synchronous! log.debug("DEBUG: Expectation callback detected!") sleep(2) // 2 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() diff --git a/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/ExpectationDownloaderDelegate.swift b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/ExpectationDownloaderDelegate.swift index aa6ddd17d8..00fa160f7b 100644 --- a/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/ExpectationDownloaderDelegate.swift +++ b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/ExpectationDownloaderDelegate.swift @@ -56,8 +56,10 @@ extension TestUtils.Downloading { } 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 { @@ -106,15 +108,15 @@ extension TestUtils.Downloading { } func expectAbsence(method: Method, request: HTTPDownloadRequest, completion: ExpectationMetBlock? = nil) { - var entry = ExpectationQueueEntry(method: method, request: request, completion: completion) + let entry = ExpectationQueueEntry(method: method, request: request, completion: completion) entry.expectation.isInverted = true - expectationQueue.append(entry) + expectedAbsences.append(entry) } func expectAbsence(method: Method, queue: HTTPDownloader, completion: ExpectationMetBlock? = nil) { - var entry = ExpectationQueueEntry(method: method, queue: queue, completion: completion) + let entry = ExpectationQueueEntry(method: method, queue: queue, completion: completion) entry.expectation.isInverted = true - expectationQueue.append(entry) + expectedAbsences.append(entry) } func sequentialWait(testCase: XCTestCase, timeout: TimeInterval = 10) { @@ -125,6 +127,11 @@ extension TestUtils.Downloading { 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) } } From eb90189e3ea8d0249855a5f9a1877df0261c5e8f Mon Sep 17 00:00:00 2001 From: jahorton Date: Thu, 28 May 2020 11:03:16 +0700 Subject: [PATCH 3/4] fix(ios): polishes last commit, fixes broken KeymanEngine version update --- .../KeymanEngine.xcodeproj/project.pbxproj | 20 +++++++++++++++++++ .../HTTPDownloaderTests.swift | 5 ++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj index 5dc6411e15..419b659897 100644 --- a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj +++ b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj @@ -1063,6 +1063,7 @@ C06D37281F81F4E100F61AE0 /* Headers */, C06D37291F81F4E100F61AE0 /* Resources */, CEC0C66F2410B005003E1BCD /* ShellScript */, + CE880430247F6EB6009BCA1A /* ShellScript */, ); buildRules = ( ); @@ -1288,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; @@ -1305,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 */ diff --git a/ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift b/ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift index 5d2e5bbe72..fde140c1b8 100644 --- a/ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift +++ b/ios/engine/KMEI/KeymanEngineTests/HTTPDownloaderTests.swift @@ -122,8 +122,7 @@ class HTTPDownloaderTests: XCTestCase { 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! - log.debug("DEBUG: Expectation callback detected!") - sleep(2) // 2 seconds. + sleep(1) // in seconds. response.expectation.fulfill() return false }) @@ -139,6 +138,6 @@ class HTTPDownloaderTests: XCTestCase { 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: 10) + testDelegate.sequentialWait(testCase: self, timeout: 5) } } From e925a6b88d526dde1fee7c1255827a3404a1b7e7 Mon Sep 17 00:00:00 2001 From: jahorton Date: Thu, 28 May 2020 11:16:49 +0700 Subject: [PATCH 4/4] docs(ios): Credit for the URLSession mocking setup --- .../TestUtils/Downloading/URLSessionDownloadTaskMock.swift | 3 +++ .../TestUtils/Downloading/URLSessionMock.swift | 3 +++ 2 files changed, 6 insertions(+) diff --git a/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionDownloadTaskMock.swift b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionDownloadTaskMock.swift index 2bcedfde07..0bd6604443 100644 --- a/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionDownloadTaskMock.swift +++ b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionDownloadTaskMock.swift @@ -9,6 +9,9 @@ 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 diff --git a/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionMock.swift b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionMock.swift index 87227378c9..46d2d8e99f 100644 --- a/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionMock.swift +++ b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Downloading/URLSessionMock.swift @@ -19,6 +19,9 @@ extension TestUtils.Downloading { } } + /** + * Many thanks to https://www.swiftbysundell.com/articles/mocking-in-swift/ for the approach used here. + */ class URLSessionMock: URLSession { private var mockedResultQueue: [DownloadMockResult]