Files
conjure/Tests/ConjureKitTests/TranslationExecutionTests.swift
T

259 lines
11 KiB
Swift

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) }
@Suite(.serialized)
struct TranslationExecutionTests {
@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) }, 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) },
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 = tool.providerPicker
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)
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 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)
}
}