From a7bcceff47a5aacfdf2f21a9da7bab0bc8bb4c76 Mon Sep 17 00:00:00 2001 From: Shawn Schantz Date: Mon, 4 May 2026 11:38:56 -0400 Subject: [PATCH] feat(mac): add protocols, stubs and unit tests added suite for Settings Container and stubs to test it without accessing disk or user defaults --- mac/Config/Config/KeyboardListView.swift | 4 +- .../Sources/KeymanSettings/DefaultsRepo.swift | 19 ++++ .../Sources/KeymanSettings/PackageRepo.swift | 18 ++++ .../KeymanSettings/SettingsContainer.swift | 97 +++++++++++-------- .../Sources/Model/Keyboard.swift | 22 ++++- .../Sources/Model/KeymanPackage.swift | 64 ++++++------ ...ository.swift => DefaultsRepository.swift} | 18 ++-- .../Persistence/PackageRepository.swift | 34 +++++-- .../KeymanSettingsTests.swift | 85 +++++++++++++--- .../Tests/KeymanSettingsTests/RepoStubs.swift | 96 ++++++++++++++++++ 10 files changed, 353 insertions(+), 104 deletions(-) create mode 100644 mac/KeymanSettings/Sources/KeymanSettings/DefaultsRepo.swift create mode 100644 mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift rename mac/KeymanSettings/Sources/Persistence/{SettingsRepository.swift => DefaultsRepository.swift} (86%) create mode 100644 mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift diff --git a/mac/Config/Config/KeyboardListView.swift b/mac/Config/Config/KeyboardListView.swift index 2a74130342..26aaa2b01c 100644 --- a/mac/Config/Config/KeyboardListView.swift +++ b/mac/Config/Config/KeyboardListView.swift @@ -20,8 +20,8 @@ struct KeyboardListView: View { ForEach($keyboards) { $keyboard in HStack { Toggle("", isOn: Binding( - get: { settings.isKeyboardEnabled(packageId: packageId, keyboardId: keyboard.keyboardId) }, - set: { newValue in settings.setKeyboardEnabled(packageId: packageId, keyboardId: keyboard.keyboardId, enabled: newValue) + get: { settings.isKeyboardEnabled(packageId: packageId, keyboardKey: keyboard.keyboardKey) }, + set: { newValue in settings.setKeyboardEnabled(packageId: packageId, keyboardKey: keyboard.keyboardKey, enabled: newValue) settings.objectWillChange.send() } )) Text(keyboard.keyboardId) diff --git a/mac/KeymanSettings/Sources/KeymanSettings/DefaultsRepo.swift b/mac/KeymanSettings/Sources/KeymanSettings/DefaultsRepo.swift new file mode 100644 index 0000000000..1ef5241479 --- /dev/null +++ b/mac/KeymanSettings/Sources/KeymanSettings/DefaultsRepo.swift @@ -0,0 +1,19 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-04-28 + * + * DefaultsRepo is a protocol that exposes the ability to read, write and remove + * Keyman UserDefaults settings. + * + */ + +import Foundation + +public protocol DefaultsRepo { + func readEnabledKeyboards() -> Set + func writeEnabledKeyboards(enabledKeyboardsArray: [String]) + func readSelectedKeyboard() -> String + func logDefaults() + func clearDefaults() +} diff --git a/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift b/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift new file mode 100644 index 0000000000..b70a61c011 --- /dev/null +++ b/mac/KeymanSettings/Sources/KeymanSettings/PackageRepo.swift @@ -0,0 +1,18 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2025-12-10 + * + * PackageRepo is a protocol that exposes the ability to read, write and remove + * Keyman packages. + * + */ + +import Foundation + +public protocol PackageRepo { + func keyman19SharedDataDirectoryExists() -> Bool + func createKeyman19SharedDataDirectories() + func loadPackages() -> [KeymanPackage] + func deletePackage(package: KeymanPackage) +} diff --git a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift index 301afc5f1d..a5b45e1e30 100644 --- a/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift +++ b/mac/KeymanSettings/Sources/KeymanSettings/SettingsContainer.swift @@ -7,6 +7,15 @@ * Provides a place for the config app can bind directly to the settings * and update when changes are made * + * The Settings consist of two types of data: + * - Keyman packages that have been installed on disk in the Group Containers directory + * - Some lightwieght settings that are stored in the macOS UserDefaults Database + * + * Both the packages and the defaults are in a shared location that can be accessed by + * the Keyman config app and the Keyman input method. + * + * This class accesses the two types of data through PackageRepository and DefaultsRepository + * and publishes the combined data to the UI through the `installedPackages` array. */ import Foundation @@ -20,11 +29,11 @@ public class SettingsContainer : ObservableObject { // packages are loaded from disk, each package may contain one or more keyboard @Published public var installedPackages: [KeymanPackage] - fileprivate let packageRepository: PackageRepository - fileprivate let settingsRepository: SettingsRepository + fileprivate let packageRepository: PackageRepo + fileprivate let defaultsRepository: DefaultsRepo // the selected keyboard is stored in the UserDefaults - fileprivate var currentKeyboard: String + fileprivate var selectedKeyboard: String #warning("does the config app need to be aware of this?") public init() { @@ -32,7 +41,7 @@ public class SettingsContainer : ObservableObject { // create the settings repository, gaining access to the app group UserDefaults do { - try self.settingsRepository = SettingsRepository(suiteName: KeymanPaths.groupId) + try self.defaultsRepository = DefaultsRepository(suiteName: KeymanPaths.groupId) print("Found group container") } catch UserDefaultsError.unknownSuite { fatalError("Group container not found.") @@ -40,19 +49,25 @@ public class SettingsContainer : ObservableObject { fatalError("Unable to access settings in group container.") } - self.currentKeyboard = self.settingsRepository.readSelectedKeyboard() + self.selectedKeyboard = self.defaultsRepository.readSelectedKeyboard() // first load all the installed packages from disk self.installedPackages = [] - if let persistedPackages = self.loadPackages() { - self.installedPackages = persistedPackages - } + self.loadPackages() // next, apply the settings to the packages // this mainly consists of marking them as enabled or not self.applySettingsToInstalledPackages() } + + public init(defaultsRepo: DefaultsRepo, packageRepo: PackageRepo) { + self.defaultsRepository = defaultsRepo + self.packageRepository = packageRepo + self.selectedKeyboard = self.defaultsRepository.readSelectedKeyboard() + + self.installedPackages = [] + } #warning("delete test code") public func debug() { @@ -67,17 +82,17 @@ public class SettingsContainer : ObservableObject { * for debugging: prints UserDefaults values */ public func logSettings() { - self.settingsRepository.logSettings() + self.defaultsRepository.logDefaults() } public func clearSettings() { - self.settingsRepository.clearSettings() + self.defaultsRepository.clearDefaults() } - public func findPackage(packageId: UUID) throws -> KeymanPackage { + public func findPackage(packageId: UUID) -> KeymanPackage? { guard let package = self.installedPackages.first(where: { $0.id == packageId }) else { print ("Error: could not find package with ID: \(packageId)") - throw SettingsError.unknownPackage + return nil } return package @@ -104,15 +119,14 @@ public class SettingsContainer : ObservableObject { * returns true if the keyboard is enabled * when enabled, the keyboard appears in the Keyman sub menu in the mac */ - public func isKeyboardEnabled(packageId: UUID, keyboardId: String) -> Bool { - var enabled = false; - - if let package = try? self.findPackage(packageId: packageId) { - enabled = package.isKeyboardEnabled(keyboardId: keyboardId) - print ("isEnabled for \(keyboardId) returning with \(enabled)") - } else { - print ("Could not read keyboard state for package: \(packageId) and keyboard: \(keyboardId)") + public func isKeyboardEnabled(packageId: UUID, keyboardKey: String) -> Bool { + guard let package = self.findPackage(packageId: packageId) else { + print ("Could not read keyboard state for package: \(packageId) and keyboard: \(keyboardKey)") + return false } + + let enabled = package.isKeyboardEnabled(keyboardKey: keyboardKey) + print ("isEnabled for \(keyboardKey) returning with \(enabled)") return enabled } @@ -120,14 +134,14 @@ public class SettingsContainer : ObservableObject { /** * enable or disable the keyboard */ - public func setKeyboardEnabled(packageId: UUID, keyboardId: String, enabled: Bool) { - if let package = try? self.findPackage(packageId: packageId) { - // update state of Keyboard - print ("setKeyboardEnabled for \(keyboardId) setting to \(enabled)") - package.enableKeyboard(keyboardId: keyboardId, enabled: enabled) - } else { - print ("Could not read keyboard state for package: \(packageId) and keyboard: \(keyboardId)") + public func setKeyboardEnabled(packageId: UUID, keyboardKey: String, enabled: Bool) { + guard let package = self.findPackage(packageId: packageId) else { + print ("Could not read keyboard state for package: \(packageId) and keyboard: \(keyboardKey)") + return } + + print ("setKeyboardEnabled for \(keyboardKey) setting to \(enabled)") + package.enableKeyboard(keyboardKey: keyboardKey, enabled: enabled) // update persisted state in UserDefaults enabledKeyboards array self.persistKeyboardState() @@ -138,23 +152,25 @@ public class SettingsContainer : ObservableObject { */ func persistKeyboardState() { let enabledKeyboards = self.getAllEnabledKeyboardSettingsKeys() - self.settingsRepository.writeEnabledKeyboards(enabledKeyboardsArray: Array(enabledKeyboards)) + self.defaultsRepository.writeEnabledKeyboards(enabledKeyboardsArray: Array(enabledKeyboards)) } /** * read the Keyman packages from the group container directory and store in the keyboardPackages array */ - func loadPackages() -> [KeymanPackage]? { - var packagesArray = nil as [KeymanPackage]? + func loadPackages() { + var packagesArray = nil as [KeymanPackage]? - // load keyboards from disk + // read keyboards from disk if (self.packageRepository.keyman19SharedDataDirectoryExists()) { packagesArray = self.packageRepository.loadPackages() } else { self.packageRepository.createKeyman19SharedDataDirectories() } - return packagesArray + if let persistedPackages = packagesArray { + self.installedPackages = persistedPackages + } } /** @@ -166,7 +182,7 @@ public class SettingsContainer : ObservableObject { // loop through all the installed packages and for each of the package's keyboards, // insert the settings key for the keyboard self.installedPackages.forEach { $0.keyboards.forEach - {settingsKeys.insert($0.keyboardSettingsKey)} + {settingsKeys.insert($0.keyboardKey)} } return settingsKeys @@ -182,7 +198,7 @@ public class SettingsContainer : ObservableObject { // insert the settings key for every enabled keyboard self.installedPackages.forEach { $0.keyboards.forEach { if ($0.enabled) { - settingsKeys.insert($0.keyboardSettingsKey) + settingsKeys.insert($0.keyboardKey) } } } @@ -195,14 +211,14 @@ public class SettingsContainer : ObservableObject { */ func validateSettings() { let installedKeyboardKeys = self.getAllKeyboardSettingsKeys() - let enabledKeyboardKeys = self.settingsRepository.readEnabledKeyboards() + let enabledKeyboardKeys = self.defaultsRepository.readEnabledKeyboards() if (enabledKeyboardKeys.isSubset(of: installedKeyboardKeys)) { print("only installed keyboards are listed as enabled: no need to synchronize") } else { print("enabled keyboards list contains uninstalled keyboards: synchronize enabled keyboards list") let installedEnabledKeyboardKeys = enabledKeyboardKeys.intersection(installedKeyboardKeys) - self.settingsRepository.writeEnabledKeyboards(enabledKeyboardsArray: Array(installedEnabledKeyboardKeys)) + self.defaultsRepository.writeEnabledKeyboards(enabledKeyboardsArray: Array(installedEnabledKeyboardKeys)) } } @@ -212,11 +228,16 @@ public class SettingsContainer : ObservableObject { func applySettingsToInstalledPackages() { self.validateSettings() - let enabledKeyboards = self.settingsRepository.readEnabledKeyboards() + let enabledKeyboards = self.defaultsRepository.readEnabledKeyboards() // set enabled flag if the keyboard is contained in the set of enabledKeyboards self.installedPackages.forEach { $0.keyboards.forEach - {$0.enabled = enabledKeyboards.contains($0.keyboardSettingsKey)} + { + let keyboard = $0 + print("before keyboard \(keyboard.name) has enabled status: \(keyboard.enabled)") + $0.enabled = enabledKeyboards.contains($0.keyboardKey) + print("after keyboard \(keyboard.name) has enabled status: \(keyboard.enabled)") + } } } } diff --git a/mac/KeymanSettings/Sources/Model/Keyboard.swift b/mac/KeymanSettings/Sources/Model/Keyboard.swift index b54a425d7e..3ed8cb726f 100644 --- a/mac/KeymanSettings/Sources/Model/Keyboard.swift +++ b/mac/KeymanSettings/Sources/Model/Keyboard.swift @@ -22,10 +22,11 @@ public class Keyboard: Identifiable, Hashable, Equatable { public var keyboardDirectoryUrl: URL // the URL of the .kmx file for the package public let kmxFileUrl: URL - // the UserDefaults key for this package, used for the selected Keyboard and enabled keyboards + // a key to uniquely identify the keyboard + // in the UserDefaults this key is used for the selected Keyboard and enabled keyboards properties // the key is in the form "/[package directory]/[package name].kmx" // for example, "/khmer_angkor/khmer_angkor.kmx" - public let keyboardSettingsKey: String + public let keyboardKey: String public init(keyboardSource: KeyboardSource, directoryUrl: URL) { self.enabled = true @@ -33,12 +34,23 @@ public class Keyboard: Identifiable, Hashable, Equatable { self.keyboardId = keyboardSource.id self.keyboardDirectoryUrl = directoryUrl self.kmxFileUrl = Keyboard.deriveKmxFileUrl(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) - self.keyboardSettingsKey = Keyboard.deriveKeyboardSettingsKey(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) + self.keyboardKey = Keyboard.deriveKeyboardSettingsKey(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) -// ConfigLogger.shared.testLogger.debug("keyboard created for: \(keyboardSource.id)") - print("keyboard created for: \(keyboardSource.id) \r with kmxFileUrl: \(self.kmxFileUrl) \r and settingsKey: \(self.keyboardSettingsKey)") + print("keyboard created for: \(keyboardSource.id) \r with kmxFileUrl: \(self.kmxFileUrl) \r and settingsKey: \(self.keyboardKey)") } + /** + * initializer that does not rely on package source -- provided to create unit test data + */ + public init(name: String, keyboardId: String, keyboardDirectoryUrl: URL, enabled: Bool) { + self.name = name + self.keyboardId = keyboardId + self.keyboardDirectoryUrl = keyboardDirectoryUrl + self.kmxFileUrl = Keyboard.deriveKmxFileUrl(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) + self.enabled = enabled + self.keyboardKey = Keyboard.deriveKeyboardSettingsKey(from: self.keyboardDirectoryUrl, keyboardId: self.keyboardId) + } + public static func == (lhs: Keyboard, rhs: Keyboard) -> Bool { return lhs.keyboardId == rhs.keyboardId && lhs.enabled == rhs.enabled } diff --git a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift index bf7e22d4e3..c1c245385f 100644 --- a/mac/KeymanSettings/Sources/Model/KeymanPackage.swift +++ b/mac/KeymanSettings/Sources/Model/KeymanPackage.swift @@ -22,34 +22,25 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { }() public var id = UUID() - let source: PackageSource - public var keyboards: [Keyboard] - + // the URL of the directory in which the package is contained public let sourceDirectoryUrl: URL // the URL of the kmp.json file for the package + + public var keyboards: [Keyboard] + public let packageName: String + public let packageVersion: String + + public let copyright: String? public let jsonFileUrl: URL public let readmeFileUrl: URL? public let graphicFileUrl: URL? public let graphicImage: NSImage? - // computed properties for convenience - public var packageName: String { - return source.info.name.description - } - public var packageVersion: String { - return source.info.version.description - } - public var copyright: String? { - if let copy = source.info.copyright?.description { - return copy - } else { - return nil - } - } - init(packageSource: PackageSource) { - self.source = packageSource + self.packageName = packageSource.info.name.description + self.packageVersion = packageSource.info.version.description + self.copyright = packageSource.info.copyright?.description self.sourceDirectoryUrl = packageSource.directoryUrl! self.jsonFileUrl = packageSource.jsonFileUrl! @@ -65,7 +56,7 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { var keyboardsArray = [Keyboard]() - if let keyboards = source.keyboards { + if let keyboards = packageSource.keyboards { for keyboardSource in keyboards { let keyboard = Keyboard(keyboardSource: keyboardSource, directoryUrl: self.sourceDirectoryUrl) keyboardsArray.append(keyboard) @@ -73,21 +64,38 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { } self.keyboards = keyboardsArray -// ConfigLogger.shared.testLogger.debug("package created for: \(packageSource.packageName)") print("package created for: \(packageSource.packageName)") } - - public func isKeyboardEnabled(keyboardId: String) -> Bool { + + /** + * initializer that does not rely on package source -- provided to create unit test data + */ + public init(sourceDirectoryUrl: URL, keyboards: [Keyboard], packageName: String, packageVersion: String, copyright: String? = nil, jsonFileUrl: URL, readmeFileUrl: URL? = nil, graphicFileUrl: URL? = nil, graphicImage: NSImage? = nil) { + self.sourceDirectoryUrl = sourceDirectoryUrl + self.keyboards = keyboards + self.packageName = packageName + self.packageVersion = packageVersion + self.copyright = copyright + self.jsonFileUrl = jsonFileUrl + self.readmeFileUrl = readmeFileUrl + self.graphicFileUrl = graphicFileUrl + self.graphicImage = graphicImage + } + + public func isKeyboardEnabled(keyboardKey: String) -> Bool { var enabled = false - if let keyboard = self.keyboards.first(where: { $0.keyboardId == keyboardId }) { + if let keyboard = self.keyboards.first(where: { $0.keyboardKey == keyboardKey }) { + let akeyboard = keyboard + print("keyboard: \(akeyboard.keyboardKey) is enabled: \(keyboard.enabled)") enabled = keyboard.enabled } + print("returning: \(enabled)") return enabled } - public func enableKeyboard(keyboardId: String, enabled: Bool) { - let keyboard = self.keyboards.first(where: { $0.keyboardId == keyboardId }) + public func enableKeyboard(keyboardKey: String, enabled: Bool) { + let keyboard = self.keyboards.first(where: { $0.keyboardKey == keyboardKey }) if (keyboard != nil) { keyboard!.enabled = enabled } @@ -97,7 +105,7 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { guard let keyboard = self.keyboards.first(where: { $0.keyboardId == keyboardId }) else { return nil } - return keyboard.keyboardSettingsKey + return keyboard.keyboardKey } public func getEnabledKeyboardsSettingsKeys() -> [String] { @@ -105,7 +113,7 @@ public class KeymanPackage: Identifiable, Hashable, Equatable { self.keyboards.forEach { keyboard in if (keyboard.enabled) { - settingsKeyArray.append(keyboard.keyboardSettingsKey) + settingsKeyArray.append(keyboard.keyboardKey) } } diff --git a/mac/KeymanSettings/Sources/Persistence/SettingsRepository.swift b/mac/KeymanSettings/Sources/Persistence/DefaultsRepository.swift similarity index 86% rename from mac/KeymanSettings/Sources/Persistence/SettingsRepository.swift rename to mac/KeymanSettings/Sources/Persistence/DefaultsRepository.swift index 8c17c65f3f..ea5ad0b9a0 100644 --- a/mac/KeymanSettings/Sources/Persistence/SettingsRepository.swift +++ b/mac/KeymanSettings/Sources/Persistence/DefaultsRepository.swift @@ -3,8 +3,8 @@ * * Created by Shawn Schantz on 2025-12-10 * - * SettingsRepository is responsible for reading, writing and removing - * settings stored in the UserDefaults + * DefaultsRepository is responsible for reading, writing and removing + * values stored in the UserDefaults * */ @@ -14,7 +14,7 @@ public enum UserDefaultsError: Error { case unknownSuite } -public struct SettingsRepository { +public class DefaultsRepository: DefaultsRepo { fileprivate let pathUtil: KeymanPaths let defaultsSuiteName: String let defaults: UserDefaults @@ -52,6 +52,7 @@ public struct SettingsRepository { /** * update the list of enabled keyboards in the UserDefaults + * each String in the array must be formatted `/[packageName]/[keyboardName].kmx` */ public func writeEnabledKeyboards(enabledKeyboardsArray: [String]) { self.defaults.set(enabledKeyboardsArray, forKey: kEnabledKeyboardsKey) @@ -67,13 +68,14 @@ public struct SettingsRepository { /** * update the selected keyboard keyboards in the UserDefaults + * `keyboardName` must be formatted `/[packageName]/[keyboardName].kmx` */ public func writeSelectedKeyboard(keyboardName: String) { self.defaults.set(keyboardName, forKey: kSelectedKeyboardKey) } /** - * read the boolean setting which allows a Sentry error to be generated for testing + * read the boolean value that allows a Sentry error to be generated for testing * This value is set in the **standard** application UserDefaults -- not the shared app group defaults. * This is necessary because it must be set to true from the command line. */ @@ -82,7 +84,7 @@ public struct SettingsRepository { } /** - * read the data model version for the settings to know what values and format to expect + * read the data model version to know what values and format to expect */ public func readDataModelVersion() -> Int { // note that zero is returned if key is not found in UserDefaults, @@ -90,7 +92,7 @@ public struct SettingsRepository { } /** - * read the boolean setting which indicates whether the OSK is opened when the input method is activated + * read the boolean value that indicates whether the OSK is opened when the input method is activated * There may be no need to read this from with the config app. */ public func readShowOskOnActivate() -> Bool { @@ -114,7 +116,7 @@ public struct SettingsRepository { * with app group UserDefaults, there is no way to view from the command line * (unlike standard application-level UserDefaults) */ - public func logSettings() { + public func logDefaults() { print("UserDefaults:") print("\(kSelectedKeyboardKey): \(self.readSelectedKeyboard())") print("\(kDataModelVersionKey): \(self.readDataModelVersion())") @@ -128,7 +130,7 @@ public struct SettingsRepository { * for debugging: clear all the entries for the app group UserDefaults * unlike standard application-level UserDefaults, there is no way to view from the command line */ - public func clearSettings() { + public func clearDefaults() { self.defaults.dictionaryRepresentation().keys.forEach { key in self.defaults.removeObject(forKey: key) } diff --git a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift index be9a2c20a3..a5acfb61f9 100644 --- a/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift +++ b/mac/KeymanSettings/Sources/Persistence/PackageRepository.swift @@ -3,14 +3,14 @@ * * Created by Shawn Schantz on 2025-12-10 * - * DataRepository is responsible for reading, writing and removing + * PackageRepository is responsible for reading, writing and removing * Keyman data on disk. * */ import Foundation -public struct PackageRepository { +public class PackageRepository: PackageRepo { fileprivate let packageFileName = "kmp.json" fileprivate let pathUtil: KeymanPaths @@ -18,7 +18,10 @@ public struct PackageRepository { self.pathUtil = KeymanPaths() } - func loadPackages() -> [KeymanPackage] { + /** + * after reading the Keyman packages from disk, wrap each package as a `KeymanPackage` object + */ + public func loadPackages() -> [KeymanPackage] { var installedPackages: [KeymanPackage] = [] let packageSourceArray = self.readKeymanPackagesForKeyman19() @@ -38,7 +41,7 @@ public struct PackageRepository { /** * delete the package from disk */ - func deletePackage(package: KeymanPackage) { + public func deletePackage(package: KeymanPackage) { print("deleting package: \(package.sourceDirectoryUrl)") do { try FileManager.default.removeItem(at: package.sourceDirectoryUrl) @@ -51,7 +54,7 @@ public struct PackageRepository { /** * Creates the directory tree where keyboards are stored under the standard 'Group Containers' directory */ - func createKeyman19SharedDataDirectories() { + public func createKeyman19SharedDataDirectories() { if let keyboardDirectory = pathUtil.keyman19KeyboardsDirectory { do { @@ -71,10 +74,11 @@ public struct PackageRepository { } } + // TODO: delete /** * for group container testing purposes to check directory access */ - public func writeTestFileToContainer() { + func writeTestFileToContainer() { if let keyboardDirectory = self.pathUtil.keyman19KeyboardsDirectory { print("About to write to: \(keyboardDirectory.path)") @@ -113,7 +117,10 @@ public struct PackageRepository { return exists && isDirectory.boolValue } - public func readKeymanPackagesForKeyman19() -> [PackageSource] { + /** + * read keyboard packages at Keyman 19 location, inside Group Containers directory + */ + func readKeymanPackagesForKeyman19() -> [PackageSource] { guard let keyboardsUrl = self.pathUtil.keyman19KeyboardsDirectory else { return [] } @@ -121,7 +128,10 @@ public struct PackageRepository { return readKeyboardPackageSource(keyboardDirectoryUrl: keyboardsUrl) } - public func readKeyboardPackageSource(keyboardDirectoryUrl: URL) -> [PackageSource] { + /** + * loop through all the sub-directories in the keyboards directory and try to read them as packages + */ + func readKeyboardPackageSource(keyboardDirectoryUrl: URL) -> [PackageSource] { var packages: [PackageSource] = [] do { @@ -148,6 +158,9 @@ public struct PackageRepository { return packages } + /** + * check the specified directory for kmp.json file and read it if it exists + */ func readKeyboardPackageFromDirectory(keyboardDirectoryUrl: URL) -> PackageSource? { var packageSource: PackageSource? let lastPathComponent = keyboardDirectoryUrl.lastPathComponent @@ -167,7 +180,10 @@ public struct PackageRepository { return packageSource } - public func readKeyboardPackage(_ kmpFileUrl: URL) -> PackageSource? { + /** + * read the kmp.json file at the specified URL + */ + func readKeyboardPackage(_ kmpFileUrl: URL) -> PackageSource? { var packageSource: PackageSource? do { let jsonData = try Data(contentsOf: kmpFileUrl, options: .mappedIfSafe) diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift b/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift index 0b5a026d1a..258f07cdda 100644 --- a/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift +++ b/mac/KeymanSettings/Tests/KeymanSettingsTests/KeymanSettingsTests.swift @@ -2,6 +2,63 @@ import Testing import Foundation @testable import KeymanSettings + +@Suite("Settings Container") struct SettingsContainersTests { + + fileprivate init() async throws { + print("init") + } + + @Test("Check settings creation ") func testSettingsCreation() async throws { + let defaultsRepo = DefaultsRepoStub(enabledKeyboards: Set([moabiteKeyboardKey, hittiteKeyboardKey])) + let packageRepo = PackageRepoStub() + let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo) + + #expect(settingsContainer.installedPackages.isEmpty) + + settingsContainer.loadPackages() + + let package = try #require(settingsContainer.findPackage(packageId: packageRepo.testPackageId)) + + #expect(defaultsRepo.readEnabledKeyboards().contains(moabiteKeyboardKey)) + } + + /** + * include uninstalled keyboard in the list of enabled keyboards and verify that it is removed after `validateSettings()` is called + */ + @Test("Check settings validation ") func testSettingsValidation() async throws { + let defaultsRepo = DefaultsRepoStub(enabledKeyboards: Set([moabiteKeyboardKey, uninstalledKeyboardKey])) + let packageRepo = PackageRepoStub() + let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo) + + settingsContainer.loadPackages() + settingsContainer.validateSettings() + let enabledKeyboards = defaultsRepo.readEnabledKeyboards() + + #expect(!enabledKeyboards.contains(uninstalledKeyboardKey)) + #expect(enabledKeyboards.contains(moabiteKeyboardKey)) + } +} + +/** + * see that keyboard state changes from disabled to enabled when settings are applied + */ +@Test("Check settings applied ") func testSettingsApplied() async throws { + let defaultsRepo = DefaultsRepoStub(enabledKeyboards: Set([moabiteKeyboardKey, hittiteKeyboardKey])) + let packageRepo = PackageRepoStub() + let settingsContainer = SettingsContainer(defaultsRepo: defaultsRepo, packageRepo: packageRepo) + + settingsContainer.loadPackages() + let package = try #require(settingsContainer.findPackage(packageId: packageRepo.testPackageId)) + + #expect(!package.isKeyboardEnabled(keyboardKey: hittiteKeyboardKey)) + + settingsContainer.applySettingsToInstalledPackages() + + #expect(package.isKeyboardEnabled(keyboardKey: hittiteKeyboardKey)) +} + + @Suite("Check Keyman paths") struct KeymanPathsTests { fileprivate init() async throws { @@ -53,12 +110,12 @@ import Foundation // defaults read test.suite.name @Suite("Settings check") struct KeymanSettingsRepositoryTests { - var settingsRepo: SettingsRepository + var settingsRepo: DefaultsRepository fileprivate init() async throws { print("init Settings") do { - try self.settingsRepo = SettingsRepository(suiteName: "test.suite.name") + try self.settingsRepo = DefaultsRepository(suiteName: "test.suite.name") print("Found group container") } catch UserDefaultsError.unknownSuite { fatalError("Group container not found.") @@ -91,49 +148,49 @@ import Foundation } @Test("Read package") func loadPackageSource() async throws { - let dataRepo = PackageRepository() + let packageRepo = PackageRepository() let kmpUrl = try #require(self.getAmharicKmpUrl()) - let _ = try #require(dataRepo.readKeyboardPackage(kmpUrl)) + let _ = try #require(packageRepo.readKeyboardPackage(kmpUrl)) } @Test("Read package name") func readPackageName() async throws { - let dataRepo = PackageRepository() + let packageRepo = PackageRepository() let kmpUrl = try #require(self.getAmharicKmpUrl()) - let source = try #require(dataRepo.readKeyboardPackage(kmpUrl)) + let source = try #require(packageRepo.readKeyboardPackage(kmpUrl)) #expect(source.packageName == "GFF Amharic Keyboard") } @Test("Read package version") func readPackageVersion() async throws { - let dataRepo = PackageRepository() + let packageRepo = PackageRepository() let kmpUrl = try #require(self.getAmharicKmpUrl()) - let source = try #require(dataRepo.readKeyboardPackage(kmpUrl)) + let source = try #require(packageRepo.readKeyboardPackage(kmpUrl)) #expect(source.packageVersion == "3.1.2") } @Test("Read copyright") func readCopyright() async throws { - let dataRepo = PackageRepository() + let packageRepo = PackageRepository() let kmpUrl = try #require(self.getAmharicKmpUrl()) - let source = try #require(dataRepo.readKeyboardPackage(kmpUrl)) + let source = try #require(packageRepo.readKeyboardPackage(kmpUrl)) #expect(source.copyright == "© Geʾez Frontier Foundation, SIL International") } @Test("Read keyboard count") func readKeyboardCount() async throws { - let dataRepo = PackageRepository() + let packageRepo = PackageRepository() let kmpUrl = try #require(self.getAmharicKmpUrl()) - let source = try #require(dataRepo.readKeyboardPackage(kmpUrl)) + let source = try #require(packageRepo.readKeyboardPackage(kmpUrl)) #expect(source.keyboards?.count == 1) } @Test("Read keyboard name") func readKeyboardName() async throws { - let dataRepo = PackageRepository() + let packageRepo = PackageRepository() let kmpUrl = try #require(self.getAmharicKmpUrl()) - let source = try #require(dataRepo.readKeyboardPackage(kmpUrl)) + let source = try #require(packageRepo.readKeyboardPackage(kmpUrl)) let keyboard = try #require(source.keyboards?.first) #expect(keyboard.name == "አማርኛ (Amharic)") } diff --git a/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift b/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift new file mode 100644 index 0000000000..92182f5394 --- /dev/null +++ b/mac/KeymanSettings/Tests/KeymanSettingsTests/RepoStubs.swift @@ -0,0 +1,96 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by Shawn Schantz on 2026-04-29 + * + * Contains stubs for the repository classes to support + * unit tests that do not access the file system or UserDefaults database. * + */ + +import Foundation +import KeymanSettings + +/** + * conforms to DefaultsRepo but takes a set of enabled keyboard keys in the initializer and + * keeps everything in memory rather than accessing UserDefaults. + */ + +public let moabiteKeyboardKey = "/extinctpackage/moabite_basic.kmx" +public let hittiteKeyboardKey = "/extinctpackage/hittite_basic.kmx" +public let uninstalledKeyboardKey = "/extinctpackage/girgashite_basic.kmx" + +class DefaultsRepoStub: DefaultsRepo { + private var selectedKeyboard: String + private var enabledKeyboards: Set + + init(enabledKeyboards: Set) { + self.enabledKeyboards = enabledKeyboards + + // selectedKeyboard not relevant yet in config app + // but may need validation later + selectedKeyboard = moabiteKeyboardKey + } + + func readEnabledKeyboards() -> Set { + return enabledKeyboards + } + + func writeEnabledKeyboards(enabledKeyboardsArray: [String]) { + self.enabledKeyboards = Set(enabledKeyboardsArray) + } + + func readSelectedKeyboard() -> String { + self.selectedKeyboard = hittiteKeyboardKey + return self.selectedKeyboard + } + + func logDefaults() { + print("UserDefaults:") + print("\("KMSelectedKeyboardsKey"): \(self.readSelectedKeyboard())") + print("\("KMEnabledKeyboardsKey"): \(self.readEnabledKeyboards())") + } + + func clearDefaults() { + selectedKeyboard = "" + enabledKeyboards = [] + } +} + +/** + * conforms to PackageRepo but creates a package with two keyboards and + * stores them in memory with no access to disk + */ +class PackageRepoStub: PackageRepo { + public var testPackageId = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + + func keyman19SharedDataDirectoryExists() -> Bool { + true + } + + func createKeyman19SharedDataDirectories() { + // do nothing + } + + /** + * creates a package containing two keyboards, one enabled and one disabled + */ + func loadPackages() -> [KeymanPackage] { + let packagesDirectoryUrl = URL(filePath: "/Users/shawn-sil/Library/Group%20Containers/group.com.keyman/Library/Application%20Support/Keyman-Packages/")! + let testPackageDirectoryName = "extinctpackage" + let testPackageUrl = packagesDirectoryUrl.appendingPathComponent(testPackageDirectoryName) + let moabiteKeyboardId = "moabite_basic" + let moabiteKeyboard = Keyboard(name: "moabite basic", keyboardId: moabiteKeyboardId, keyboardDirectoryUrl: testPackageUrl, enabled: true) + let hittiteKeyboardId = "hittite_basic" + let hittiteKeyboard = Keyboard(name: "hittite basic", keyboardId: hittiteKeyboardId, keyboardDirectoryUrl: testPackageUrl, enabled: false) + + let testPackage = KeymanPackage(sourceDirectoryUrl: testPackageUrl, + keyboards: [moabiteKeyboard, hittiteKeyboard], packageName: "Extinct Languages", packageVersion: "1.1.03", jsonFileUrl: testPackageUrl.appendingPathComponent("kmp.json")) + testPackageId = testPackage.id + + return [testPackage] + } + + func deletePackage(package: KeymanPackage) { + + } +}