281 lines
11 KiB
Swift
281 lines
11 KiB
Swift
import Testing
|
|
import Foundation
|
|
import AVFoundation
|
|
import CoreGraphics
|
|
@testable import TinySTT
|
|
|
|
struct TextTests {
|
|
@Test func punctuationAndWhitespace() {
|
|
#expect(TextPostProcessor.finalize("今天开会", addPeriod: true) == "今天开会。")
|
|
#expect(TextPostProcessor.finalize("已经结束!", addPeriod: true) == "已经结束!")
|
|
#expect(TextPostProcessor.finalize("他说“你好。”", addPeriod: true) == "他说“你好。”")
|
|
#expect(TextPostProcessor.finalize("保持 两个空格", addPeriod: false) == "保持 两个空格")
|
|
#expect(TextPostProcessor.finalize("嗯,那个,下午开会", addPeriod: false) == "嗯,那个,下午开会")
|
|
#expect(TextPostProcessor.finalize("\n \t", addPeriod: true).isEmpty)
|
|
}
|
|
|
|
@Test func shortcutRoundTrip() throws {
|
|
let spec = HotkeySpec.make(kind: .combo, keyCode: 49, modifiers: 1 << 19)
|
|
#expect(spec.label == "⌥Space")
|
|
#expect(try JSONDecoder().decode(HotkeySpec.self, from: JSONEncoder().encode(spec)) == spec)
|
|
#expect(HotkeySpec.rightOption.keyCode == 61)
|
|
#expect(HotkeySpec.modifierName(forKeyCode: 58) == "左 Option")
|
|
}
|
|
|
|
@Test func audioLevelAndConversion() throws {
|
|
let source = AVAudioFormat(standardFormatWithSampleRate: 48000, channels: 1)!
|
|
let target = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1)!
|
|
let buffer = AVAudioPCMBuffer(pcmFormat: source, frameCapacity: 1024)!
|
|
buffer.frameLength = 1024
|
|
buffer.floatChannelData![0].initialize(repeating: 0, count: 1024)
|
|
#expect(AudioMath.level(of: buffer) == 0)
|
|
buffer.floatChannelData![0].update(repeating: 0.2, count: 1024)
|
|
#expect(AudioMath.level(of: buffer) > 0.5)
|
|
let converted = try #require(AudioMath.convert(buffer, using: AVAudioConverter(from: source, to: target)!, to: target))
|
|
#expect(converted.frameLength > 0)
|
|
#expect(converted.format.sampleRate == 16000)
|
|
}
|
|
|
|
@Test func timeoutDoesNotWaitForUncooperativeTask() async {
|
|
let start = Date()
|
|
let value: Int? = await withTimeout(0.02) {
|
|
await withCheckedContinuation { continuation in
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { continuation.resume(returning: 42) }
|
|
}
|
|
}
|
|
#expect(value == nil)
|
|
#expect(Date().timeIntervalSince(start) < 0.3)
|
|
#expect(await withTimeout(1) { 7 } == 7)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class FakeSession: RecognitionSession {
|
|
var onTextChanged: ((String) -> Void)?
|
|
var onAudioLevel: ((Float) -> Void)?
|
|
var onFailure: ((String) -> Void)?
|
|
var cancelled = false
|
|
var finishDelay = false
|
|
var failStart = false
|
|
var authorizationDelay = false
|
|
var started = false
|
|
var result = "测试"
|
|
func authorize() async throws {
|
|
if authorizationDelay { try? await Task.sleep(for: .milliseconds(100)) }
|
|
}
|
|
func start() throws {
|
|
started = true
|
|
if failStart { throw NSError(domain: "test", code: 1) }
|
|
}
|
|
func finish() async -> String {
|
|
if finishDelay { try? await Task.sleep(for: .milliseconds(80)) }
|
|
return result
|
|
}
|
|
func cancel() async { cancelled = true }
|
|
}
|
|
|
|
@Suite(.serialized)
|
|
@MainActor
|
|
struct SessionTests {
|
|
private func settle() async { try? await Task.sleep(for: .milliseconds(30)) }
|
|
|
|
@Test func cancelWhileStartingThenRestart() async {
|
|
let old = FakeSession()
|
|
let fresh = FakeSession()
|
|
var calls = 0
|
|
let controller = DictationController(acquire: {
|
|
calls += 1
|
|
if calls == 1 { try? await Task.sleep(for: .milliseconds(100)); return old }
|
|
return fresh
|
|
}, microphone: { true }, secureInput: { false }, replenish: {})
|
|
controller.testMode = true
|
|
controller.start()
|
|
await settle()
|
|
#expect(controller.state == .starting)
|
|
controller.stop() // release before preparation finishes
|
|
#expect(controller.state == .idle)
|
|
controller.testMode = true
|
|
controller.start()
|
|
await settle()
|
|
#expect(controller.state == .recording)
|
|
try? await Task.sleep(for: .milliseconds(120))
|
|
#expect(old.cancelled)
|
|
#expect(controller.state == .recording)
|
|
controller.cancel()
|
|
}
|
|
|
|
@Test func cancelDuringFinalizationDiscardsResult() async {
|
|
let fake = FakeSession()
|
|
fake.finishDelay = true
|
|
let controller = DictationController(acquire: { fake }, microphone: { true }, secureInput: { false }, replenish: {})
|
|
controller.testMode = true
|
|
controller.start()
|
|
await settle()
|
|
#expect(controller.state == .recording)
|
|
controller.stop()
|
|
#expect(controller.state == .finalizing)
|
|
controller.cancel()
|
|
try? await Task.sleep(for: .milliseconds(150))
|
|
#expect(controller.state == .idle)
|
|
#expect(controller.lastResult.isEmpty)
|
|
#expect(!controller.testMode)
|
|
}
|
|
|
|
@Test func testModeAndStartFailureCleanup() async {
|
|
let fake = FakeSession()
|
|
fake.failStart = true
|
|
let controller = DictationController(acquire: { fake }, microphone: { true }, secureInput: { false }, replenish: {})
|
|
controller.testMode = true
|
|
controller.start()
|
|
await settle()
|
|
#expect(controller.state == .idle)
|
|
#expect(fake.cancelled)
|
|
#expect(!controller.testMode)
|
|
}
|
|
|
|
@Test func successfulTestKeepsTextWithoutInsertion() async {
|
|
let fake = FakeSession()
|
|
let controller = DictationController(acquire: { fake }, microphone: { true }, secureInput: { false }, replenish: {})
|
|
controller.testMode = true
|
|
controller.start()
|
|
await settle()
|
|
try? await Task.sleep(for: .milliseconds(400))
|
|
controller.stop()
|
|
await settle()
|
|
#expect(controller.state == .idle)
|
|
#expect(controller.lastResult == "测试。")
|
|
#expect(!controller.testMode)
|
|
}
|
|
|
|
@Test func emptyTestResultReportsFailure() async {
|
|
let fake = FakeSession()
|
|
fake.result = ""
|
|
let controller = DictationController(acquire: { fake }, microphone: { true }, secureInput: { false }, replenish: {})
|
|
var error: String?
|
|
controller.onError = { error = $0 }
|
|
controller.testMode = true
|
|
controller.start()
|
|
await settle()
|
|
try? await Task.sleep(for: .milliseconds(400))
|
|
controller.stop()
|
|
await settle()
|
|
#expect(controller.state == .idle)
|
|
#expect(controller.lastResult.isEmpty)
|
|
#expect(error?.contains("测试未识别到文字") == true)
|
|
}
|
|
|
|
@Test func cancellationWhileAuthorizingNeverStartsMicrophone() async {
|
|
let fake = FakeSession()
|
|
fake.authorizationDelay = true
|
|
let controller = DictationController(acquire: { fake }, microphone: { true }, secureInput: { false }, replenish: {})
|
|
controller.testMode = true
|
|
controller.start()
|
|
await settle()
|
|
#expect(controller.state == .starting)
|
|
#expect(!fake.started)
|
|
controller.cancel()
|
|
try? await Task.sleep(for: .milliseconds(130))
|
|
#expect(controller.state == .idle)
|
|
#expect(!fake.started)
|
|
#expect(fake.cancelled)
|
|
}
|
|
|
|
@Test func microphoneDenialDoesNotAcquireSession() async {
|
|
var acquired = false
|
|
let controller = DictationController(acquire: { acquired = true; return FakeSession() }, microphone: { false }, secureInput: { false }, replenish: {})
|
|
controller.testMode = true
|
|
controller.start()
|
|
await settle()
|
|
#expect(!acquired)
|
|
#expect(controller.state == .idle)
|
|
#expect(!controller.testMode)
|
|
}
|
|
|
|
@Test func recognitionFailureCancelsWithoutInsertion() async {
|
|
let fake = FakeSession()
|
|
let controller = DictationController(acquire: { fake }, microphone: { true }, secureInput: { false }, replenish: {})
|
|
var error: String?
|
|
controller.onError = { error = $0 }
|
|
controller.testMode = true
|
|
controller.start()
|
|
await settle()
|
|
fake.onFailure?("model stopped")
|
|
await settle()
|
|
#expect(controller.state == .idle)
|
|
#expect(fake.cancelled)
|
|
#expect(error?.contains("model stopped") == true)
|
|
#expect(controller.lastResult.isEmpty)
|
|
}
|
|
|
|
@Test func secureInputNeverStartsCapture() {
|
|
var acquired = false
|
|
let controller = DictationController(acquire: { acquired = true; return FakeSession() }, microphone: { true }, secureInput: { true }, replenish: {})
|
|
controller.start()
|
|
#expect(controller.state == .idle)
|
|
#expect(!acquired)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct HotkeyEventTests {
|
|
@Test func rightOptionUsesEventSnapshotAndReleasesWithLeftHeld() async throws {
|
|
let monitor = HotkeyMonitor(spec: .rightOption, secureInput: { false })
|
|
var pressed = 0
|
|
var released = 0
|
|
monitor.handler = { event in
|
|
switch event {
|
|
case .pressed: pressed += 1
|
|
case .released: released += 1
|
|
default: break
|
|
}
|
|
}
|
|
let event = try #require(CGEvent(keyboardEventSource: nil, virtualKey: 61, keyDown: true))
|
|
event.flags = CGEventFlags(rawValue: CGEventFlags.maskAlternate.rawValue | 0x40)
|
|
monitor.handle(type: .flagsChanged, event: event)
|
|
monitor.handle(type: .flagsChanged, event: event)
|
|
try? await Task.sleep(for: .milliseconds(20))
|
|
#expect(pressed == 1)
|
|
event.flags = CGEventFlags(rawValue: CGEventFlags.maskAlternate.rawValue | 0x20)
|
|
monitor.handle(type: .flagsChanged, event: event)
|
|
try? await Task.sleep(for: .milliseconds(20))
|
|
#expect(released == 1)
|
|
event.flags = []
|
|
monitor.handle(type: .flagsChanged, event: event)
|
|
try? await Task.sleep(for: .milliseconds(20))
|
|
#expect(released == 1)
|
|
}
|
|
}
|
|
|
|
struct TranscriptTests {
|
|
@Test func finalWithShiftedStartReplacesProvisionalText() {
|
|
var transcript = TranscriptAccumulator()
|
|
transcript.apply(start: 0.011, end: 3, text: "今天开会", final: false)
|
|
transcript.apply(start: 0, end: 3.1, text: "今天开会。", final: true)
|
|
#expect(transcript.text == "今天开会。")
|
|
transcript.apply(start: 0.011, end: 3, text: "今天开会", final: false)
|
|
#expect(transcript.text == "今天开会。")
|
|
}
|
|
|
|
@Test func separateSegmentsAndRevisionsRemainInOrder() {
|
|
var transcript = TranscriptAccumulator()
|
|
transcript.apply(start: 0, end: 1, text: "第一句。", final: true)
|
|
transcript.apply(start: 1, end: 2, text: "第二", final: false)
|
|
transcript.apply(start: 2, end: 3, text: "第三", final: false)
|
|
transcript.apply(start: 1.01, end: 2, text: "第二句。", final: true)
|
|
#expect(transcript.text == "第一句。第二句。第三")
|
|
transcript.apply(start: 2, end: 3, text: "第三句。", final: true)
|
|
#expect(transcript.text == "第一句。第二句。第三句。")
|
|
}
|
|
}
|
|
|
|
struct UnicodeInputTests {
|
|
@Test func chunksPreserveChineseEmojiAndLongInput() {
|
|
let text = String(repeating: "中文🙂é", count: 30) + "结束。"
|
|
let chunks = Inserter.unicodeChunks(text)
|
|
#expect(chunks.allSatisfy { !$0.isEmpty && $0.count <= 20 })
|
|
#expect(chunks.flatMap { $0 } == Array(text.utf16))
|
|
#expect(chunks.map { String(decoding: $0, as: UTF16.self) }.joined() == text)
|
|
#expect(Inserter.unicodeChunks("").isEmpty)
|
|
}
|
|
}
|