refactor: deepen Translate execution testing
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user