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] = [] 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 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) } }