Merge pull request #1072 from kiwix/fix-unittests

Fix "flaky" unit-tests
This commit is contained in:
Kelson 2025-01-17 10:03:35 +01:00 committed by GitHub
commit 1a673e54b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 167 additions and 46 deletions

View File

@ -13,17 +13,23 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Kiwix; If not, see https://www.gnu.org/licenses/. // along with Kiwix; If not, see https://www.gnu.org/licenses/.
//
// CategoriesToLanguage.swift
// Kiwix
//
import Foundation 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 { func has(category: Category, inLanguages langCodes: Set<String>) -> Bool {
guard !langCodes.isEmpty, !dictionary.isEmpty else { guard !langCodes.isEmpty, !dictionary.isEmpty else {
@ -35,15 +41,14 @@ struct CategoriesToLanguages {
return !languages.isDisjoint(with: langCodes) return !languages.isDisjoint(with: langCodes)
} }
static func save(_ dictionary: [Category: Set<String>]) { func save(_ dictionary: [Category: Set<String>]) {
Defaults[.categoriesToLanguages] = dictionary defaults[.categoriesToLanguages] = dictionary
} }
static func allCategories() -> [Category] { func allCategories() -> [Category] {
let categoriesToLanguages = CategoriesToLanguages() let contentLanguages = defaults[.libraryLanguageCodes]
let contentLanguages = Defaults[.libraryLanguageCodes]
return Category.allCases.filter { (category: Category) in return Category.allCases.filter { (category: Category) in
categoriesToLanguages.has(category: category, inLanguages: contentLanguages) has(category: category, inLanguages: contentLanguages)
} }
} }
} }

32
Model/Defaulting.swift Normal file
View 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
}
}
}

View File

