Files
FamilyMealPlanner/MealMoodTests/SpeechDictationServiceTests.swift
T
alexandrev-tibco 39d3534c57 fix: el tap del microfono seguia aislado al main actor (2.0.3 build 68)
El fix anterior arreglo los permisos —el crash se movio de start a beginSession,
y de TCC a AVAudioNodeTap::CheckEmitBuffer— pero el tap seguia petando.

Mi error: quitar el acceso a self del closure no elimina la inferencia de
aislamiento. La inferencia viene de DONDE se escribe el closure, no de lo que
captura. Escrito dentro de un metodo @MainActor, seguia siendo main-actor
isolated, y el tap se dispara desde el hilo de audio en tiempo real.

Ahora el handler se construye en makeTapHandler, que es nonisolated, y se pasa a
installTap. La estructura se auto-verifica: quitarle nonisolated a makeTapHandler
no compila, porque installTap es nonisolated y no puede llamar a un metodo
aislado. El bug pasa de crash en produccion a error de compilacion.

Tests: testTapHandlerRunsOffTheMainThread invoca el handler desde una cola de
fondo, que es exactamente la condicion que trapeaba. No hace falta microfono; el
intento anterior con AVAudioEngine se saltaba siempre porque el simulador no
tiene entrada de audio utilizable.

Barrido del mismo patron en el resto de servicios @MainActor: CalendarService y
NotificationService usan las variantes async/await, que estan anotadas y no
tienen closures. SpeechDictationService era el unico sitio.

Lanes feedback y crashlog para leer los reportes de TestFlight desde la API:
spaceship apunta a v1/betaFeedbacks, que Apple ya retiro; el endpoint vivo es
v1/apps/<id>/betaFeedbackCrashSubmissions y el log viene inline en logText.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ks8uUcMA9mjypVK7F2Pkt
2026-08-20 09:21:52 +02:00

74 lines
3.2 KiB
Swift

import XCTest
import AVFoundation
import Speech
@testable import MealMood
/// Smoke cover for the dictation entry point.
///
/// **This does not reproduce the crash it was written for.** The production
/// crash (`dispatch_assert_queue` inside the `requestAuthorization` handler)
/// needs TCC to deliver the callback asynchronously on a background queue, which
/// is what happens on device when the permission prompt is answered. The
/// simulator delivers it synchronously on the calling thread instead, so the
/// isolation check passes and the pre-fix code runs clean here verified by
/// reverting the fix and watching these tests still pass.
///
/// What it does cover: `start` settles instead of hanging, and does not report
/// recording when permission is unavailable. Verifying the crash itself needs a
/// real device with the speech and microphone permissions reset.
@MainActor
final class SpeechDictationServiceTests: XCTestCase {
func testStartSettlesWithoutRecordingWhenPermissionIsUnavailable() async throws {
let service = SpeechDictationService()
XCTAssertEqual(service.state, .idle)
service.start(localeIdentifier: "en-US")
let deadline = Date().addingTimeInterval(10)
while service.state == .idle && Date() < deadline {
try await Task.sleep(nanoseconds: 100_000_000)
}
XCTAssertNotEqual(service.state, .recording,
"No permission is granted in this environment, so it must not record")
}
/// This one *does* reproduce the crash class, without needing a microphone.
///
/// In 2.0.3 build 68 the app still died at the tap: `AVAudioNodeTapBlock` is
/// pre-concurrency, so a closure literal written inside a `@MainActor` method
/// was inferred main-actor isolated, and the tap fires from the realtime
/// audio thread Swift 6's isolation check tripped `dispatch_assert_queue`.
/// Dropping the `self` capture had not been enough; isolation is inferred
/// from where the closure is written.
///
/// Calling the handler off the main thread is the exact condition that
/// trapped, so this fails loudly if the isolation ever creeps back.
func testTapHandlerRunsOffTheMainThread() async throws {
let request = SFSpeechAudioBufferRecognitionRequest()
let handler = SpeechDictationService.makeTapHandler(feeding: request)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1))
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 1024))
buffer.frameLength = 1024
let ran = expectation(description: "tap handler ran on a background queue")
DispatchQueue.global(qos: .userInitiated).async {
XCTAssertFalse(Thread.isMainThread)
handler(buffer, AVAudioTime(sampleTime: 0, atRate: 44_100))
ran.fulfill()
}
await fulfillment(of: [ran], timeout: 5)
request.endAudio()
}
func testStartTwiceDoesNotBlowUp() {
let service = SpeechDictationService()
service.start(localeIdentifier: "en-US")
service.start(localeIdentifier: "en-US")
XCTAssertNotEqual(service.state, .recording)
}
}