mirror of
https://github.com/kiwix/kiwix-apple.git
synced 2025-09-22 02:52:39 -04:00
Merge pull request #1072 from kiwix/fix-unittests
Fix "flaky" unit-tests
This commit is contained in:
commit
1a673e54b4
@ -13,17 +13,23 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
//
|
||||
// CategoriesToLanguage.swift
|
||||
// Kiwix
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Defaults
|
||||
|
||||
struct CategoriesToLanguages {
|
||||
protocol CategoriesProtocol {
|
||||
func has(category: Category, inLanguages langCodes: Set<String>) -> Bool
|
||||
func save(_ dictionary: [Category: Set<String>])
|
||||
func allCategories() -> [Category]
|
||||
}
|
||||
|
||||
private let dictionary: [Category: Set<String>] = Defaults[.categoriesToLanguages]
|
||||
struct CategoriesToLanguages: CategoriesProtocol {
|
||||
|
||||
private let defaults: Defaulting
|
||||
private let dictionary: [Category: Set<String>]
|
||||
|
||||
init(withDefaults defaults: Defaulting = UDefaults()) {
|
||||
self.defaults = defaults
|
||||
self.dictionary = defaults[.categoriesToLanguages]
|
||||
}
|
||||
|
||||
func has(category: Category, inLanguages langCodes: Set<String>) -> Bool {
|
||||
guard !langCodes.isEmpty, !dictionary.isEmpty else {
|
||||
@ -35,15 +41,14 @@ struct CategoriesToLanguages {
|
||||
return !languages.isDisjoint(with: langCodes)
|
||||
}
|
||||
|
||||
static func save(_ dictionary: [Category: Set<String>]) {
|
||||
Defaults[.categoriesToLanguages] = dictionary
|
||||
func save(_ dictionary: [Category: Set<String>]) {
|
||||
defaults[.categoriesToLanguages] = dictionary
|
||||
}
|
||||
|
||||
static func allCategories() -> [Category] {
|
||||
let categoriesToLanguages = CategoriesToLanguages()
|
||||
let contentLanguages = Defaults[.libraryLanguageCodes]
|
||||
func allCategories() -> [Category] {
|
||||
let contentLanguages = defaults[.libraryLanguageCodes]
|
||||
return Category.allCases.filter { (category: Category) in
|
||||
categoriesToLanguages.has(category: category, inLanguages: contentLanguages)
|
||||
has(category: category, inLanguages: contentLanguages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
32
Model/Defaulting.swift
Normal file
32
Model/Defaulting.swift
Normal file
@ -0,0 +1,32 @@
|
||||
// This file is part of Kiwix for iOS & macOS.
|
||||
//
|
||||
// Kiwix is free software; you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation; either version 3 of the License, or
|
||||
// any later version.
|
||||
//
|
||||
// Kiwix is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
import Defaults
|
||||
|
||||
public protocol Defaulting: NSObjectProtocol {
|
||||
subscript<Value: Defaults.Serializable>(key: Defaults.Key<Value>) -> Value { get set }
|
||||
}
|
||||
|
||||
final class UDefaults: NSObject, Defaulting {
|
||||
subscript<Value>(key: Defaults.Key<Value>) -> Value where Value: DefaultsSerializable {
|
||||
get {
|
||||
Defaults[key]
|
||||
}
|
||||
set {
|
||||
Defaults[key] = newValue
|
||||
}
|
||||
}
|
||||
}
|
@ -36,7 +36,7 @@ enum ActiveSheet: Hashable, Identifiable {
|
||||
case safari(url: URL)
|
||||
}
|
||||
|
||||
enum Category: String, CaseIterable, Identifiable, LosslessStringConvertible {
|
||||
enum Category: String, CaseIterable, Identifiable, LosslessStringConvertible, Hashable {
|
||||
var description: String { rawValue }
|
||||
|
||||
var id: String { rawValue }
|
||||
|
@ -60,6 +60,7 @@ final class LibraryRefreshViewModelTest: XCTestCase {
|
||||
}
|
||||
|
||||
private func makeOPDSData(zimFileID: UUID) -> String {
|
||||
// swiftlint:disable line_length
|
||||
"""
|
||||
<feed xmlns="http://www.w3.org/2005/Atom"
|
||||
xmlns:dc="http://purl.org/dc/terms/"
|
||||
@ -88,6 +89,7 @@ final class LibraryRefreshViewModelTest: XCTestCase {
|
||||
</entry>
|
||||
</feed>
|
||||
"""
|
||||
// swiftlint:enable line_length
|
||||
}
|
||||
|
||||
/// Test time out fetching library data.
|
||||
@ -96,9 +98,12 @@ final class LibraryRefreshViewModelTest: XCTestCase {
|
||||
HTTPTestingURLProtocol.handler = { urlProtocol in
|
||||
urlProtocol.client?.urlProtocol(urlProtocol, didFailWithError: URLError(URLError.Code.timedOut))
|
||||
}
|
||||
|
||||
let testDefaults = TestDefaults()
|
||||
testDefaults.setup()
|
||||
let viewModel = LibraryViewModel(urlSession: urlSession,
|
||||
processFactory: { LibraryProcess() })
|
||||
processFactory: { LibraryProcess(defaultState: .initial) },
|
||||
defaults: testDefaults,
|
||||
categories: CategoriesToLanguages(withDefaults: testDefaults))
|
||||
await viewModel.start(isUserInitiated: true)
|
||||
XCTAssert(viewModel.error is LibraryRefreshError)
|
||||
XCTAssertEqual(
|
||||
@ -119,8 +124,12 @@ final class LibraryRefreshViewModelTest: XCTestCase {
|
||||
urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol)
|
||||
}
|
||||
|
||||
let testDefaults = TestDefaults()
|
||||
testDefaults.setup()
|
||||
let viewModel = LibraryViewModel(urlSession: urlSession,
|
||||
processFactory: { LibraryProcess() })
|
||||
processFactory: { LibraryProcess(defaultState: .initial) },
|
||||
defaults: testDefaults,
|
||||
categories: CategoriesToLanguages(withDefaults: testDefaults))
|
||||
await viewModel.start(isUserInitiated: true)
|
||||
XCTAssert(viewModel.error is LibraryRefreshError)
|
||||
XCTAssertEqual(
|
||||
@ -137,13 +146,17 @@ final class LibraryRefreshViewModelTest: XCTestCase {
|
||||
url: URL.mock(),
|
||||
statusCode: 200, httpVersion: nil, headerFields: [:]
|
||||
)!
|
||||
urlProtocol.client?.urlProtocol(urlProtocol, didLoad: "Invalid OPDS Data".data(using: .utf8)!)
|
||||
urlProtocol.client?.urlProtocol(urlProtocol, didLoad: Data("Invalid OPDS Data".utf8))
|
||||
urlProtocol.client?.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol)
|
||||
}
|
||||
|
||||
let testDefaults = TestDefaults()
|
||||
testDefaults.setup()
|
||||
let viewModel = LibraryViewModel(urlSession: urlSession,
|
||||
processFactory: { LibraryProcess() })
|
||||
processFactory: { LibraryProcess(defaultState: .initial) },
|
||||
defaults: testDefaults,
|
||||
categories: CategoriesToLanguages(withDefaults: testDefaults))
|
||||
await viewModel.start(isUserInitiated: true)
|
||||
XCTExpectFailure("Requires work in dependency to resolve the issue.")
|
||||
XCTAssertEqual(
|
||||
@ -154,6 +167,7 @@ final class LibraryRefreshViewModelTest: XCTestCase {
|
||||
|
||||
/// Test zim file entity is created, and metadata are saved when new zim file becomes available in online catalog.
|
||||
@MainActor
|
||||
// swiftlint:disable:next function_body_length
|
||||
func testNewZimFileAndProperties() async throws {
|
||||
let zimFileID = UUID()
|
||||
HTTPTestingURLProtocol.handler = { urlProtocol in
|
||||
@ -166,9 +180,12 @@ final class LibraryRefreshViewModelTest: XCTestCase {
|
||||
urlProtocol.client?.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol)
|
||||
}
|
||||
|
||||
let testDefaults = TestDefaults()
|
||||
testDefaults.setup()
|
||||
let viewModel = LibraryViewModel(urlSession: urlSession,
|
||||
processFactory: { LibraryProcess() })
|
||||
processFactory: { LibraryProcess(defaultState: .initial) },
|
||||
defaults: testDefaults,
|
||||
categories: CategoriesToLanguages(withDefaults: testDefaults))
|
||||
await viewModel.start(isUserInitiated: true)
|
||||
|
||||
// check no error has happened
|
||||
@ -185,6 +202,7 @@ final class LibraryRefreshViewModelTest: XCTestCase {
|
||||
XCTAssertEqual(zimFile.id, zimFileID)
|
||||
XCTAssertEqual(zimFile.articleCount, 50001)
|
||||
XCTAssertEqual(zimFile.category, Category.wikipedia.rawValue)
|
||||
// swiftlint:disable:next force_try
|
||||
XCTAssertEqual(zimFile.created, try! Date("2023-01-07T00:00:00Z", strategy: .iso8601))
|
||||
XCTAssertEqual(
|
||||
zimFile.downloadURL,
|
||||
@ -211,14 +229,21 @@ final class LibraryRefreshViewModelTest: XCTestCase {
|
||||
XCTAssertEqual(zimFile.persistentID, "wikipedia_en_top")
|
||||
XCTAssertEqual(zimFile.requiresServiceWorkers, false)
|
||||
XCTAssertEqual(zimFile.size, 6515656704)
|
||||
|
||||
// clean up
|
||||
context.delete(zimFile)
|
||||
}
|
||||
|
||||
/// Test zim file deprecation
|
||||
@MainActor
|
||||
func testZimFileDeprecation() async throws {
|
||||
let testDefaults = TestDefaults()
|
||||
testDefaults.setup()
|
||||
// refresh library for the first time, which should create one zim file
|
||||
let viewModel = LibraryViewModel(urlSession: urlSession,
|
||||
processFactory: { LibraryProcess() })
|
||||
processFactory: { LibraryProcess(defaultState: .initial) },
|
||||
defaults: testDefaults,
|
||||
categories: CategoriesToLanguages(withDefaults: testDefaults))
|
||||
await viewModel.start(isUserInitiated: true)
|
||||
let context = Database.shared.viewContext
|
||||
let zimFile1 = try XCTUnwrap(try context.fetch(ZimFile.fetchRequest()).first)
|
||||
@ -231,7 +256,7 @@ final class LibraryRefreshViewModelTest: XCTestCase {
|
||||
XCTAssertNotEqual(zimFile1.fileID, zimFile2.fileID)
|
||||
|
||||
// set fileURLBookmark of zim file 2
|
||||
zimFile2.fileURLBookmark = "/Users/tester/Downloads/file_url.zim".data(using: .utf8)
|
||||
zimFile2.fileURLBookmark = Data("/Users/tester/Downloads/file_url.zim".utf8)
|
||||
try context.save()
|
||||
|
||||
// refresh library for the third time
|
||||
@ -241,6 +266,10 @@ final class LibraryRefreshViewModelTest: XCTestCase {
|
||||
// check there are two zim files in the database, and zim file 2 is not deprecated
|
||||
XCTAssertEqual(zimFiles.count, 2)
|
||||
XCTAssertEqual(zimFiles.filter({ $0.fileID == zimFile2.fileID }).count, 1)
|
||||
|
||||
// clean up
|
||||
context.delete(zimFile1)
|
||||
context.delete(zimFile2)
|
||||
}
|
||||
}
|
||||
|
||||
|
41
Tests/TestDefaults.swift
Normal file
41
Tests/TestDefaults.swift
Normal file
@ -0,0 +1,41 @@
|
||||
// This file is part of Kiwix for iOS & macOS.
|
||||
//
|
||||
// Kiwix is free software; you can redistribute it and/or modify it
|
||||
// under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation; either version 3 of the License, or
|
||||
// any later version.
|
||||
//
|
||||
// Kiwix is distributed in the hope that it will be useful, but
|
||||
// WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
// General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
import Foundation
|
||||
import Defaults
|
||||
@testable import Kiwix
|
||||
|
||||
final class TestDefaults: NSObject, Defaulting {
|
||||
|
||||
var dict: [Defaults.AnyKey: any DefaultsSerializable] = [:]
|
||||
|
||||
func setup() {
|
||||
self[.categoriesToLanguages] = [:]
|
||||
self[.libraryAutoRefresh] = false
|
||||
self[.libraryETag] = ""
|
||||
self[.libraryUsingOldISOLangCodes] = false
|
||||
self[.libraryLanguageCodes] = Set<String>()
|
||||
}
|
||||
|
||||
subscript<Value>(key: Defaults.Key<Value>) -> Value where Value: DefaultsSerializable {
|
||||
get {
|
||||
// swiftlint:disable:next force_cast
|
||||
dict[key] as! Value
|
||||
}
|
||||
set {
|
||||
dict[key] = newValue
|
||||
}
|
||||
}
|
||||
}
|
@ -15,7 +15,6 @@
|
||||
|
||||
import CoreData
|
||||
import Combine
|
||||
import Defaults
|
||||
import os
|
||||
|
||||
enum LibraryState {
|
||||
@ -24,8 +23,8 @@ enum LibraryState {
|
||||
case complete
|
||||
case error
|
||||
|
||||
static func defaultState() -> LibraryState {
|
||||
if Defaults[.libraryLastRefresh] == nil {
|
||||
static func defaultState(defaults: Defaulting = UDefaults()) -> LibraryState {
|
||||
if defaults[.libraryLastRefresh] == nil {
|
||||
return .initial
|
||||
} else {
|
||||
return .complete
|
||||
@ -52,6 +51,8 @@ final class LibraryViewModel: ObservableObject {
|
||||
@MainActor @Published var state: LibraryState
|
||||
@MainActor private let process: LibraryProcess
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let defaults: Defaulting
|
||||
private let categories: CategoriesProtocol
|
||||
|
||||
private let urlSession: URLSession
|
||||
private var insertionCount = 0
|
||||
@ -60,9 +61,16 @@ final class LibraryViewModel: ObservableObject {
|
||||
private static let catalogURL = URL(string: "https://library.kiwix.org/catalog/v2/entries?count=-1")!
|
||||
|
||||
@MainActor
|
||||
init(urlSession: URLSession? = nil, processFactory: @MainActor () -> LibraryProcess = { .shared }) {
|
||||
init(
|
||||
urlSession: URLSession? = nil,
|
||||
processFactory: @MainActor () -> LibraryProcess = { .shared },
|
||||
defaults: Defaulting = UDefaults(),
|
||||
categories: CategoriesProtocol = CategoriesToLanguages(withDefaults: UDefaults())
|
||||
) {
|
||||
self.urlSession = urlSession ?? URLSession.shared
|
||||
self.process = processFactory()
|
||||
self.defaults = defaults
|
||||
self.categories = categories
|
||||
state = process.state
|
||||
process.$state.sink { [weak self] newState in
|
||||
self?.state = newState
|
||||
@ -78,8 +86,8 @@ final class LibraryViewModel: ObservableObject {
|
||||
guard process.state != .inProgress else { return }
|
||||
do {
|
||||
// decide if refresh should proceed
|
||||
let lastRefresh: Date? = Defaults[.libraryLastRefresh]
|
||||
let hasAutoRefresh: Bool = Defaults[.libraryAutoRefresh]
|
||||
let lastRefresh: Date? = defaults[.libraryLastRefresh]
|
||||
let hasAutoRefresh: Bool = defaults[.libraryAutoRefresh]
|
||||
let isStale = (lastRefresh?.timeIntervalSinceNow ?? -3600) <= -3600
|
||||
guard isUserInitiated || (hasAutoRefresh && isStale) else { return }
|
||||
|
||||
@ -90,7 +98,7 @@ final class LibraryViewModel: ObservableObject {
|
||||
// this is the case when we have no new data (304 http)
|
||||
// but we still need to refresh the memory only stored
|
||||
// zimfile categories to languages dictionary
|
||||
if CategoriesToLanguages.allCategories().count < 2 {
|
||||
if categories.allCategories().count < 2 {
|
||||
let context = Database.shared.viewContext
|
||||
if let zimFiles: [ZimFile] = try? context.fetch(ZimFile.fetchRequest()) {
|
||||
saveCategoryAvailableInLanguages(fromDBZimFiles: zimFiles)
|
||||
@ -113,15 +121,15 @@ final class LibraryViewModel: ObservableObject {
|
||||
}
|
||||
let parser = try await parse(data: data)
|
||||
// delete all old ISO Lang Code entries if needed, by passing in an empty parser
|
||||
if Defaults[.libraryUsingOldISOLangCodes] {
|
||||
if defaults[.libraryUsingOldISOLangCodes] {
|
||||
try await process(parser: DeletingParser())
|
||||
Defaults[.libraryUsingOldISOLangCodes] = false
|
||||
defaults[.libraryUsingOldISOLangCodes] = false
|
||||
}
|
||||
// process the feed
|
||||
try await process(parser: parser)
|
||||
|
||||
// update library last refresh timestamp
|
||||
Defaults[.libraryLastRefresh] = Date()
|
||||
defaults[.libraryLastRefresh] = Date()
|
||||
|
||||
saveCategoryAvailableInLanguages(using: parser)
|
||||
|
||||
@ -156,7 +164,7 @@ final class LibraryViewModel: ObservableObject {
|
||||
dictionary[category] = allLanguagesForCategory
|
||||
}
|
||||
}
|
||||
CategoriesToLanguages.save(dictionary)
|
||||
categories.save(dictionary)
|
||||
}
|
||||
|
||||
private func saveCategoryAvailableInLanguages(fromDBZimFiles zimFiles: [ZimFile]) {
|
||||
@ -172,7 +180,7 @@ final class LibraryViewModel: ObservableObject {
|
||||
}
|
||||
dictionary[category] = allLanguagesForCategory
|
||||
}
|
||||
CategoriesToLanguages.save(dictionary)
|
||||
categories.save(dictionary)
|
||||
}
|
||||
|
||||
/// The fetched content is filtered by the languages set in settings.
|
||||
@ -183,10 +191,10 @@ final class LibraryViewModel: ObservableObject {
|
||||
let validCodes = Set<String>(languages.map { $0.code })
|
||||
// preserve only valid selections by:
|
||||
// converting earlier user selections, and filtering out invalid ones
|
||||
Defaults[.libraryLanguageCodes] = LanguagesConverter.convert(codes: Defaults[.libraryLanguageCodes],
|
||||
defaults[.libraryLanguageCodes] = LanguagesConverter.convert(codes: defaults[.libraryLanguageCodes],
|
||||
validCodes: validCodes)
|
||||
|
||||
guard Defaults[.libraryLanguageCodes].isEmpty else {
|
||||
guard defaults[.libraryLanguageCodes].isEmpty else {
|
||||
return // what was earlier set by the user or picked by default is valid
|
||||
}
|
||||
|
||||
@ -201,22 +209,22 @@ final class LibraryViewModel: ObservableObject {
|
||||
let deviceLangSet = Set<String>([deviceLang].compactMap { $0 })
|
||||
let validDefaults = LanguagesConverter.convert(codes: deviceLangSet, validCodes: validCodes)
|
||||
if validDefaults.isEmpty { // meaning the device language isn't valid (or nil)
|
||||
Defaults[.libraryLanguageCodes] = [fallbackToEnglish]
|
||||
defaults[.libraryLanguageCodes] = [fallbackToEnglish]
|
||||
} else {
|
||||
Defaults[.libraryLanguageCodes] = validDefaults
|
||||
defaults[.libraryLanguageCodes] = validDefaults
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchData() async throws -> Data? {
|
||||
do {
|
||||
var request = URLRequest(url: Self.catalogURL, timeoutInterval: 20)
|
||||
request.allHTTPHeaderFields = ["If-None-Match": Defaults[.libraryETag]]
|
||||
request.allHTTPHeaderFields = ["If-None-Match": defaults[.libraryETag]]
|
||||
let (data, response) = try await self.urlSession.data(for: request)
|
||||
guard let response = response as? HTTPURLResponse else { return nil }
|
||||
switch response.statusCode {
|
||||
case 200:
|
||||
if let eTag = response.allHeaderFields["Etag"] as? String {
|
||||
Defaults[.libraryETag] = eTag
|
||||
defaults[.libraryETag] = eTag
|
||||
}
|
||||
// OK to process further
|
||||
os_log("Retrieved OPDS Data, size: %llu bytes", log: Log.OPDS, type: .info, data.count)
|
||||
|
@ -26,9 +26,12 @@ struct Library: View {
|
||||
private let categories: [Category]
|
||||
let dismiss: (() -> Void)?
|
||||
|
||||
init(dismiss: (() -> Void)?) {
|
||||
init(
|
||||
dismiss: (() -> Void)?,
|
||||
categories: [Category] = CategoriesToLanguages().allCategories()
|
||||
) {
|
||||
self.dismiss = dismiss
|
||||
categories = CategoriesToLanguages.allCategories()
|
||||
self.categories = categories
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
@ -24,8 +24,11 @@ struct ZimFilesCategories: View {
|
||||
private var categories: [Category]
|
||||
private let dismiss: (() -> Void)?
|
||||
|
||||
init(dismiss: (() -> Void)?) {
|
||||
categories = CategoriesToLanguages.allCategories()
|
||||
init(
|
||||
dismiss: (() -> Void)?,
|
||||
categories: [Category] = CategoriesToLanguages().allCategories()
|
||||
) {
|
||||
self.categories = categories
|
||||
selected = categories.first ?? .wikipedia
|
||||
self.dismiss = dismiss
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user