@ -36,7 +36,7 @@ enum ActiveSheet: Hashable, Identifiable {
case safari(url: URL) case safari(url: URL)
} }
enum Category: String, CaseIterable, Identifiable, LosslessStringConvertible { enum Category: String, CaseIterable, Identifiable, LosslessStringConvertible, Hashable {
var description: String { rawValue } var description: String { rawValue }
var id: String { rawValue } var id: String { rawValue }

View File

@ -60,6 +60,7 @@ final class LibraryRefreshViewModelTest: XCTestCase {
} }
private func makeOPDSData(zimFileID: UUID) -> String { private func makeOPDSData(zimFileID: UUID) -> String {
// swiftlint:disable line_length
""" """
<feed xmlns="http://www.w3.org/2005/Atom" <feed xmlns="http://www.w3.org/2005/Atom"
xmlns:dc="http://purl.org/dc/terms/" xmlns:dc="http://purl.org/dc/terms/"
@ -88,6 +89,7 @@ final class LibraryRefreshViewModelTest: XCTestCase {
</entry> </entry>
</feed> </feed>
""" """
// swiftlint:enable line_length
} }
/// Test time out fetching library data. /// Test time out fetching library data.
@ -96,9 +98,12 @@ final class LibraryRefreshViewModelTest: XCTestCase {
HTTPTestingURLProtocol.handler = { urlProtocol in HTTPTestingURLProtocol.handler = { urlProtocol in
urlProtocol.client?.urlProtocol(urlProtocol, didFailWithError: URLError(URLError.Code.timedOut)) urlProtocol.client?.urlProtocol(urlProtocol, didFailWithError: URLError(URLError.Code.timedOut))
} }
let testDefaults = TestDefaults()
testDefaults.setup()
let viewModel = LibraryViewModel(urlSession: urlSession, let viewModel = LibraryViewModel(urlSession: urlSession,
processFactory: { LibraryProcess() }) processFactory: { LibraryProcess(defaultState: .initial) },
defaults: testDefaults,
categories: CategoriesToLanguages(withDefaults: testDefaults))
await viewModel.start(isUserInitiated: true) await viewModel.start(isUserInitiated: true)
XCTAssert(viewModel.error is LibraryRefreshError) XCTAssert(viewModel.error is LibraryRefreshError)
XCTAssertEqual( XCTAssertEqual(
@ -119,8 +124,12 @@ final class LibraryRefreshViewModelTest: XCTestCase {
urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol) urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol)
} }
let testDefaults = TestDefaults()
testDefaults.setup()
let viewModel = LibraryViewModel(urlSession: urlSession, let viewModel = LibraryViewModel(urlSession: urlSession,
processFactory: { LibraryProcess() }) processFactory: { LibraryProcess(defaultState: .initial) },
defaults: testDefaults,
categories: CategoriesToLanguages(withDefaults: testDefaults))
await viewModel.start(isUserInitiated: true) await viewModel.start(isUserInitiated: true)
XCTAssert(viewModel.error is LibraryRefreshError) XCTAssert(viewModel.error is LibraryRefreshError)
XCTAssertEqual( XCTAssertEqual(
@ -137,13 +146,17 @@ final class LibraryRefreshViewModelTest: XCTestCase {
url: URL.mock(), url: URL.mock(),
statusCode: 200, httpVersion: nil, headerFields: [:] 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?.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed)
urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol) urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol)
} }
let testDefaults = TestDefaults()
testDefaults.setup()
let viewModel = LibraryViewModel(urlSession: urlSession, let viewModel = LibraryViewModel(urlSession: urlSession,
processFactory: { LibraryProcess() }) processFactory: { LibraryProcess(defaultState: .initial) },
defaults: testDefaults,
categories: CategoriesToLanguages(withDefaults: testDefaults))
await viewModel.start(isUserInitiated: true) await viewModel.start(isUserInitiated: true)
XCTExpectFailure("Requires work in dependency to resolve the issue.") XCTExpectFailure("Requires work in dependency to resolve the issue.")
XCTAssertEqual( 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. /// Test zim file entity is created, and metadata are saved when new zim file becomes available in online catalog.
@MainActor @MainActor
// swiftlint:disable:next function_body_length
func testNewZimFileAndProperties() async throws { func testNewZimFileAndProperties() async throws {
let zimFileID = UUID() let zimFileID = UUID()
HTTPTestingURLProtocol.handler = { urlProtocol in HTTPTestingURLProtocol.handler = { urlProtocol in
@ -166,9 +180,12 @@ final class LibraryRefreshViewModelTest: XCTestCase {
urlProtocol.client?.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed) urlProtocol.client?.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed)
urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol) urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol)
} }
let testDefaults = TestDefaults()
testDefaults.setup()
let viewModel = LibraryViewModel(urlSession: urlSession, let viewModel = LibraryViewModel(urlSession: urlSession,
processFactory: { LibraryProcess() }) processFactory: { LibraryProcess(defaultState: .initial) },
defaults: testDefaults,
categories: CategoriesToLanguages(withDefaults: testDefaults))
await viewModel.start(isUserInitiated: true) await viewModel.start(isUserInitiated: true)
// check no error has happened // check no error has happened
@ -185,6 +202,7 @@ final class LibraryRefreshViewModelTest: XCTestCase {
XCTAssertEqual(zimFile.id, zimFileID) XCTAssertEqual(zimFile.id, zimFileID)
XCTAssertEqual(zimFile.articleCount, 50001) XCTAssertEqual(zimFile.articleCount, 50001)
XCTAssertEqual(zimFile.category, Category.wikipedia.rawValue) 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.created, try! Date("2023-01-07T00:00:00Z", strategy: .iso8601))
XCTAssertEqual( XCTAssertEqual(
zimFile.downloadURL, zimFile.downloadURL,
@ -211,14 +229,21 @@ final class LibraryRefreshViewModelTest: XCTestCase {
XCTAssertEqual(zimFile.persistentID, "wikipedia_en_top") XCTAssertEqual(zimFile.persistentID, "wikipedia_en_top")
XCTAssertEqual(zimFile.requiresServiceWorkers, false) XCTAssertEqual(zimFile.requiresServiceWorkers, false)
XCTAssertEqual(zimFile.size, 6515656704) XCTAssertEqual(zimFile.size, 6515656704)
// clean up
context.delete(zimFile)
} }
/// Test zim file deprecation /// Test zim file deprecation
@MainActor @MainActor
func testZimFileDeprecation() async throws { func testZimFileDeprecation() async throws {
let testDefaults = TestDefaults()
testDefaults.setup()
// refresh library for the first time, which should create one zim file // refresh library for the first time, which should create one zim file
let viewModel = LibraryViewModel(urlSession: urlSession, let viewModel = LibraryViewModel(urlSession: urlSession,
processFactory: { LibraryProcess() }) processFactory: { LibraryProcess(defaultState: .initial) },
defaults: testDefaults,
categories: CategoriesToLanguages(withDefaults: testDefaults))
await viewModel.start(isUserInitiated: true) await viewModel.start(isUserInitiated: true)
let context = Database.shared.viewContext let context = Database.shared.viewContext
let zimFile1 = try XCTUnwrap(try context.fetch(ZimFile.fetchRequest()).first) let zimFile1 = try XCTUnwrap(try context.fetch(ZimFile.fetchRequest()).first)
@ -231,7 +256,7 @@ final class LibraryRefreshViewModelTest: XCTestCase {
XCTAssertNotEqual(zimFile1.fileID, zimFile2.fileID) XCTAssertNotEqual(zimFile1.fileID, zimFile2.fileID)
// set fileURLBookmark of zim file 2 // 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() try context.save()
// refresh library for the third time // 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 // check there are two zim files in the database, and zim file 2 is not deprecated
XCTAssertEqual(zimFiles.count, 2) XCTAssertEqual(zimFiles.count, 2)
XCTAssertEqual(zimFiles.filter({ $0.fileID == zimFile2.fileID }).count, 1) XCTAssertEqual(zimFiles.filter({ $0.fileID == zimFile2.fileID }).count, 1)
// clean up
context.delete(zimFile1)
context.delete(zimFile2)
} }
} }

