321 lines
14 KiB
Swift
321 lines
14 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import Testing
|
|
@testable import Conjure
|
|
|
|
@MainActor
|
|
private final class TranslationRequests {
|
|
var inputs: [(text: String, source: String, target: 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, source: String, target: String, key: String? = nil) async throws -> String {
|
|
inputs.append((text, source, target, 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) }
|
|
|
|
@Suite(.serialized)
|
|
struct TranslationExecutionTests {
|
|
@Test @MainActor
|
|
func emptyPrimaryActionSwapsLanguagesWithoutSubmitting() {
|
|
_ = NSApplication.shared
|
|
let suite = "TranslateEmpty.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let tool = TranslationController(defaults: defaults,
|
|
myMemory: { _, _, _ in Issue.record("Empty input must not submit"); return "unexpected" })
|
|
tool.input = { " \n\t" }
|
|
_ = tool.view
|
|
let button = descendants(tool.view).compactMap { $0 as? NSButton }
|
|
.first { $0.action == NSSelectorFromString("performPrimaryAction") }!
|
|
button.performClick(nil)
|
|
#expect(tool.sourceLanguage == "ja")
|
|
#expect(tool.targetLanguage == "en")
|
|
tool.performPrimaryAction()
|
|
#expect(tool.sourceLanguage == "en")
|
|
#expect(tool.targetLanguage == "ja")
|
|
}
|
|
|
|
@Test @MainActor
|
|
func languageActionsInvalidateOldResultsAndApplyTheSelectedPair() async throws {
|
|
_ = NSApplication.shared
|
|
let suite = "TranslateLanguages.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let requests = TranslationRequests()
|
|
let tool = TranslationController(defaults: defaults,
|
|
myMemory: { try await requests.translate($0, source: $1, target: $2) })
|
|
tool.input = { "こんにちは" }
|
|
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
|
|
styleMask: .borderless, backing: .buffered, defer: false)
|
|
window.isReleasedWhenClosed = false
|
|
window.contentView = tool.view
|
|
defer { tool.close(); window.orderOut(nil) }
|
|
let output = try #require(descendants(tool.view).compactMap { $0 as? NSTextView }.first)
|
|
tool.performPrimaryAction()
|
|
try await waitForTranslation { requests.pending.count == 1 }
|
|
let languages = try #require(descendants(tool.view).compactMap { $0 as? NSButton }
|
|
.first { $0.accessibilityLabel() == "Translation languages" })
|
|
languages.performClick(nil)
|
|
let host = try #require(window.contentView)
|
|
let search = try #require(descendants(host).compactMap { $0 as? NSSearchField }.first)
|
|
search.stringValue = "swap"
|
|
search.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: search))
|
|
#expect(tool.handle(#selector(NSResponder.insertNewline(_:))))
|
|
#expect(tool.sourceLanguage == "ja")
|
|
#expect(tool.targetLanguage == "en")
|
|
requests.pending[0].resume(returning: "obsolete")
|
|
try await waitForTranslation { requests.returned == 1 }
|
|
#expect(output.string.isEmpty)
|
|
tool.performPrimaryAction()
|
|
try await waitForTranslation { requests.pending.count == 2 }
|
|
#expect(requests.inputs[1].source == "ja")
|
|
#expect(requests.inputs[1].target == "en")
|
|
requests.pending[1].resume(returning: "Hello")
|
|
try await waitForTranslation { output.string == "Hello" }
|
|
tool.setLanguages(source: "de", target: "fr")
|
|
#expect(output.string.isEmpty)
|
|
let reopened = TranslationController(defaults: defaults)
|
|
#expect(reopened.sourceLanguage == "de")
|
|
#expect(reopened.targetLanguage == "fr")
|
|
}
|
|
|
|
@Test @MainActor
|
|
func primaryActionCopiesCompletedTranslationAndResetsAfterEditing() async throws {
|
|
_ = NSApplication.shared
|
|
let suite = "TranslateCopy.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
let pasteboard = NSPasteboard.withUniqueName()
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suite)
|
|
pasteboard.releaseGlobally()
|
|
}
|
|
let requests = TranslationRequests()
|
|
let tool = TranslationController(defaults: defaults,
|
|
myMemory: { try await requests.translate($0, source: $1, target: $2) }, pasteboard: pasteboard)
|
|
tool.input = { "Hello" }
|
|
var dismissed = false
|
|
tool.dismiss = { dismissed = true }
|
|
let button = try #require(descendants(tool.view).compactMap { $0 as? NSButton }
|
|
.first { $0.action == NSSelectorFromString("performPrimaryAction") })
|
|
button.performClick(nil)
|
|
try await waitForTranslation { requests.pending.count == 1 }
|
|
tool.performPrimaryAction()
|
|
#expect(!dismissed)
|
|
requests.pending[0].resume(returning: "こんにちは")
|
|
try await waitForTranslation { button.isEnabled }
|
|
tool.performPrimaryAction()
|
|
#expect(pasteboard.string(forType: .string) == "こんにちは")
|
|
#expect(dismissed)
|
|
dismissed = false
|
|
tool.inputChanged()
|
|
button.performClick(nil)
|
|
try await waitForTranslation { requests.pending.count == 2 }
|
|
#expect(!dismissed)
|
|
requests.pending[1].resume(returning: "再翻訳")
|
|
try await waitForTranslation { button.isEnabled }
|
|
button.performClick(nil)
|
|
#expect(pasteboard.string(forType: .string) == "再翻訳")
|
|
#expect(dismissed)
|
|
}
|
|
|
|
@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, source: $1, target: $2) },
|
|
openAI: { try await requests.translate($0, source: $1, target: $2, key: $3) })
|
|
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, source: $1, target: $2) },
|
|
openAI: { try await requests.translate($0, source: $1, target: $2, key: $3) })
|
|
var input = "first"
|
|
tool.input = { input }
|
|
let views = descendants(tool.view)
|
|
let output = try #require(views.compactMap { $0 as? NSTextView }.first)
|
|
let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("performPrimaryAction") })
|
|
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)
|
|
|
|
tool.selectProvider(openAI: true)
|
|
#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, source: $1, target: $2) },
|
|
openAI: { try await requests.translate($0, source: $1, target: $2, key: $3) })
|
|
tool.input = { "Hello" }
|
|
let views = descendants(tool.view)
|
|
let settings = TranslationSettingsController(credentials: keys.store)
|
|
let settingsViews = descendants(settings.view)
|
|
let field = try #require(settingsViews.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("performPrimaryAction") })
|
|
let save = try #require(settingsViews.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("saveKey") })
|
|
let forget = try #require(settingsViews.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("forgetKey") })
|
|
let openSettings = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("openSettings") })
|
|
|
|
tool.translate() // No stored key.
|
|
#expect(requests.inputs.isEmpty)
|
|
#expect(button.isEnabled)
|
|
#expect(status.textColor == .systemRed)
|
|
#expect(!openSettings.isHidden)
|
|
field.stringValue = "typed-key"
|
|
tool.translate() // Typing in Settings must not implicitly save on Translate.
|
|
#expect(requests.inputs.isEmpty)
|
|
#expect(keys.value == nil)
|
|
#expect(field.stringValue == "typed-key")
|
|
keys.denied = true
|
|
save.performClick(nil) // A failed save must retain the typed key for retry.
|
|
#expect(requests.inputs.isEmpty)
|
|
#expect(field.stringValue == "typed-key")
|
|
#expect(keys.value == nil)
|
|
#expect(settingsViews.compactMap { $0 as? NSTextField }.contains { $0.stringValue.contains("Credential access denied") })
|
|
tool.translate() // Failed reads are also recoverable without a request.
|
|
#expect(requests.inputs.isEmpty)
|
|
#expect(button.isEnabled)
|
|
keys.denied = false
|
|
save.performClick(nil)
|
|
#expect(keys.value == "typed-key")
|
|
#expect(field.stringValue.isEmpty)
|
|
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)
|
|
keys.denied = true
|
|
forget.performClick(nil)
|
|
#expect(keys.value == "typed-key")
|
|
#expect(settingsViews.compactMap { $0 as? NSTextField }.contains { $0.stringValue.contains("Credential access denied") })
|
|
keys.denied = false
|
|
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)
|
|
}
|
|
}
|