feat(mac): add protocols, stubs and unit tests
Some checks are pending
Keyman Build Summary / Summarize build status checks (push) Waiting to run

added suite for Settings Container and stubs to
test it without accessing disk or user defaults
This commit is contained in:
Shawn Schantz 2026-05-04 11:38:56 -04:00
parent af09f89bc9
commit a7bcceff47
10 changed files with 353 additions and 104 deletions

View file

@ -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)

View file

@ -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<String>
func writeEnabledKeyboards(enabledKeyboardsArray: [String])
func readSelectedKeyboard() -> String
func logDefaults()
func clearDefaults()
}

View file

@ -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)
}

View file

@ -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)")
}
}
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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)")
}

View file

@ -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<String>
init(enabledKeyboards: Set<String>) {
self.enabledKeyboards = enabledKeyboards
// selectedKeyboard not relevant yet in config app
// but may need validation later
selectedKeyboard = moabiteKeyboardKey
}
func readEnabledKeyboards() -> Set<String> {
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) {
}
}