refactor: deepen Translate execution testing
This commit is contained in:
@@ -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