41
Tests/TestDefaults.swift Normal file
View 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
}
}
}

View File

@ -15,7 +15,6 @@
import CoreData import CoreData
import Combine import Combine
import Defaults
import os import os
enum LibraryState { enum LibraryState {
@ -24,8 +23,8 @@ enum LibraryState {
case complete case complete
case error case error
static func defaultState() -> LibraryState { static func defaultState(defaults: Defaulting = UDefaults()) -> LibraryState {
if Defaults[.libraryLastRefresh] == nil { if defaults[.libraryLastRefresh] == nil {
return .initial return .initial
} else { } else {
return .complete return .complete
@ -52,6 +51,8 @@ final class LibraryViewModel: ObservableObject {
@MainActor @Published var state: LibraryState @MainActor @Published var state: LibraryState
@MainActor private let process: LibraryProcess @MainActor private let process: LibraryProcess
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private let defaults: Defaulting
private let categories: CategoriesProtocol
private let urlSession: URLSession private let urlSession: URLSession
private var insertionCount = 0 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")! private static let catalogURL = URL(string: "https://library.kiwix.org/catalog/v2/entries?count=-1")!
@MainActor @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.urlSession = urlSession ?? URLSession.shared
self.process = processFactory() self.process = processFactory()
self.defaults = defaults
self.categories = categories
state = process.state state = process.state
process.$state.sink { [weak self] newState in process.$state.sink { [weak self] newState in
self?.state = newState self?.state = newState
@ -78,8 +86,8 @@ final class LibraryViewModel: ObservableObject {
guard process.state != .inProgress else { return } guard process.state != .inProgress else { return }
do { do {
// decide if refresh should proceed // decide if refresh should proceed
let lastRefresh: Date? = Defaults[.libraryLastRefresh] let lastRefresh: Date? = defaults[.libraryLastRefresh]
let hasAutoRefresh: Bool = Defaults[.libraryAutoRefresh] let hasAutoRefresh: Bool = defaults[.libraryAutoRefresh]
let isStale = (lastRefresh?.timeIntervalSinceNow ?? -3600) <= -3600 let isStale = (lastRefresh?.timeIntervalSinceNow ?? -3600) <= -3600
guard isUserInitiated || (hasAutoRefresh && isStale) else { return } 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) // this is the case when we have no new data (304 http)
// but we still need to refresh the memory only stored // but we still need to refresh the memory only stored
// zimfile categories to languages dictionary // zimfile categories to languages dictionary
if CategoriesToLanguages.allCategories().count < 2 { if categories.allCategories().count < 2 {
let context = Database.shared.viewContext let context = Database.shared.viewContext
if let zimFiles: [ZimFile] = try? context.fetch(ZimFile.fetchRequest()) { if let zimFiles: [ZimFile] = try? context.fetch(ZimFile.fetchRequest()) {
saveCategoryAvailableInLanguages(fromDBZimFiles: zimFiles) saveCategoryAvailableInLanguages(fromDBZimFiles: zimFiles)
@ -113,15 +121,15 @@ final class LibraryViewModel: ObservableObject {
} }
let parser = try await parse(data: data) let parser = try await parse(data: data)
// delete all old ISO Lang Code entries if needed, by passing in an empty parser // 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()) try await process(parser: DeletingParser())
Defaults[.libraryUsingOldISOLangCodes] = false defaults[.libraryUsingOldISOLangCodes] = false
} }
// process the feed // process the feed
try await process(parser: parser) try await process(parser: parser)
// update library last refresh timestamp // update library last refresh timestamp
Defaults[.libraryLastRefresh] = Date() defaults[.libraryLastRefresh] = Date()
saveCategoryAvailableInLanguages(using: parser) saveCategoryAvailableInLanguages(using: parser)
@ -156,7 +164,7 @@ final class LibraryViewModel: ObservableObject {
dictionary[category] = allLanguagesForCategory dictionary[category] = allLanguagesForCategory
} }
} }
CategoriesToLanguages.save(dictionary) categories.save(dictionary)
} }
private func saveCategoryAvailableInLanguages(fromDBZimFiles zimFiles: [ZimFile]) { private func saveCategoryAvailableInLanguages(fromDBZimFiles zimFiles: [ZimFile]) {
@ -172,7 +180,7 @@ final class LibraryViewModel: ObservableObject {
} }
dictionary[category] = allLanguagesForCategory dictionary[category] = allLanguagesForCategory
} }
CategoriesToLanguages.save(dictionary) categories.save(dictionary)
} }
/// The fetched content is filtered by the languages set in settings. /// 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 }) let validCodes = Set<String>(languages.map { $0.code })
// preserve only valid selections by: // preserve only valid selections by:
// converting earlier user selections, and filtering out invalid ones // 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) 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 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 deviceLangSet = Set<String>([deviceLang].compactMap { $0 })
let validDefaults = LanguagesConverter.convert(codes: deviceLangSet, validCodes: validCodes) let validDefaults = LanguagesConverter.convert(codes: deviceLangSet, validCodes: validCodes)
if validDefaults.isEmpty { // meaning the device language isn't valid (or nil) if validDefaults.isEmpty { // meaning the device language isn't valid (or nil)
Defaults[.libraryLanguageCodes] = [fallbackToEnglish] defaults[.libraryLanguageCodes] = [fallbackToEnglish]
} else { } else {
Defaults[.libraryLanguageCodes] = validDefaults defaults[.libraryLanguageCodes] = validDefaults
} }
} }
private func fetchData() async throws -> Data? { private func fetchData() async throws -> Data? {
do { do {
var request = URLRequest(url: Self.catalogURL, timeoutInterval: 20) 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) let (data, response) = try await self.urlSession.data(for: request)
guard let response = response as? HTTPURLResponse else { return nil } guard let response = response as? HTTPURLResponse else { return nil }
switch response.statusCode { switch response.statusCode {
case 200: case 200:
if let eTag = response.allHeaderFields["Etag"] as? String { if let eTag = response.allHeaderFields["Etag"] as? String {
Defaults[.libraryETag] = eTag defaults[.libraryETag] = eTag
} }
// OK to process further // OK to process further
os_log("Retrieved OPDS Data, size: %llu bytes", log: Log.OPDS, type: .info, data.count) os_log("Retrieved OPDS Data, size: %llu bytes", log: Log.OPDS, type: .info, data.count)

View File

@ -26,9 +26,12 @@ struct Library: View {
private let categories: [Category] private let categories: [Category]
let dismiss: (() -> Void)? let dismiss: (() -> Void)?
init(dismiss: (() -> Void)?) { init(
dismiss: (() -> Void)?,
categories: [Category] = CategoriesToLanguages().allCategories()
) {
self.dismiss = dismiss self.dismiss = dismiss
categories = CategoriesToLanguages.allCategories() self.categories = categories
} }
var body: some View { var body: some View {

View File

@ -24,8 +24,11 @@ struct ZimFilesCategories: View {
private var categories: [Category] private var categories: [Category]
private let dismiss: (() -> Void)? private let dismiss: (() -> Void)?
init(dismiss: (() -> Void)?) { init(
categories = CategoriesToLanguages.allCategories() dismiss: (() -> Void)?,
categories: [Category] = CategoriesToLanguages().allCategories()
) {
self.categories = categories
selected = categories.first ?? .wikipedia selected = categories.first ?? .wikipedia
self.dismiss = dismiss self.dismiss = dismiss
} }