refactor: centralize shortcut recording lifetime
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ConjureKit
|
||||
@testable import Conjure
|
||||
|
||||
@MainActor
|
||||
private final class RecordingRegistration {
|
||||
var active: KeyboardShortcut?
|
||||
var rejected: KeyboardShortcut?
|
||||
var unavailable = false
|
||||
|
||||
var adapter: GlobalShortcut.Registration {
|
||||
.init(activate: { [self] candidate in
|
||||
if unavailable || candidate == rejected { throw RecordingFailure() }
|
||||
active = candidate
|
||||
}, unregister: { [self] in active = nil }, stop: { [self] in active = nil })
|
||||
}
|
||||
}
|
||||
|
||||
private struct RecordingFailure: LocalizedError {
|
||||
var errorDescription: String? { "Registration denied" }
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func recordingOwnsCompletionCancellationAndFailureRestoration() throws {
|
||||
let suite = "ShortcutRecording.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let old = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.control, .option]))
|
||||
let candidate = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.command, .shift]))
|
||||
let other = try #require(KeyboardShortcut(keyCode: 0, modifiers: [.control, .option]))
|
||||
let states = GlobalShortcuts.destinations.map { _ in RecordingRegistration() }
|
||||
defaults.set(old.storedValue, forKey: GlobalShortcuts.destinations[0].storageKey)
|
||||
defaults.set(other.storedValue, forKey: GlobalShortcuts.destinations[1].storageKey)
|
||||
let bindings = GlobalShortcuts.destinations.enumerated().map { index, destination in
|
||||
GlobalShortcut(defaults: defaults, storageKey: destination.storageKey, registration: states[index].adapter)
|
||||
}
|
||||
let shortcuts = GlobalShortcuts(defaults: defaults, bindings: bindings)
|
||||
defer { shortcuts.stop() }
|
||||
#expect(shortcuts.start().isEmpty)
|
||||
#expect(states[0].active == old)
|
||||
#expect(states[1].active == other)
|
||||
|
||||
shortcuts.beginRecording()
|
||||
shortcuts.beginRecording() // Beginning twice must not change the saved bindings.
|
||||
#expect(shortcuts.isRecording)
|
||||
#expect(states.allSatisfy { $0.active == nil })
|
||||
#expect(shortcuts.cancelRecording().isEmpty)
|
||||
#expect(!shortcuts.isRecording)
|
||||
#expect(states[0].active == old)
|
||||
#expect(states[1].active == other)
|
||||
#expect(shortcuts.cancelRecording().isEmpty)
|
||||
|
||||
shortcuts.beginRecording()
|
||||
states[0].rejected = candidate
|
||||
let failure = shortcuts.finishRecording(candidate, at: 0)
|
||||
#expect(failure.contains { $0.contains("Registration denied") })
|
||||
#expect(!shortcuts.isRecording)
|
||||
#expect(bindings[0].shortcut == old)
|
||||
#expect(KeyboardShortcut(storedValue: defaults.object(forKey: GlobalShortcuts.destinations[0].storageKey)) == old)
|
||||
#expect(states[0].active == old)
|
||||
#expect(states[1].active == other)
|
||||
|
||||
shortcuts.beginRecording()
|
||||
states[0].rejected = nil
|
||||
#expect(shortcuts.finishRecording(candidate, at: 0).isEmpty)
|
||||
#expect(!shortcuts.isRecording)
|
||||
#expect(states[0].active == candidate)
|
||||
#expect(states[1].active == other)
|
||||
#expect(KeyboardShortcut(storedValue: defaults.object(forKey: GlobalShortcuts.destinations[0].storageKey)) == candidate)
|
||||
|
||||
shortcuts.beginRecording()
|
||||
#expect(!shortcuts.finishRecording(other, at: 0).isEmpty) // Duplicate: still restore everything.
|
||||
#expect(states[0].active == candidate)
|
||||
#expect(states[1].active == other)
|
||||
#expect(!shortcuts.isRecording)
|
||||
|
||||
shortcuts.beginRecording()
|
||||
states[1].unavailable = true
|
||||
let restorationFailure = shortcuts.cancelRecording()
|
||||
#expect(restorationFailure.contains { $0.hasPrefix(GlobalShortcuts.destinations[1].title) })
|
||||
#expect(!shortcuts.isRecording)
|
||||
#expect(states[0].active == candidate)
|
||||
#expect(states[1].active == nil)
|
||||
#expect(bindings[1].shortcut == other)
|
||||
|
||||
#expect(shortcuts.finishRecording(nil, at: 0).isEmpty) // Clear outside recording must not resume other bindings.
|
||||
#expect(states[0].active == nil)
|
||||
#expect(states[1].active == nil)
|
||||
#expect(defaults.object(forKey: GlobalShortcuts.destinations[0].storageKey) == nil)
|
||||
}
|
||||
|
||||
@Test(arguments: ["close", "focus", "destination", "escape", "invalid", "responder"])
|
||||
@MainActor
|
||||
func recordingSettingsExitRestoresSavedShortcuts(_ exit: String) throws {
|
||||
_ = NSApplication.shared
|
||||
let suite = "RecordingExit.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let shortcut = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.control, .option]))
|
||||
defaults.set(shortcut.storedValue, forKey: GlobalShortcuts.destinations[0].storageKey)
|
||||
let states = GlobalShortcuts.destinations.map { _ in RecordingRegistration() }
|
||||
let bindings = GlobalShortcuts.destinations.enumerated().map { index, destination in
|
||||
GlobalShortcut(defaults: defaults, storageKey: destination.storageKey, registration: states[index].adapter)
|
||||
}
|
||||
let shortcuts = GlobalShortcuts(defaults: defaults, bindings: bindings)
|
||||
defer { shortcuts.stop() }
|
||||
#expect(shortcuts.start().isEmpty)
|
||||
let settings = SettingsWindowController(shortcuts: shortcuts)
|
||||
let window = try #require(settings.window)
|
||||
defer { window.close() }
|
||||
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||
let views = descendants(try #require(window.contentView))
|
||||
let recorder = try #require(views.compactMap { $0 as? NSButton }.first {
|
||||
$0.action == NSSelectorFromString("beginRecording")
|
||||
})
|
||||
recorder.performClick(nil)
|
||||
#expect(shortcuts.isRecording)
|
||||
#expect(states[0].active == nil)
|
||||
switch exit {
|
||||
case "close":
|
||||
settings.windowWillClose(Notification(name: NSWindow.willCloseNotification, object: window))
|
||||
case "focus":
|
||||
settings.windowDidResignKey(Notification(name: NSWindow.didResignKeyNotification, object: window))
|
||||
case "destination":
|
||||
let destination = try #require(views.compactMap { $0 as? NSPopUpButton }.first)
|
||||
destination.selectItem(at: 1)
|
||||
_ = settings.perform(destination.action, with: destination)
|
||||
case "responder":
|
||||
_ = window.makeFirstResponder(nil)
|
||||
default:
|
||||
let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
|
||||
timestamp: 1, windowNumber: window.windowNumber, context: nil,
|
||||
characters: exit == "escape" ? "\u{1b}" : "a",
|
||||
charactersIgnoringModifiers: exit == "escape" ? "\u{1b}" : "a",
|
||||
isARepeat: false, keyCode: exit == "escape" ? 53 : 0))
|
||||
recorder.keyDown(with: event)
|
||||
}
|
||||
#expect(!shortcuts.isRecording)
|
||||
#expect(states[0].active == shortcut)
|
||||
#expect(bindings[0].isRegistered)
|
||||
}
|
||||
Reference in New Issue
Block a user