Files
conjure/Tests/ConjureKitTests/TranslationExecutionTests.swift
T
2026-10-16 11:29:18 +02:00

202 lines
8.2 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) }
@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)
}