// // LanguagePickAssociatorTests.swift // KeymanEngineTests // // Created by Joshua Horton on 8/3/20. // Copyright © 2020 SIL International. All rights reserved. // import XCTest @testable import KeymanEngine class LanguagePickAssociatorTests: XCTestCase { class Counter { var count = 0 func increment() { count += 1 } var value: Int { return count } } var mockedURLSession: TestUtils.Downloading.URLSessionMock! let mockedSearchCallback: LanguagePickAssociator.AssociationSearcher = { lgCodes, callback in var searchResult: [String: (KeymanPackage.Key, URL)?] = [:] // We aren't downloading these models, so a placeholder URL is fine. let placeholderURL = URL.init(string: "http://place.holder.com")! lgCodes.forEach { lgCode in switch lgCode { case "en": searchResult["en"] = (TestUtils.Packages.Keys.nrc_en_mtnt, placeholderURL) case "str": searchResult["str"] = (TestUtils.Packages.Keys.nrc_str_sencoten, placeholderURL) default: searchResult[lgCode] = nil } } callback(searchResult) } override func setUp() { mockedURLSession = TestUtils.Downloading.URLSessionMock() } override func tearDownWithError() throws { TestUtils.standardTearDown() let queueWasCleared = mockedURLSession!.queueIsEmpty mockedURLSession = nil if !queueWasCleared { throw NSError(domain: "Keyman", code: 4, userInfo: [NSLocalizedDescriptionKey: "A test did not fully utilize its queued mock results!"]) } } func testSequentialQuadPick() throws { let expectation = XCTestExpectation() let tracker = Counter() let associator = LanguagePickAssociator(searchWith: mockedSearchCallback) { state in switch state { case .cancelled: XCTFail() case .inProgress(let queriesComplete, let modelsFound): XCTAssertLessThanOrEqual(queriesComplete, 4) XCTAssertLessThanOrEqual(modelsFound, 2) XCTAssertLessThanOrEqual(modelsFound, queriesComplete) tracker.increment() case .complete(let queriesComplete, let results): XCTAssertEqual(queriesComplete, 4) if results.count == 2 { XCTAssertTrue(results.keys.contains(TestUtils.Packages.Keys.nrc_en_mtnt)) XCTAssertTrue(results.keys.contains(TestUtils.Packages.Keys.nrc_str_sencoten)) } else { XCTFail() } XCTAssertEqual(tracker.value, 4) expectation.fulfill() } } associator.pickerInitialized() associator.selectLanguages(Set(["en"])) associator.selectLanguages(Set(["km"])) associator.selectLanguages(Set(["str"])) associator.selectLanguages(Set(["foobar"])) associator.pickerFinalized() wait(for: [expectation], timeout: 5) } func testSimultaneousQuadPick() throws { let expectation = XCTestExpectation() let tracker = Counter() let associator = LanguagePickAssociator(searchWith: mockedSearchCallback) { state in switch state { case .cancelled: XCTFail() case .inProgress(let queriesComplete, let modelsFound): XCTAssertLessThanOrEqual(queriesComplete, 4) XCTAssertLessThanOrEqual(modelsFound, 2) XCTAssertLessThanOrEqual(modelsFound, queriesComplete) tracker.increment() case .complete(let queriesComplete, let results): XCTAssertEqual(queriesComplete, 4) if results.count == 2 { XCTAssertTrue(results.keys.contains(TestUtils.Packages.Keys.nrc_en_mtnt)) XCTAssertTrue(results.keys.contains(TestUtils.Packages.Keys.nrc_str_sencoten)) } else { XCTFail() } XCTAssertEqual(tracker.value, 1) expectation.fulfill() } } associator.pickerInitialized() associator.selectLanguages(Set(["en", "km", "str", "foobar"])) associator.pickerFinalized() wait(for: [expectation], timeout: 5) } func testDeselect() throws { let expectation = XCTestExpectation() let associator = LanguagePickAssociator(searchWith: mockedSearchCallback) { state in switch state { case .cancelled: XCTFail() case .inProgress(let queriesComplete, let modelsFound): XCTAssertLessThanOrEqual(queriesComplete, 4) XCTAssertLessThanOrEqual(modelsFound, 1) XCTAssertLessThanOrEqual(modelsFound, queriesComplete) case .complete(let queriesComplete, let results): XCTAssertEqual(queriesComplete, 4) // We still queried for "str". if results.count == 1 { XCTAssertTrue(results.keys.contains(TestUtils.Packages.Keys.nrc_en_mtnt)) XCTAssertFalse(results.keys.contains(TestUtils.Packages.Keys.nrc_str_sencoten)) } else { XCTFail() } expectation.fulfill() } } associator.pickerInitialized() associator.selectLanguages(Set(["en", "km", "str", "foobar"])) associator.deselectLanguages(Set(["str"])) associator.pickerFinalized() wait(for: [expectation], timeout: 5) } func testFickleSelection() throws { let expectation = XCTestExpectation() let tracker = Counter() let associator = LanguagePickAssociator(searchWith: mockedSearchCallback) { state in switch state { case .cancelled: XCTFail() case .inProgress(let queriesComplete, let modelsFound): XCTAssertLessThanOrEqual(queriesComplete, 4) XCTAssertLessThanOrEqual(modelsFound, 1) XCTAssertLessThanOrEqual(modelsFound, queriesComplete) tracker.increment() case .complete(let queriesComplete, let results): XCTAssertEqual(queriesComplete, 4) if results.count == 1 { XCTAssertFalse(results.keys.contains(TestUtils.Packages.Keys.nrc_en_mtnt)) XCTAssertTrue(results.keys.contains(TestUtils.Packages.Keys.nrc_str_sencoten)) } else { XCTFail() } XCTAssertEqual(tracker.value, 1) expectation.fulfill() } } associator.pickerInitialized() associator.selectLanguages(Set(["en", "km", "str", "foobar"])) associator.deselectLanguages(Set(["str"])) associator.selectLanguages(Set(["str"])) associator.deselectLanguages(Set(["str"])) associator.selectLanguages(Set(["str"])) associator.deselectLanguages(Set(["en"])) associator.selectLanguages(Set(["en"])) associator.deselectLanguages(Set(["en"])) associator.pickerFinalized() wait(for: [expectation], timeout: 5) } func testNoSelect() throws { let expectation = XCTestExpectation() let associator = LanguagePickAssociator(searchWith: mockedSearchCallback) { state in switch state { case .cancelled: XCTFail() case .inProgress(_, _): // If no queries occur, no .inProgress calls should occur. XCTFail() case .complete(let queriesComplete, let results): XCTAssertEqual(queriesComplete, 0) XCTAssertEqual(results.count, 0) expectation.fulfill() } } associator.pickerInitialized() // For actual use with the package-install language picker, this should be impossible. // Still, best to test it anyway... especially since it may see use outside said picker. associator.pickerFinalized() wait(for: [expectation], timeout: 5) } func testSelectionsDismissed() throws { let expectation = XCTestExpectation() let tracker = Counter() let associator = LanguagePickAssociator(searchWith: mockedSearchCallback) { state in switch state { case .cancelled: expectation.fulfill() case .inProgress(let queriesComplete, let modelsFound): XCTAssertLessThanOrEqual(queriesComplete, 4) XCTAssertLessThanOrEqual(modelsFound, 2) XCTAssertLessThanOrEqual(modelsFound, queriesComplete) tracker.increment() case .complete(_, _): XCTFail() } } associator.pickerInitialized() associator.selectLanguages(Set(["en", "km", "str", "foobar"])) associator.pickerDismissed() wait(for: [expectation], timeout: 5) } func testSelectionsDeinit() throws { let expectation = XCTestExpectation() let tracker = Counter() // Uses scoping to test variable de-init behavior. do { var associator: LanguagePickAssociator? = LanguagePickAssociator(searchWith: mockedSearchCallback) { state in switch state { case .cancelled: expectation.fulfill() case .inProgress(let queriesComplete, let modelsFound): XCTAssertLessThanOrEqual(queriesComplete, 4) XCTAssertLessThanOrEqual(modelsFound, 2) XCTAssertLessThanOrEqual(modelsFound, queriesComplete) tracker.increment() case .complete(_, _): XCTFail() } } associator!.pickerInitialized() associator!.selectLanguages(Set(["en", "km", "str", "foobar"])) associator = nil } wait(for: [expectation], timeout: 5) } func testQueryCompletionDelay() throws { let progressExpectation = XCTestExpectation() progressExpectation.isInverted = true let immediateCompleteExpectation = XCTestExpectation() immediateCompleteExpectation.isInverted = true let completeExpectation = XCTestExpectation() let delaySync = DispatchGroup() delaySync.enter() let delayedSearchCallback: LanguagePickAssociator.AssociationSearcher = { lgCodes, callback in self.mockedSearchCallback(lgCodes) { result in // It will only return when we _allow_ it to return (via delaySync.leave). // This also delays the progressExpectation. delaySync.notify(queue: DispatchQueue.main) { callback(result) } } } let associator = LanguagePickAssociator(searchWith: delayedSearchCallback) { state in switch state { case .cancelled: XCTFail() case .inProgress(_, _): // We've delayed this as well. progressExpectation.fulfill() case .complete(_, _): immediateCompleteExpectation.fulfill() completeExpectation.fulfill() } } associator.pickerInitialized() associator.selectLanguages(Set(["en", "km", "str", "foobar"])) associator.pickerFinalized() wait(for: [progressExpectation, immediateCompleteExpectation], timeout: 1) delaySync.leave() wait(for: [completeExpectation], timeout: 1) } func testLexicalModelSearcher() throws { let mocked_en_query = TestUtils.Downloading.MockResult(location: TestUtils.Queries.model_case_en, error: nil) let mocked_km_query = TestUtils.Downloading.MockResult(location: TestUtils.Queries.model_case_km, error: nil) let mocked_str_query = TestUtils.Downloading.MockResult(location: TestUtils.Queries.model_case_str, error: nil) let mocked_none_query = TestUtils.Downloading.MockResult(location: TestUtils.Queries.model_case_none, error: nil) // Ensure the mocking order matches the set's iteration order. // The calls will be synchronous; it's the callback that isn't. let languageSet = Set(["en", "km", "str", "foobar"]) languageSet.forEach { lgCode in switch lgCode { case "en": mockedURLSession.queueMockResult(.data(mocked_en_query)) case "km": mockedURLSession.queueMockResult(.data(mocked_km_query)) case "str": mockedURLSession.queueMockResult(.data(mocked_str_query)) default: mockedURLSession.queueMockResult(.data(mocked_none_query)) } } let expectation = XCTestExpectation() let searchCallback = LanguagePickAssociator.constructLexicalModelSearcher(session: mockedURLSession) searchCallback(languageSet) { results in if results.count == 2 { XCTAssertTrue(results.keys.contains("en")) XCTAssertTrue(results.keys.contains("str")) } else { XCTFail() } expectation.fulfill() } wait(for: [expectation], timeout: 5) } func testLexicalModelSearcherWithFiltering() throws { // For this test, we want a pre-installed lexical model for the "en" language code. guard let package = try ResourceFileManager.shared.prepareKMPInstall(from: TestUtils.LexicalModels.mtntKMP) as? LexicalModelKeymanPackage else { XCTFail() return } try ResourceFileManager.shared.install(resourceWithID: TestUtils.LexicalModels.mtnt.fullID, from: package) let mocked_km_query = TestUtils.Downloading.MockResult(location: TestUtils.Queries.model_case_km, error: nil) let mocked_str_query = TestUtils.Downloading.MockResult(location: TestUtils.Queries.model_case_str, error: nil) let mocked_none_query = TestUtils.Downloading.MockResult(location: TestUtils.Queries.model_case_none, error: nil) // Ensure the mocking order matches the set's iteration order. // The calls will be synchronous; it's the callback that isn't. let languageSet = Set(["en", "km", "str", "foobar"]) languageSet.forEach { lgCode in switch lgCode { case "en": return // Do not queue - the query should never occur! case "km": mockedURLSession.queueMockResult(.data(mocked_km_query)) case "str": mockedURLSession.queueMockResult(.data(mocked_str_query)) default: mockedURLSession.queueMockResult(.data(mocked_none_query)) } } let expectation = XCTestExpectation() let searchCallback = LanguagePickAssociator.constructLexicalModelSearcher(session: mockedURLSession) searchCallback(languageSet) { results in if results.count == 1 { XCTAssertTrue(results.keys.contains("str")) } else { XCTFail() } expectation.fulfill() } wait(for: [expectation], timeout: 5) } // A modded version of `testSequentialQuadPick`. Also includes fine-grained progress checks! func testWithQueryIntegration() throws { let mocked_en_query = TestUtils.Downloading.MockResult(location: TestUtils.Queries.model_case_en, error: nil) let mocked_km_query = TestUtils.Downloading.MockResult(location: TestUtils.Queries.model_case_km, error: nil) let mocked_str_query = TestUtils.Downloading.MockResult(location: TestUtils.Queries.model_case_str, error: nil) let mocked_none_query = TestUtils.Downloading.MockResult(location: TestUtils.Queries.model_case_none, error: nil) // Here, we control the order in which the queries arise. mockedURLSession.queueMockResult(.data(mocked_en_query)) mockedURLSession.queueMockResult(.data(mocked_km_query)) mockedURLSession.queueMockResult(.data(mocked_str_query)) mockedURLSession.queueMockResult(.data(mocked_none_query)) let expectation = XCTestExpectation() let queryExpectations: [String: XCTestExpectation] = [ "en": XCTestExpectation(), "km": XCTestExpectation(), "str": XCTestExpectation(), "foobar": XCTestExpectation() ] let progressExpectations: [Int: XCTestExpectation] = [ 1: XCTestExpectation(), 2: XCTestExpectation(), 3: XCTestExpectation(), 4: XCTestExpectation() ] let tracker = Counter() let baseSearchCallback = LanguagePickAssociator.constructLexicalModelSearcher(session: mockedURLSession) let searchCallback: LanguagePickAssociator.AssociationSearcher = { lgCodes, completion in baseSearchCallback(lgCodes) { results in completion(results) // Allows us to enforce query ordering. queryExpectations[lgCodes.first!]!.fulfill() } } let associator = LanguagePickAssociator(searchWith: searchCallback) { state in switch state { case .cancelled: XCTFail() case .inProgress(1, 1): // "en" - has lexical model tracker.increment() progressExpectations[1]!.fulfill() case .inProgress(2, 1): // "km" tracker.increment() progressExpectations[2]!.fulfill() case .inProgress(3, 2): // "str" - has lexical model tracker.increment() progressExpectations[3]!.fulfill() case .inProgress(4, 2): // "foobar" tracker.increment() progressExpectations[4]!.fulfill() case .complete(let queriesComplete, let results): XCTAssertEqual(queriesComplete, 4) if results.count == 2 { XCTAssertTrue(results.keys.contains(TestUtils.Packages.Keys.nrc_en_mtnt)) XCTAssertTrue(results.keys.contains(TestUtils.Packages.Keys.nrc_str_sencoten)) } else { XCTFail() } XCTAssertEqual(tracker.value, 4) expectation.fulfill() default: XCTFail() } } associator.pickerInitialized() associator.selectLanguages(Set(["en"])) wait(for: [queryExpectations["en"]!, progressExpectations[1]!], timeout: 1) associator.selectLanguages(Set(["km"])) wait(for: [queryExpectations["km"]!, progressExpectations[2]!], timeout: 1) associator.selectLanguages(Set(["str"])) wait(for: [queryExpectations["str"]!, progressExpectations[3]!], timeout: 1) associator.selectLanguages(Set(["foobar"])) wait(for: [queryExpectations["foobar"]!, progressExpectations[4]!], timeout: 1) associator.pickerFinalized() wait(for: [expectation], timeout: 5) } }