refactor: deepen Translate execution testing

This commit is contained in:
Valdemar Lorenzen
2026-10-16 10:16:57 +02:00
parent 27c203b336
commit 04e505b846
3 changed files with 298 additions and 41 deletions
+2
View File
@@ -97,6 +97,8 @@ The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.co
Search for **Conjure Translate** and open it. Enter English in the search bar, choose a provider, then press Return or click **Translate**. Typing alone never sends text. Copy selected Japanese output with Command-C; the copy hint appears beside the Translate action at the bottom. The Back chevron beside the input or Escape returns to the launcher and restores your search. Search for **Conjure Translate** and open it. Enter English in the search bar, choose a provider, then press Return or click **Translate**. Typing alone never sends text. Copy selected Japanese output with Command-C; the copy hint appears beside the Translate action at the bottom. The Back chevron beside the input or Escape returns to the launcher and restores your search.
Editing the input, switching providers, forgetting the key, or leaving Translate cancels the active request; cancelled results are not shown. After a request fails, submit again to retry.
- **MyMemory:** No account or key required. Limits are 500 UTF-8 bytes per request and [5,000 characters per day](https://mymemory.translated.net/doc/usagelimits.php) for anonymous use. - **MyMemory:** No account or key required. Limits are 500 UTF-8 bytes per request and [5,000 characters per day](https://mymemory.translated.net/doc/usagelimits.php) for anonymous use.
- **OpenAI:** Requires an API key, access to `gpt-6-luna`, and sufficient API billing. Choose **Save Key** to store it in macOS Keychain; **Forget Key** removes it. Requests disable API response storage, but OpenAI's retention policies still apply. - **OpenAI:** Requires an API key, access to `gpt-6-luna`, and sufficient API billing. Choose **Save Key** to store it in macOS Keychain; **Forget Key** removes it. Requests disable API response storage, but OpenAI's retention policies still apply.
+94 -40
View File
@@ -15,11 +15,25 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
private var task: Task<Void, Never>? private var task: Task<Void, Never>?
var input: (() -> String)? var input: (() -> String)?
var closed: (() -> Void)? var closed: (() -> Void)?
private static let credential: [String: Any] = [ private let defaults: UserDefaults
kSecClass as String: kSecClassGenericPassword, private let credentialsStore: Credentials
kSecAttrService as String: "Conjure.OpenAI", private let myMemory: @MainActor (String) async throws -> String
kSecAttrAccount as String: "translation", private let openAI: @MainActor (String, String) async throws -> String
]
init(
defaults: UserDefaults = .standard,
credentials: Credentials = .keychain,
myMemory: @escaping @MainActor (String) async throws -> String = { try await MyMemoryAPI.translate($0) },
openAI: @escaping @MainActor (String, String) async throws -> String = { try await TranslationAPI.translate($0, apiKey: $1) }
) {
self.defaults = defaults
self.credentialsStore = credentials
self.myMemory = myMemory
self.openAI = openAI
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("Conjure creates Translate programmatically") }
override func loadView() { override func loadView() {
view = NSView() view = NSView()
@@ -27,7 +41,7 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
title.font = .systemFont(ofSize: 13, weight: .semibold) title.font = .systemFont(ofSize: 13, weight: .semibold)
title.textColor = SunsetPalette.text title.textColor = SunsetPalette.text
provider.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"]) provider.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"])
provider.selectItem(at: UserDefaults.standard.string(forKey: "translationProvider") == "openai" ? 1 : 0) provider.selectItem(at: defaults.string(forKey: "translationProvider") == "openai" ? 1 : 0)
provider.setAccessibilityLabel("Translation provider") provider.setAccessibilityLabel("Translation provider")
provider.target = self provider.target = self
provider.action = #selector(providerChanged) provider.action = #selector(providerChanged)
@@ -132,21 +146,18 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
func controlTextDidChange(_ notification: Notification) { inputChanged() } func controlTextDidChange(_ notification: Notification) { inputChanged() }
@objc func goBack() { @objc func goBack() {
task?.cancel() cancelRequest()
task = nil
closed?() closed?()
} }
func inputChanged() { func inputChanged() {
task?.cancel() cancelRequest()
task = nil
translateButton.isEnabled = true
output.string = "" output.string = ""
setStatus("Press Return to translate.") setStatus("Press Return to translate.")
} }
@objc private func providerChanged() { @objc private func providerChanged() {
UserDefaults.standard.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider") defaults.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider")
keyField.stringValue = "" keyField.stringValue = ""
updateProvider() updateProvider()
} }
@@ -165,33 +176,26 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
setStatus("Enter a valid API key before saving.", isError: true) setStatus("Enter a valid API key before saving.", isError: true)
return return
} }
let data = Data(key.utf8) do {
var result = SecItemUpdate(Self.credential as CFDictionary, [kSecValueData as String: data] as CFDictionary) try credentialsStore.save(key)
if result == errSecItemNotFound {
var item = Self.credential
item[kSecValueData as String] = data
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
result = SecItemAdd(item as CFDictionary, nil)
}
if result == errSecSuccess {
keyField.stringValue = "" keyField.stringValue = ""
setStatus("API key saved in Keychain. Press Return to translate.") setStatus("API key saved in Keychain. Press Return to translate.")
} else { showKeychainError(result) } } catch { setStatus(error.localizedDescription, isError: true) }
} }
@objc private func forgetKey() { @objc private func forgetKey() {
cancelRequest()
do {
try credentialsStore.forget()
keyField.stringValue = ""
setStatus("API key removed.")
} catch { setStatus(error.localizedDescription, isError: true) }
}
private func cancelRequest() {
task?.cancel() task?.cancel()
task = nil task = nil
translateButton.isEnabled = true translateButton.isEnabled = true
let result = SecItemDelete(Self.credential as CFDictionary)
if result == errSecSuccess || result == errSecItemNotFound {
keyField.stringValue = ""
setStatus("API key removed.")
} else { showKeychainError(result) }
}
private func showKeychainError(_ code: OSStatus) {
setStatus("Keychain error: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))", isError: true)
} }
private func setStatus(_ message: String, isError: Bool = false) { private func setStatus(_ message: String, isError: Bool = false) {
@@ -210,25 +214,27 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
var key = "" var key = ""
if !myMemory { if !myMemory {
if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } } if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } }
var query = Self.credential do {
query[kSecReturnData as String] = true guard let savedKey = try credentialsStore.load() else {
var item: CFTypeRef? setStatus("Enter and save your OpenAI API key first.", isError: true)
let result = SecItemCopyMatching(query as CFDictionary, &item)
guard result == errSecSuccess, let data = item as? Data, let savedKey = String(data: data, encoding: .utf8) else {
if result == errSecItemNotFound { setStatus("Enter and save your OpenAI API key first.", isError: true) }
else { showKeychainError(result) }
return return
} }
key = savedKey key = savedKey
} catch {
setStatus(error.localizedDescription, isError: true)
return
}
} }
output.string = "" output.string = ""
setStatus("Translating…") setStatus("Translating…")
translateButton.isEnabled = false translateButton.isEnabled = false
let myMemoryTranslate = self.myMemory
let openAITranslate = self.openAI
task = Task { [weak self] in task = Task { [weak self] in
do { do {
let translation = try await myMemory let translation = try await myMemory
? MyMemoryAPI.translate(text) ? myMemoryTranslate(text)
: TranslationAPI.translate(text, apiKey: key) : openAITranslate(text, key)
guard !Task.isCancelled, let self else { return } guard !Task.isCancelled, let self else { return }
self.output.string = translation self.output.string = translation
self.setStatus("Japanese · Select text to copy (⌘C).") self.setStatus("Japanese · Select text to copy (⌘C).")
@@ -241,4 +247,52 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
self.translateButton.isEnabled = true self.translateButton.isEnabled = true
} }
} }
@MainActor
struct Credentials {
var save: (String) throws -> Void
var load: () throws -> String?
var forget: () throws -> Void
private static var query: [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "Conjure.OpenAI",
kSecAttrAccount as String: "translation"]
}
static let keychain = Credentials(
save: { key in
let data = Data(key.utf8)
var result = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if result == errSecItemNotFound {
var item = query
item[kSecValueData as String] = data
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
result = SecItemAdd(item as CFDictionary, nil)
}
guard result == errSecSuccess else { throw KeychainError(code: result) }
},
load: {
var query = Self.query
query[kSecReturnData as String] = true
var item: CFTypeRef?
let result = SecItemCopyMatching(query as CFDictionary, &item)
if result == errSecItemNotFound { return nil }
guard result == errSecSuccess, let data = item as? Data,
let key = String(data: data, encoding: .utf8) else { throw KeychainError(code: result) }
return key
},
forget: {
let result = SecItemDelete(query as CFDictionary)
guard result == errSecSuccess || result == errSecItemNotFound else { throw KeychainError(code: result) }
}
)
}
private struct KeychainError: LocalizedError {
let code: OSStatus
var errorDescription: String? {
"Keychain error: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))"
}
}
} }
@@ -0,0 +1,201 @@
import AppKit
import Foundation
import Testing
@testable import Conjure
@MainActor
private final class TranslationRequests {
var inputs: [(text: String, key: String?)] = []
var pending: [CheckedContinuation<String, any Error>] = []
var returned = 0
// Deliberately ignore cancellation: the controller must reject late completions itself.
func translate(_ text: String, key: String? = nil) async throws -> String {
inputs.append((text, key))
defer { returned += 1 }
return try await withCheckedThrowingContinuation { pending.append($0) }
}
}
private struct CredentialFailure: LocalizedError {
var errorDescription: String? { "Credential access denied" }
}
@MainActor
private final class TranslationKeys {
var value: String?
var accesses = 0
var denied = false
var store: TranslationController.Credentials {
.init(save: { [self] key in
accesses += 1
if denied { throw CredentialFailure() }
value = key
}, load: { [self] in
accesses += 1
if denied { throw CredentialFailure() }
return value
}, forget: { [self] in
accesses += 1
if denied { throw CredentialFailure() }
value = nil
})
}
}
@MainActor
private func waitForTranslation(_ condition: () -> Bool) async throws {
// This is a scheduling deadline, not a clock used by the behavior under test.
for _ in 0..<1_000 {
if condition() { return }
try await Task.sleep(for: .milliseconds(1))
}
try #require(condition(), "Translate did not reach the expected observable state")
}
@MainActor
private func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
@Test @MainActor
func translationExecutionIsExplicitAndMyMemoryNeverTouchesCredentials() async throws {
_ = NSApplication.shared
let suite = "TranslateExecution.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let keys = TranslationKeys()
keys.value = "private-key"
let requests = TranslationRequests()
let tool = TranslationController(defaults: defaults, credentials: keys.store,
myMemory: { try await requests.translate($0) },
openAI: { try await requests.translate($0, key: $1) })
tool.input = { "Hello" }
let views = descendants(tool.view)
let output = try #require(views.compactMap { $0 as? NSTextView }.first)
tool.inputChanged()
#expect(requests.inputs.isEmpty)
tool.translate()
tool.translate() // A second submission while busy must not send another request.
try await waitForTranslation { requests.pending.count == 1 }
requests.pending[0].resume(returning: "こんにちは")
try await waitForTranslation { output.string == "こんにちは" }
#expect(requests.inputs.count == 1)
#expect(requests.inputs[0].text == "Hello")
#expect(requests.inputs[0].key == nil)
#expect(keys.accesses == 0)
}
@Test @MainActor
func translationExecutionRejectsOldCompletionsWithoutClearingNewRequests() async throws {
_ = NSApplication.shared
let suite = "TranslateLifetime.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let keys = TranslationKeys()
keys.value = "saved-key"
let requests = TranslationRequests()
let tool = TranslationController(defaults: defaults, credentials: keys.store,
myMemory: { try await requests.translate($0) },
openAI: { try await requests.translate($0, key: $1) })
var input = "first"
tool.input = { input }
let views = descendants(tool.view)
let output = try #require(views.compactMap { $0 as? NSTextView }.first)
let provider = try #require(views.compactMap { $0 as? NSPopUpButton }.first)
let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("translate") })
tool.translate()
try await waitForTranslation { requests.pending.count == 1 }
input = "second"
tool.inputChanged()
tool.translate()
try await waitForTranslation { requests.pending.count == 2 }
requests.pending[0].resume(returning: "obsolete")
try await waitForTranslation { requests.returned == 1 }
#expect(output.string.isEmpty)
#expect(!button.isEnabled)
tool.translate()
#expect(requests.inputs.count == 2)
provider.selectItem(at: 1)
_ = tool.perform(provider.action, with: provider)
#expect(defaults.string(forKey: "translationProvider") == "openai")
tool.translate()
try await waitForTranslation { requests.pending.count == 3 }
#expect(requests.inputs[2].key == "saved-key")
requests.pending[1].resume(throwing: CredentialFailure())
try await waitForTranslation { requests.returned == 2 }
#expect(!button.isEnabled)
requests.pending[2].resume(returning: "最新")
try await waitForTranslation { output.string == "最新" }
#expect(button.isEnabled)
tool.translate()
try await waitForTranslation { requests.pending.count == 4 }
var closed = false
tool.closed = { closed = true }
tool.goBack()
requests.pending[3].resume(returning: "after close")
try await waitForTranslation { requests.returned == 4 }
#expect(closed)
#expect(output.string.isEmpty)
}
@Test @MainActor
func translationExecutionRecoversFromCredentialAndRequestFailures() async throws {
_ = NSApplication.shared
let suite = "TranslateCredentials.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.set("openai", forKey: "translationProvider")
defer { defaults.removePersistentDomain(forName: suite) }
let keys = TranslationKeys()
let requests = TranslationRequests()
let tool = TranslationController(defaults: defaults, credentials: keys.store,
myMemory: { try await requests.translate($0) },
openAI: { try await requests.translate($0, key: $1) })
tool.input = { "Hello" }
let views = descendants(tool.view)
let field = try #require(views.compactMap { $0 as? NSSecureTextField }.first)
let output = try #require(views.compactMap { $0 as? NSTextView }.first)
let status = try #require(views.compactMap { $0 as? NSTextField }.first { $0.accessibilityLabel() == "Translation status and errors" })
let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("translate") })
let forget = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("forgetKey") })
tool.translate() // No stored key.
#expect(requests.inputs.isEmpty)
#expect(button.isEnabled)
#expect(status.textColor == .systemRed)
field.stringValue = "typed-key"
keys.denied = true
tool.translate() // A failed save must not silently send the typed key.
#expect(requests.inputs.isEmpty)
#expect(field.stringValue == "typed-key")
#expect(keys.value == nil)
#expect(status.stringValue.contains("Credential access denied"))
field.stringValue = ""
tool.translate() // Failed reads are also recoverable without a request.
#expect(requests.inputs.isEmpty)
#expect(button.isEnabled)
keys.denied = false
field.stringValue = "typed-key"
tool.translate()
try await waitForTranslation { requests.pending.count == 1 }
#expect(keys.value == "typed-key")
#expect(field.stringValue.isEmpty)
#expect(requests.inputs[0].key == "typed-key")
requests.pending[0].resume(throwing: CredentialFailure())
try await waitForTranslation { button.isEnabled }
#expect(status.textColor == .systemRed)
tool.translate()
try await waitForTranslation { requests.pending.count == 2 }
forget.performClick(nil)
#expect(keys.value == nil)
#expect(button.isEnabled)
requests.pending[1].resume(returning: "forgotten request")
try await waitForTranslation { requests.returned == 2 }
#expect(output.string.isEmpty)
keys.value = "replacement"
tool.translate()
try await waitForTranslation { requests.pending.count == 3 }
requests.pending[2].resume(returning: "成功")
try await waitForTranslation { output.string == "成功" }
#expect(status.textColor != .systemRed)
}