Implemented the resumed interrupted recordings and allowed processing discards on the RecordingViewModel view model in the Recording package target.
This commit is contained in:
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
/// A transcription of a processed recording.
|
/// A transcription of a processed recording.
|
||||||
public struct Transcription: Equatable, Identifiable, Sendable {
|
public struct Transcription: Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The unique identifier of the transcription.
|
/// The unique identifier of the transcription.
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ public extension Capturing {
|
|||||||
// MARK: - Events
|
// MARK: - Events
|
||||||
|
|
||||||
/// An event emitted by a ``Capturing`` service outside its method calls.
|
/// An event emitted by a ``Capturing`` service outside its method calls.
|
||||||
public enum CapturingEvent: Sendable {
|
public enum CapturingEvent: Equatable, Sendable {
|
||||||
/// The system interrupted the ongoing capture, pausing it.
|
/// The system interrupted the ongoing capture, pausing it.
|
||||||
case interrupted
|
case interrupted
|
||||||
|
/// The system ended the interruption of the capture, hinting whether the capture may resume right away.
|
||||||
|
case interruptionEnded(shouldResume: Bool)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ public protocol Preinstalling: Sendable {
|
|||||||
// MARK: - Events
|
// MARK: - Events
|
||||||
|
|
||||||
/// An event emitted by a ``Preinstalling`` service while preinstalling speech model assets.
|
/// An event emitted by a ``Preinstalling`` service while preinstalling speech model assets.
|
||||||
|
///
|
||||||
|
/// Every case carries the locale the preinstallation was requested for — not its supported equivalent — so a host can match the
|
||||||
|
/// event against the locale it passed to ``Preinstalling/preinstall(for:)``.
|
||||||
public enum PreinstallingEvent: Sendable {
|
public enum PreinstallingEvent: Sendable {
|
||||||
/// The download of the locale's speech model assets was cancelled before it finished.
|
/// The download of the locale's speech model assets was cancelled before it finished.
|
||||||
case cancelled(Locale)
|
case cancelled(Locale)
|
||||||
|
|||||||
@@ -74,18 +74,19 @@ public final class AssetPreinstalling: Preinstalling {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Preinstalls the speech model assets for the supported equivalent of the given locale, emitting the started, cancelled, and
|
/// Preinstalls the speech model assets for the supported equivalent of the given locale, emitting the started, cancelled, and
|
||||||
/// failed downloads through ``events`` — a download that finishes, or assets that are already installed, emit nothing.
|
/// failed downloads through ``events`` — a download that finishes, or assets that are already installed, emit nothing. The
|
||||||
|
/// emitted events carry the given locale, not its supported equivalent, so a caller can match them against its requests.
|
||||||
///
|
///
|
||||||
/// - Parameter locale: The locale to preinstall the speech model assets for.
|
/// - Parameter locale: The locale to preinstall the speech model assets for.
|
||||||
public func preinstall(
|
public func preinstall(
|
||||||
for locale: Locale
|
for locale: Locale
|
||||||
) async {
|
) async {
|
||||||
guard let locale = await supportedLocale(equivalentTo: locale) else {
|
guard let supported = await supportedLocale(equivalentTo: locale) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await install(for: locale) { [continuation] in
|
try await install(for: supported) { [continuation] in
|
||||||
continuation.yield(.started(locale))
|
continuation.yield(.started(locale))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -94,7 +95,7 @@ public final class AssetPreinstalling: Preinstalling {
|
|||||||
} else {
|
} else {
|
||||||
continuation.yield(.failed(locale))
|
continuation.yield(.failed(locale))
|
||||||
|
|
||||||
logger.error("The speech model assets for the \"\(locale.identifier, privacy: .public)\" locale failed to preinstall: \(String(describing: error), privacy: .public)")
|
logger.error("The speech model assets for the \"\(supported.identifier, privacy: .public)\" locale failed to preinstall: \(String(describing: error), privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import AVFoundation
|
|||||||
/// The service records into a temporary `.m4a` file through an `AVAudioRecorder`, and returns the file's location when the recording stops.
|
/// The service records into a temporary `.m4a` file through an `AVAudioRecorder`, and returns the file's location when the recording stops.
|
||||||
/// The user's permission to record is requested before a recording starts and, on the platforms that require it, the shared audio session is
|
/// The user's permission to record is requested before a recording starts and, on the platforms that require it, the shared audio session is
|
||||||
/// configured for recording while the capture is in progress. When the system interrupts the session while a recording is in progress —
|
/// configured for recording while the capture is in progress. When the system interrupts the session while a recording is in progress —
|
||||||
/// because of a phone call or Siri, for example — the service emits ``CapturingEvent/interrupted`` through ``events``.
|
/// because of a phone call or Siri, for example — the service emits ``CapturingEvent/interrupted`` through ``events``, followed by
|
||||||
|
/// ``CapturingEvent/interruptionEnded(shouldResume:)`` once the interruption is over.
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class AudioCapturing: Capturing {
|
public final class AudioCapturing: Capturing {
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ public final class AudioCapturing: Capturing {
|
|||||||
self.recorder = nil
|
self.recorder = nil
|
||||||
|
|
||||||
#if os(iOS) || os(visionOS)
|
#if os(iOS) || os(visionOS)
|
||||||
try? AVAudioSession.sharedInstance().setActive(false)
|
try? AVAudioSession.sharedInstance().setActive(false)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
return Constant.File.url
|
return Constant.File.url
|
||||||
@@ -146,31 +147,60 @@ public final class AudioCapturing: Capturing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS) || os(visionOS)
|
#if os(iOS) || os(visionOS)
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private extension AudioCapturing {
|
private extension AudioCapturing {
|
||||||
|
|
||||||
/// Handles an interruption notification of the audio session, emitting ``CapturingEvent/interrupted`` through ``events``
|
/// Handles an interruption notification of the audio session while a recording exists, emitting the event the notification
|
||||||
/// when the system began interrupting an ongoing recording. The end of an interruption is deliberately ignored: the capture is
|
/// describes through ``events``: the beginning of an interruption — which pauses the capture — emits
|
||||||
/// never resumed without the user asking for it.
|
/// ``CapturingEvent/interrupted``, and its end emits ``CapturingEvent/interruptionEnded(shouldResume:)`` with the
|
||||||
///
|
/// system's hint on whether the capture may resume right away.
|
||||||
/// - Parameter notification: The interruption notification posted by the audio session.
|
///
|
||||||
func handleInterruption(
|
/// - Parameter notification: The interruption notification posted by the audio session.
|
||||||
_ notification: Notification
|
func handleInterruption(
|
||||||
) {
|
_ notification: Notification
|
||||||
guard
|
) {
|
||||||
recorder != nil,
|
guard
|
||||||
let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
recorder != nil,
|
||||||
AVAudioSession.InterruptionType(rawValue: typeValue) == .began
|
let event = CapturingEvent(interruption: notification.userInfo)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.yield(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
continuation.yield(.interrupted)
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - CapturingEvent+Interruptions
|
||||||
|
|
||||||
|
extension CapturingEvent {
|
||||||
|
|
||||||
|
/// Creates the event described by the user info of an audio session interruption notification, or `nil` when the user info
|
||||||
|
/// describes no known interruption.
|
||||||
|
///
|
||||||
|
/// - Parameter userInfo: The user info dictionary of the interruption notification.
|
||||||
|
init?(
|
||||||
|
interruption userInfo: [AnyHashable: Any]?
|
||||||
|
) {
|
||||||
|
guard let typeValue = userInfo?[Constant.Interruption.keyType] as? UInt else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch typeValue {
|
||||||
|
case Constant.Interruption.typeBegan:
|
||||||
|
self = .interrupted
|
||||||
|
case Constant.Interruption.typeEnded:
|
||||||
|
let optionsValue = userInfo?[Constant.Interruption.keyOptions] as? UInt ?? 0
|
||||||
|
|
||||||
|
self = .interruptionEnded(shouldResume: optionsValue & Constant.Interruption.optionShouldResume != 0)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
@@ -193,4 +223,32 @@ private enum Constant {
|
|||||||
/// The location of the temporary file the audio is captured into.
|
/// The location of the temporary file the audio is captured into.
|
||||||
static let url = FileManager.default.temporaryDirectory.appending(path: "recording.m4a")
|
static let url = FileManager.default.temporaryDirectory.appending(path: "recording.m4a")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The interruption constants, matching AVFoundation's audio session interruption keys and values — mirrored as literals on
|
||||||
|
/// the platforms without an audio session, so the interruption parsing stays compilable, and therefore testable, everywhere.
|
||||||
|
enum Interruption {
|
||||||
|
#if os(iOS) || os(visionOS)
|
||||||
|
/// The user info key of the interruption options.
|
||||||
|
static let keyOptions = AVAudioSessionInterruptionOptionKey
|
||||||
|
/// The user info key of the interruption type.
|
||||||
|
static let keyType = AVAudioSessionInterruptionTypeKey
|
||||||
|
/// The raw value of the interruption option hinting the capture may resume.
|
||||||
|
static let optionShouldResume = AVAudioSession.InterruptionOptions.shouldResume.rawValue
|
||||||
|
/// The raw value of the beginning of an interruption.
|
||||||
|
static let typeBegan = AVAudioSession.InterruptionType.began.rawValue
|
||||||
|
/// The raw value of the end of an interruption.
|
||||||
|
static let typeEnded = AVAudioSession.InterruptionType.ended.rawValue
|
||||||
|
#else
|
||||||
|
/// The user info key of the interruption options.
|
||||||
|
static let keyOptions = "AVAudioSessionInterruptionOptionKey"
|
||||||
|
/// The user info key of the interruption type.
|
||||||
|
static let keyType = "AVAudioSessionInterruptionTypeKey"
|
||||||
|
/// The raw value of the interruption option hinting the capture may resume.
|
||||||
|
static let optionShouldResume: UInt = 1
|
||||||
|
/// The raw value of the beginning of an interruption.
|
||||||
|
static let typeBegan: UInt = 1
|
||||||
|
/// The raw value of the end of an interruption.
|
||||||
|
static let typeEnded: UInt = 0
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,17 +13,21 @@ struct RecordingButtonStyle: ButtonStyle {
|
|||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The color scheme of the environment.
|
/// The color scheme of the environment.
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme)
|
||||||
|
private var colorScheme
|
||||||
|
|
||||||
/// Whether the button allows user interaction.
|
/// Whether the button allows user interaction.
|
||||||
@Environment(\.isEnabled) private var isEnabled
|
@Environment(\.isEnabled)
|
||||||
|
private var isEnabled
|
||||||
|
|
||||||
/// The padding between the button's label and its background, scaled relative to the current dynamic type size.
|
/// The padding between the button's label and its background, scaled relative to the current dynamic type size.
|
||||||
@ScaledMetric private var padding = Constant.Padding.button
|
@ScaledMetric
|
||||||
|
private var padding = Constant.Padding.button
|
||||||
|
|
||||||
/// The width and height of the button's label, scaled relative to the current dynamic type size.
|
/// The width and height of the button's label, scaled relative to the current dynamic type size.
|
||||||
@ScaledMetric private var size = Constant.Size.button
|
@ScaledMetric
|
||||||
|
private var size = Constant.Size.button
|
||||||
|
|
||||||
/// Whether the color scheme of the button's label should be inverted from the environment's.
|
/// Whether the color scheme of the button's label should be inverted from the environment's.
|
||||||
private let invertStyle: Bool
|
private let invertStyle: Bool
|
||||||
|
|
||||||
@@ -57,7 +61,7 @@ struct RecordingButtonStyle: ButtonStyle {
|
|||||||
.foregroundStyle(.windowBackground.opacity(opacity))
|
.foregroundStyle(.windowBackground.opacity(opacity))
|
||||||
.environment(
|
.environment(
|
||||||
\.colorScheme,
|
\.colorScheme,
|
||||||
colorSchemeLabel
|
colorSchemeLabel
|
||||||
)
|
)
|
||||||
.glassEffect(
|
.glassEffect(
|
||||||
.regular
|
.regular
|
||||||
@@ -73,9 +77,9 @@ struct RecordingButtonStyle: ButtonStyle {
|
|||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private extension RecordingButtonStyle {
|
private extension RecordingButtonStyle {
|
||||||
|
|
||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|
||||||
/// The color scheme for the button's label: the opposite of the environment's when ``invertStyle`` is set, the environment's otherwise.
|
/// The color scheme for the button's label: the opposite of the environment's when ``invertStyle`` is set, the environment's otherwise.
|
||||||
var colorSchemeLabel: ColorScheme {
|
var colorSchemeLabel: ColorScheme {
|
||||||
guard invertStyle else {
|
guard invertStyle else {
|
||||||
@@ -147,9 +151,12 @@ private enum Constant {
|
|||||||
#Preview(
|
#Preview(
|
||||||
"Recording button style"
|
"Recording button style"
|
||||||
) {
|
) {
|
||||||
@Previewable @State var isDisabled: Bool = false
|
@Previewable @State
|
||||||
@Previewable @State var isStyleInverted: Bool = false
|
var isDisabled: Bool = false
|
||||||
|
|
||||||
|
@Previewable @State
|
||||||
|
var isStyleInverted: Bool = false
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
// Button action closure.
|
// Button action closure.
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ extension RecordingView {
|
|||||||
|
|
||||||
/// The observable model that drives ``RecordingView``.
|
/// The observable model that drives ``RecordingView``.
|
||||||
///
|
///
|
||||||
/// The model implements the recording flow as a ``State`` machine: it exposes the visibility and icon of the view's controls for the current state, counts the
|
/// The model implements the recording flow as a ``State`` machine: it exposes the visibility, icons, accessibility labels, and alert
|
||||||
/// elapsed recording time, and processes the recorded input once it is sent. The audio capture and its transcription are delegated to the
|
/// message of the view's controls for the current state, counts the elapsed recording time, and processes the recorded input once
|
||||||
/// ``Capturing`` and ``Transcribing`` attached at initialization; when either of them fails, the model falls back to the not-recording state.
|
/// it is sent. The audio capture and its transcription are delegated to the ``Capturing`` and ``Transcribing`` attached at
|
||||||
|
/// initialization; when either of them fails, the model falls back to the not-recording state and publishes the failure in ``error``.
|
||||||
|
///
|
||||||
|
/// A recording paused by a system interruption of the capture resumes by itself once the interruption ends with the system's
|
||||||
|
/// resume hint, and a recording already being processed can still be discarded, cancelling its in-flight transcription.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class Model {
|
final class Model {
|
||||||
@@ -33,7 +37,7 @@ extension RecordingView {
|
|||||||
/// The instant the current recording stretch started, or `nil` while not recording.
|
/// The instant the current recording stretch started, or `nil` while not recording.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var anchor: ContinuousClock.Instant?
|
private var anchor: ContinuousClock.Instant?
|
||||||
|
|
||||||
/// The service that captures the audio from a microphone.
|
/// The service that captures the audio from a microphone.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private let capturer: any Capturing
|
private let capturer: any Capturing
|
||||||
@@ -42,6 +46,10 @@ extension RecordingView {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private let clock = ContinuousClock()
|
private let clock = ContinuousClock()
|
||||||
|
|
||||||
|
/// Whether the recording was paused by a system interruption of the audio capture, as opposed to by the user.
|
||||||
|
@ObservationIgnored
|
||||||
|
private var isPausedByInterruption = false
|
||||||
|
|
||||||
/// The binding to the locale of the spoken language to transcribe.
|
/// The binding to the locale of the spoken language to transcribe.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private let locale: Binding<Locale>
|
private let locale: Binding<Locale>
|
||||||
@@ -103,6 +111,22 @@ extension RecordingView {
|
|||||||
state == .processing
|
state == .processing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The accessibility label for the main button, matching its action in the current state.
|
||||||
|
var labelMain: LocalizedStringResource {
|
||||||
|
switch state {
|
||||||
|
case .notRecording, .processing: .viewRecordingButtonMainLabelRecord
|
||||||
|
case .recording: .viewRecordingButtonMainLabelPause
|
||||||
|
case .paused: .viewRecordingButtonMainLabelResume
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accessibility label for the send button: the sending action while paused, the ongoing processing otherwise.
|
||||||
|
var labelSend: LocalizedStringResource {
|
||||||
|
isProcessing
|
||||||
|
? .viewRecordingButtonSendLabelProcessing
|
||||||
|
: .viewRecordingButtonSendLabelSend
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the main button should be disabled.
|
/// Whether the main button should be disabled.
|
||||||
var shouldDisableMain: Bool {
|
var shouldDisableMain: Bool {
|
||||||
state == .processing
|
state == .processing
|
||||||
@@ -119,6 +143,17 @@ extension RecordingView {
|
|||||||
state != .notRecording
|
state != .notRecording
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The localized message describing ``error`` for the alert, or `nil` when no error occurred.
|
||||||
|
var textAlertMessage: LocalizedStringResource? {
|
||||||
|
switch error {
|
||||||
|
case .assetsUnavailable: .viewRecordingAlertErrorMessageAssets
|
||||||
|
case .captureFailed: .viewRecordingAlertErrorMessageCapture
|
||||||
|
case .permissionDenied: .viewRecordingAlertErrorMessagePermission
|
||||||
|
case .transcriptionFailed: .viewRecordingAlertErrorMessageTranscription
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The elapsed recording time, formatted as `mm:ss` for the timer label.
|
/// The elapsed recording time, formatted as `mm:ss` for the timer label.
|
||||||
var textTimer: String {
|
var textTimer: String {
|
||||||
Duration
|
Duration
|
||||||
@@ -140,26 +175,45 @@ extension RecordingView {
|
|||||||
|
|
||||||
/// Handles a press of the discard button.
|
/// Handles a press of the discard button.
|
||||||
///
|
///
|
||||||
/// Throws away a paused recording: it resets the timer, returns to the not-recording state, and stops the audio capture,
|
/// Throws away a paused recording — or one already being processed, cancelling its in-flight transcription: it resets the
|
||||||
/// deleting the captured audio file. Does nothing in any other state.
|
/// timer, returns to the not-recording state, and stops the audio capture, deleting the captured audio file. Does nothing in
|
||||||
|
/// any other state.
|
||||||
func pressedDiscard() {
|
func pressedDiscard() {
|
||||||
guard state == .paused else {
|
isPausedByInterruption = false
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state = .notRecording
|
switch state {
|
||||||
|
case .paused:
|
||||||
|
state = .notRecording
|
||||||
|
|
||||||
stopTimer()
|
stopTimer()
|
||||||
|
|
||||||
accumulated = .zero
|
accumulated = .zero
|
||||||
elapsedSeconds = 0
|
elapsedSeconds = 0
|
||||||
|
|
||||||
enqueueCapturer {
|
enqueueCapturer {
|
||||||
guard let audio = try? await self.capturer.stop() else {
|
guard let audio = try? await self.capturer.stop() else {
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try? FileManager.default.removeItem(at: audio)
|
||||||
}
|
}
|
||||||
|
case .processing:
|
||||||
|
taskCapturer?.cancel()
|
||||||
|
|
||||||
try? FileManager.default.removeItem(at: audio)
|
state = .notRecording
|
||||||
|
|
||||||
|
accumulated = .zero
|
||||||
|
elapsedSeconds = 0
|
||||||
|
|
||||||
|
enqueueCapturer {
|
||||||
|
guard let audio = try? await self.capturer.stop() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try? FileManager.default.removeItem(at: audio)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +222,8 @@ extension RecordingView {
|
|||||||
/// Starts a recording when idle, pauses an ongoing recording, or resumes a paused one — starting and stopping the timer
|
/// Starts a recording when idle, pauses an ongoing recording, or resumes a paused one — starting and stopping the timer
|
||||||
/// and the audio capture accordingly. Does nothing while the input is being processed.
|
/// and the audio capture accordingly. Does nothing while the input is being processed.
|
||||||
func pressedMain() {
|
func pressedMain() {
|
||||||
|
isPausedByInterruption = false
|
||||||
|
|
||||||
switch state {
|
switch state {
|
||||||
case .notRecording:
|
case .notRecording:
|
||||||
state = .recording
|
state = .recording
|
||||||
@@ -178,10 +234,7 @@ extension RecordingView {
|
|||||||
case .recording:
|
case .recording:
|
||||||
pauseRecording()
|
pauseRecording()
|
||||||
case .paused:
|
case .paused:
|
||||||
state = .recording
|
resumeRecording()
|
||||||
|
|
||||||
startTimer(false)
|
|
||||||
startCapturer(false)
|
|
||||||
case .processing:
|
case .processing:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -196,6 +249,8 @@ extension RecordingView {
|
|||||||
///
|
///
|
||||||
/// Moves a paused recording into processing, stopping the timer and kicking off ``processInput()``. Does nothing in any other state.
|
/// Moves a paused recording into processing, stopping the timer and kicking off ``processInput()``. Does nothing in any other state.
|
||||||
func pressedSend() {
|
func pressedSend() {
|
||||||
|
isPausedByInterruption = false
|
||||||
|
|
||||||
switch state {
|
switch state {
|
||||||
case .paused:
|
case .paused:
|
||||||
state = .processing
|
state = .processing
|
||||||
@@ -232,7 +287,8 @@ private extension RecordingView.Model {
|
|||||||
|
|
||||||
/// Processes the recorded input: it stops the audio capture, transcribes the captured audio, and publishes the result in ``transcription``;
|
/// Processes the recorded input: it stops the audio capture, transcribes the captured audio, and publishes the result in ``transcription``;
|
||||||
/// when finished — or when either service fails, in which case the failure is published in ``error`` — it resets ``elapsedSeconds``
|
/// when finished — or when either service fails, in which case the failure is published in ``error`` — it resets ``elapsedSeconds``
|
||||||
/// and returns the model to the not-recording state.
|
/// and returns the model to the not-recording state. A processing cancelled through the discard button publishes nothing:
|
||||||
|
/// the discard already reset the model.
|
||||||
func processInput() async {
|
func processInput() async {
|
||||||
guard state == .processing else {
|
guard state == .processing else {
|
||||||
return
|
return
|
||||||
@@ -242,21 +298,46 @@ private extension RecordingView.Model {
|
|||||||
let audio = try await capturer.stop()
|
let audio = try await capturer.stop()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
transcription = try await transcribe(
|
let transcription = try await transcribe(
|
||||||
audio,
|
audio,
|
||||||
locale: locale.wrappedValue
|
locale: locale.wrappedValue
|
||||||
)
|
)
|
||||||
} catch {
|
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.transcription = transcription
|
||||||
|
}
|
||||||
|
catch is CancellationError {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
transcription = nil
|
transcription = nil
|
||||||
self.error = error as? AudioTranscribingError == .assetsNotInstalled
|
self.error =
|
||||||
|
error as? AudioTranscribingError == .assetsNotInstalled
|
||||||
? .assetsUnavailable
|
? .assetsUnavailable
|
||||||
: .transcriptionFailed
|
: .transcriptionFailed
|
||||||
}
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
transcription = nil
|
transcription = nil
|
||||||
self.error = .captureFailed
|
self.error = .captureFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
accumulated = .zero
|
accumulated = .zero
|
||||||
elapsedSeconds = 0
|
elapsedSeconds = 0
|
||||||
state = .notRecording
|
state = .notRecording
|
||||||
@@ -276,7 +357,8 @@ private extension RecordingView.Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Starts listening to the events emitted by the capturing service, unless already listening: an interruption of the capture pauses
|
/// Starts listening to the events emitted by the capturing service, unless already listening: an interruption of the capture pauses
|
||||||
/// an ongoing recording, exactly like a press of the main button would.
|
/// an ongoing recording, exactly like a press of the main button would, and the end of an interruption resumes the recording it
|
||||||
|
/// paused — but only when the system hints the capture may resume, and never one the user paused themselves.
|
||||||
func listenToEvents() {
|
func listenToEvents() {
|
||||||
guard taskEvents == nil else {
|
guard taskEvents == nil else {
|
||||||
return
|
return
|
||||||
@@ -291,8 +373,16 @@ private extension RecordingView.Model {
|
|||||||
switch event {
|
switch event {
|
||||||
case .interrupted:
|
case .interrupted:
|
||||||
if state == .recording {
|
if state == .recording {
|
||||||
|
isPausedByInterruption = true
|
||||||
|
|
||||||
pauseRecording()
|
pauseRecording()
|
||||||
}
|
}
|
||||||
|
case .interruptionEnded(let shouldResume):
|
||||||
|
if shouldResume, state == .paused, isPausedByInterruption {
|
||||||
|
resumeRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
isPausedByInterruption = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,6 +399,14 @@ private extension RecordingView.Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resumes the paused recording: it restarts the timer, without resetting it, and resumes the audio capture.
|
||||||
|
func resumeRecording() {
|
||||||
|
state = .recording
|
||||||
|
|
||||||
|
startTimer(false)
|
||||||
|
startCapturer(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Starts the audio capture through the attached ``Capturing``, falling back to the not-recording state and publishing the failure
|
/// Starts the audio capture through the attached ``Capturing``, falling back to the not-recording state and publishing the failure
|
||||||
/// in ``error`` when the service fails.
|
/// in ``error`` when the service fails.
|
||||||
///
|
///
|
||||||
@@ -325,7 +423,8 @@ private extension RecordingView.Model {
|
|||||||
self.stopTimer()
|
self.stopTimer()
|
||||||
|
|
||||||
self.state = .notRecording
|
self.state = .notRecording
|
||||||
self.error = error as? AudioCapturingError == .permissionNotGranted
|
self.error =
|
||||||
|
error as? AudioCapturingError == .permissionNotGranted
|
||||||
? .permissionDenied
|
? .permissionDenied
|
||||||
: .captureFailed
|
: .captureFailed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// A view that drives a recording session.
|
/// A view that drives a recording session.
|
||||||
@@ -11,17 +11,20 @@ import UIKit
|
|||||||
///
|
///
|
||||||
/// All state and control behavior lives in the view's ``Model``; the view itself only renders it and forwards button presses.
|
/// All state and control behavior lives in the view's ``Model``; the view itself only renders it and forwards button presses.
|
||||||
public struct RecordingView: View {
|
public struct RecordingView: View {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The action that opens a URL, used to open the app's settings from the error alert.
|
/// The action that opens a URL, used to open the app's settings from the error alert.
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL)
|
||||||
|
private var openURL
|
||||||
|
|
||||||
|
/// The font size of the timer's label, scaled relative to the current dynamic type size.
|
||||||
|
@ScaledMetric
|
||||||
|
private var fontSize = Constant.Size.timer
|
||||||
|
|
||||||
/// The model that owns the recording state and drives the view's controls.
|
/// The model that owns the recording state and drives the view's controls.
|
||||||
@State private var model: Model
|
@State
|
||||||
|
private var model: Model
|
||||||
/// The font size of the timer's label, scaled relative to the current dynamic type size.
|
|
||||||
@ScaledMetric private var fontSize = Constant.Size.timer
|
|
||||||
|
|
||||||
/// The closure invoked with the transcription of every processed recording.
|
/// The closure invoked with the transcription of every processed recording.
|
||||||
private let onTranscription: (Transcription) -> Void
|
private let onTranscription: (Transcription) -> Void
|
||||||
@@ -69,10 +72,10 @@ public struct RecordingView: View {
|
|||||||
.easeInOut,
|
.easeInOut,
|
||||||
value: model.textTimer
|
value: model.textTimer
|
||||||
)
|
)
|
||||||
.accessibilityLabel(Constant.Text.labelTimer)
|
.accessibilityLabel(.viewRecordingTimerLabel)
|
||||||
.accessibilityValue(model.textTimerAccessible)
|
.accessibilityValue(model.textTimerAccessible)
|
||||||
}
|
}
|
||||||
|
|
||||||
GlassEffectContainer {
|
GlassEffectContainer {
|
||||||
HStack(
|
HStack(
|
||||||
spacing: Constant.Spacing.stack
|
spacing: Constant.Spacing.stack
|
||||||
@@ -87,7 +90,7 @@ public struct RecordingView: View {
|
|||||||
invertStyle: true
|
invertStyle: true
|
||||||
))
|
))
|
||||||
.disabled(model.shouldDisableMain)
|
.disabled(model.shouldDisableMain)
|
||||||
.accessibilityLabel(labelMain)
|
.accessibilityLabel(model.labelMain)
|
||||||
|
|
||||||
if model.shouldShowActions {
|
if model.shouldShowActions {
|
||||||
Button {
|
Button {
|
||||||
@@ -104,19 +107,18 @@ public struct RecordingView: View {
|
|||||||
invertStyle: !model.isProcessing
|
invertStyle: !model.isProcessing
|
||||||
))
|
))
|
||||||
.disabled(model.isProcessing)
|
.disabled(model.isProcessing)
|
||||||
.accessibilityLabel(labelSend)
|
.accessibilityLabel(model.labelSend)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
model.pressedDiscard()
|
model.pressedDiscard()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: Constant.Symbol.discard)
|
Image.Icon.discard
|
||||||
.resizable()
|
.resizable()
|
||||||
}
|
}
|
||||||
.buttonStyle(.recording(
|
.buttonStyle(.recording(
|
||||||
invertStyle: true
|
invertStyle: true
|
||||||
))
|
))
|
||||||
.disabled(model.shouldDisableMain)
|
.accessibilityLabel(.viewRecordingButtonDiscardLabel)
|
||||||
.accessibilityLabel(Constant.Text.labelDiscard)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,72 +136,32 @@ public struct RecordingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert(
|
.alert(
|
||||||
Constant.Text.titleError,
|
.viewRecordingAlertErrorTitle,
|
||||||
isPresented: .init(
|
isPresented: .init {
|
||||||
get: { model.error != nil },
|
model.error != nil
|
||||||
set: { isPresented in
|
} set: { isPresented in
|
||||||
if !isPresented {
|
if !isPresented {
|
||||||
model.dismissedError()
|
model.dismissedError()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
),
|
},
|
||||||
presenting: model.error
|
presenting: model.error
|
||||||
) { error in
|
) { error in
|
||||||
if error == .permissionDenied, let url = Constant.URLs.settings {
|
if error == .permissionDenied, let url = URL.settings {
|
||||||
Button(Constant.Text.buttonSettings) {
|
Button(.viewRecordingAlertErrorButtonSettings) {
|
||||||
openURL(url)
|
openURL(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
Constant.Text.buttonOK,
|
.viewRecordingAlertErrorButtonOk,
|
||||||
role: .cancel
|
role: .cancel
|
||||||
) {
|
) {
|
||||||
// Dismissal is handled by the presentation binding.
|
// Dismissal is handled by the presentation binding.
|
||||||
}
|
}
|
||||||
} message: { error in
|
} message: { _ in
|
||||||
Text(message(for: error))
|
if let message = model.textAlertMessage {
|
||||||
}
|
Text(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
private extension RecordingView {
|
|
||||||
|
|
||||||
// MARK: Computed
|
|
||||||
|
|
||||||
/// The accessibility label for the main button, matching its action in the current state.
|
|
||||||
var labelMain: String {
|
|
||||||
switch model.state {
|
|
||||||
case .notRecording, .processing: Constant.Text.labelRecord
|
|
||||||
case .recording: Constant.Text.labelPause
|
|
||||||
case .paused: Constant.Text.labelResume
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The accessibility label for the send button: the sending action while paused, the ongoing processing otherwise.
|
|
||||||
var labelSend: String {
|
|
||||||
model.isProcessing
|
|
||||||
? Constant.Text.labelProcessing
|
|
||||||
: Constant.Text.labelSend
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Methods
|
|
||||||
|
|
||||||
/// Returns the message describing the given error for the alert.
|
|
||||||
///
|
|
||||||
/// - Parameter error: The error to describe.
|
|
||||||
/// - Returns: The message describing the error.
|
|
||||||
func message(
|
|
||||||
for error: RecordingError
|
|
||||||
) -> String {
|
|
||||||
switch error {
|
|
||||||
case .assetsUnavailable: Constant.Text.messageAssetsUnavailable
|
|
||||||
case .captureFailed: Constant.Text.messageCaptureFailed
|
|
||||||
case .permissionDenied: Constant.Text.messagePermissionDenied
|
|
||||||
case .transcriptionFailed: Constant.Text.messageTranscriptionFailed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,98 +181,29 @@ private enum Constant {
|
|||||||
/// The spacing between the elements of a stack.
|
/// The spacing between the elements of a stack.
|
||||||
static let stack: CGFloat = 16
|
static let stack: CGFloat = 16
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Image+Constants
|
||||||
|
|
||||||
|
private extension Image {
|
||||||
/// The symbol constants.
|
/// The symbol constants.
|
||||||
enum Symbol {
|
enum Icon {
|
||||||
/// The system symbol of the discard button's image.
|
/// The system symbol of the discard button's image.
|
||||||
static let discard = "trash"
|
static let discard = Image(systemName: "trash")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The text constants, localized through the package's string catalog.
|
// MARK: - URL+Constants
|
||||||
enum Text {
|
|
||||||
/// The title of the alert's dismissal button.
|
|
||||||
static let buttonOK = String(
|
|
||||||
localized: "view.recording.alert.error.button.ok",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The accessibility label of the discard button.
|
|
||||||
static let labelDiscard = String(
|
|
||||||
localized: "view.recording.button.discard.label",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The accessibility label of the main button while recording.
|
|
||||||
static let labelPause = String(
|
|
||||||
localized: "view.recording.button.main.label.pause",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The accessibility label of the send button while processing.
|
|
||||||
static let labelProcessing = String(
|
|
||||||
localized: "view.recording.button.send.label.processing",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The accessibility label of the main button while not recording.
|
|
||||||
static let labelRecord = String(
|
|
||||||
localized: "view.recording.button.main.label.record",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The accessibility label of the main button while paused.
|
|
||||||
static let labelResume = String(
|
|
||||||
localized: "view.recording.button.main.label.resume",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The accessibility label of the send button while paused.
|
|
||||||
static let labelSend = String(
|
|
||||||
localized: "view.recording.button.send.label.send",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The accessibility label of the timer.
|
|
||||||
static let labelTimer = String(
|
|
||||||
localized: "view.recording.timer.label",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The title of the alert's button that opens the app's settings.
|
|
||||||
static let buttonSettings = String(
|
|
||||||
localized: "view.recording.alert.error.button.settings",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The message describing unavailable speech model assets.
|
|
||||||
static let messageAssetsUnavailable = String(
|
|
||||||
localized: "view.recording.alert.error.message.assets",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The message describing a failure of the audio capture.
|
|
||||||
static let messageCaptureFailed = String(
|
|
||||||
localized: "view.recording.alert.error.message.capture",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The message describing a denied microphone permission.
|
|
||||||
static let messagePermissionDenied = String(
|
|
||||||
localized: "view.recording.alert.error.message.permission",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The message describing a failure of the transcription.
|
|
||||||
static let messageTranscriptionFailed = String(
|
|
||||||
localized: "view.recording.alert.error.message.transcription",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
/// The title of the error alert.
|
|
||||||
static let titleError = String(
|
|
||||||
localized: "view.recording.alert.error.title",
|
|
||||||
bundle: .module
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The URL constants.
|
private extension URL {
|
||||||
enum URLs {
|
/// The location that opens the app's settings, where the microphone permission can be granted.
|
||||||
/// The location that opens the app's settings, where the microphone permission can be granted.
|
static let settings: URL? = {
|
||||||
static let settings: URL? = {
|
#if os(macOS)
|
||||||
#if os(macOS)
|
.init(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
|
||||||
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
|
#else
|
||||||
#else
|
.init(string: UIApplication.openSettingsURLString)
|
||||||
URL(string: UIApplication.openSettingsURLString)
|
#endif
|
||||||
#endif
|
}()
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import Recording
|
|||||||
/// configured to fail.
|
/// configured to fail.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CapturingMock: Capturing {
|
final class CapturingMock: Capturing {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
/// The stream of events the service emits outside its method calls.
|
/// The stream of events the service emits outside its method calls.
|
||||||
let events: AsyncStream<CapturingEvent>
|
let events: AsyncStream<CapturingEvent>
|
||||||
@@ -20,16 +22,29 @@ final class CapturingMock: Capturing {
|
|||||||
|
|
||||||
/// The continuation that feeds ``events``.
|
/// The continuation that feeds ``events``.
|
||||||
private let continuation: AsyncStream<CapturingEvent>.Continuation
|
private let continuation: AsyncStream<CapturingEvent>.Continuation
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
(events, continuation) = AsyncStream.makeStream(of: CapturingEvent.self)
|
(events, continuation) = AsyncStream.makeStream(of: CapturingEvent.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
/// Emits an interruption of the ongoing capture through ``events``.
|
/// Emits an interruption of the ongoing capture through ``events``.
|
||||||
func interrupt() {
|
func interrupt() {
|
||||||
continuation.yield(.interrupted)
|
continuation.yield(.interrupted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emits the end of an interruption of the capture through ``events``.
|
||||||
|
///
|
||||||
|
/// - Parameter shouldResume: Whether the capture may resume right away.
|
||||||
|
func endInterruption(
|
||||||
|
shouldResume: Bool
|
||||||
|
) {
|
||||||
|
continuation.yield(.interruptionEnded(shouldResume: shouldResume))
|
||||||
|
}
|
||||||
|
|
||||||
func start() async throws {
|
func start() async throws {
|
||||||
try await called("start")
|
try await called("start")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Recording
|
|
||||||
|
|
||||||
/// A preinstalling service that records its requests, resolves the supported locales from configurable tables, and emits events
|
|
||||||
/// on demand.
|
|
||||||
@MainActor
|
|
||||||
final class PreinstallingMock: Preinstalling {
|
|
||||||
|
|
||||||
/// The stream of events the service emits while preinstalling.
|
|
||||||
let events: AsyncStream<PreinstallingEvent>
|
|
||||||
|
|
||||||
/// The supported equivalents the service resolves, keyed by the requested locale.
|
|
||||||
var equivalents: [Locale: Locale] = [:]
|
|
||||||
|
|
||||||
/// The locales the service reports as supported.
|
|
||||||
var localesSupported: [Locale] = []
|
|
||||||
|
|
||||||
/// The locales a preinstallation was requested for, in request order.
|
|
||||||
private(set) var localesPreinstalled: [Locale] = []
|
|
||||||
|
|
||||||
/// The continuation that feeds ``events``.
|
|
||||||
private let continuation: AsyncStream<PreinstallingEvent>.Continuation
|
|
||||||
|
|
||||||
init() {
|
|
||||||
(events, continuation) = AsyncStream.makeStream(of: PreinstallingEvent.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emits the given event through ``events``.
|
|
||||||
///
|
|
||||||
/// - Parameter event: The event to emit.
|
|
||||||
func emit(
|
|
||||||
_ event: PreinstallingEvent
|
|
||||||
) {
|
|
||||||
continuation.yield(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
func preinstall(
|
|
||||||
for locale: Locale
|
|
||||||
) async {
|
|
||||||
localesPreinstalled.append(locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
func supportedLocale(
|
|
||||||
equivalentTo locale: Locale
|
|
||||||
) async -> Locale? {
|
|
||||||
equivalents[locale]
|
|
||||||
}
|
|
||||||
|
|
||||||
func supportedLocales() async -> [Locale] {
|
|
||||||
localesSupported
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,8 @@ import Recording
|
|||||||
/// A transcribing service with a configurable delay, output, and failure.
|
/// A transcribing service with a configurable delay, output, and failure.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class TranscribingMock: Transcribing {
|
final class TranscribingMock: Transcribing {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
/// The duration of the simulated transcription work.
|
/// The duration of the simulated transcription work.
|
||||||
var delay: Duration = .seconds(0.2)
|
var delay: Duration = .seconds(0.2)
|
||||||
@@ -13,6 +15,8 @@ final class TranscribingMock: Transcribing {
|
|||||||
|
|
||||||
/// The transcription the service returns.
|
/// The transcription the service returns.
|
||||||
var transcription = "This is a mocked transcription."
|
var transcription = "This is a mocked transcription."
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
func callAsFunction(
|
func callAsFunction(
|
||||||
_ audio: URL,
|
_ audio: URL,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct TranscriptionTests {
|
|||||||
"",
|
"",
|
||||||
"This is a transcription.",
|
"This is a transcription.",
|
||||||
" ",
|
" ",
|
||||||
"Multiline\ntranscribed\ntext."
|
"Multiline\ntranscribed\ntext.",
|
||||||
])
|
])
|
||||||
func `initializer stores the given text`(text: String) {
|
func `initializer stores the given text`(text: String) {
|
||||||
let transcription = Transcription(text: text)
|
let transcription = Transcription(text: text)
|
||||||
@@ -23,7 +23,8 @@ struct TranscriptionTests {
|
|||||||
#expect(transcription.text == text)
|
#expect(transcription.text == text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `initializer stores the given identifier`() {
|
@Test
|
||||||
|
func `initializer stores the given identifier`() {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let transcription = Transcription(
|
let transcription = Transcription(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -40,10 +41,12 @@ struct TranscriptionTests {
|
|||||||
@Suite("Computed properties")
|
@Suite("Computed properties")
|
||||||
struct ComputedProperties {
|
struct ComputedProperties {
|
||||||
|
|
||||||
@Test(arguments: zip(
|
@Test(
|
||||||
["", "This is a transcription.", " "],
|
arguments: zip(
|
||||||
[true, false, false]
|
["", "This is a transcription.", " "],
|
||||||
))
|
[true, false, false]
|
||||||
|
)
|
||||||
|
)
|
||||||
func `is empty only when the text has no characters`(
|
func `is empty only when the text has no characters`(
|
||||||
for text: String,
|
for text: String,
|
||||||
expected: Bool
|
expected: Bool
|
||||||
@@ -60,7 +63,8 @@ struct TranscriptionTests {
|
|||||||
@Suite("Equatable")
|
@Suite("Equatable")
|
||||||
struct EquatableConformance {
|
struct EquatableConformance {
|
||||||
|
|
||||||
@Test func `transcriptions with the same identifier and text are equal`() {
|
@Test
|
||||||
|
func `transcriptions with the same identifier and text are equal`() {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
|
|
||||||
let first = Transcription(
|
let first = Transcription(
|
||||||
@@ -75,7 +79,8 @@ struct TranscriptionTests {
|
|||||||
#expect(first == second)
|
#expect(first == second)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `transcriptions with generated identifiers are never equal`() {
|
@Test
|
||||||
|
func `transcriptions with generated identifiers are never equal`() {
|
||||||
let first = Transcription(text: "This is a transcription.")
|
let first = Transcription(text: "This is a transcription.")
|
||||||
let second = Transcription(text: "This is a transcription.")
|
let second = Transcription(text: "This is a transcription.")
|
||||||
|
|
||||||
@@ -89,13 +94,15 @@ struct TranscriptionTests {
|
|||||||
@Suite("Identifiable")
|
@Suite("Identifiable")
|
||||||
struct IdentifiableConformance {
|
struct IdentifiableConformance {
|
||||||
|
|
||||||
@Test func `identifier is stable across accesses`() {
|
@Test
|
||||||
|
func `identifier is stable across accesses`() {
|
||||||
let transcription = Transcription(text: "This is a transcription.")
|
let transcription = Transcription(text: "This is a transcription.")
|
||||||
|
|
||||||
#expect(transcription.id == transcription.id)
|
#expect(transcription.id == transcription.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `identifiers differ between transcriptions`() {
|
@Test
|
||||||
|
func `identifiers differ between transcriptions`() {
|
||||||
let first = Transcription(text: "This is a transcription.")
|
let first = Transcription(text: "This is a transcription.")
|
||||||
let second = Transcription(text: "This is a transcription.")
|
let second = Transcription(text: "This is a transcription.")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Recording
|
||||||
|
|
||||||
|
@Suite("Capturing interruption events")
|
||||||
|
struct CapturingEventInterruptionTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `a began interruption maps to the interrupted event`() {
|
||||||
|
let event = CapturingEvent(interruption: [
|
||||||
|
"AVAudioSessionInterruptionTypeKey": UInt(1)
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(event == .interrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `an ended interruption without options maps to a non-resuming end event`() {
|
||||||
|
let event = CapturingEvent(interruption: [
|
||||||
|
"AVAudioSessionInterruptionTypeKey": UInt(0)
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(event == .interruptionEnded(shouldResume: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `an ended interruption with the resume option maps to a resuming end event`() {
|
||||||
|
let event = CapturingEvent(interruption: [
|
||||||
|
"AVAudioSessionInterruptionTypeKey": UInt(0),
|
||||||
|
"AVAudioSessionInterruptionOptionKey": UInt(1),
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(event == .interruptionEnded(shouldResume: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `a missing interruption type maps to no event`() {
|
||||||
|
#expect(CapturingEvent(interruption: [:]) == nil)
|
||||||
|
#expect(CapturingEvent(interruption: nil) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `an unknown interruption type maps to no event`() {
|
||||||
|
let event = CapturingEvent(interruption: [
|
||||||
|
"AVAudioSessionInterruptionTypeKey": UInt(99)
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(event == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `a mistyped interruption type maps to no event`() {
|
||||||
|
let event = CapturingEvent(interruption: [
|
||||||
|
"AVAudioSessionInterruptionTypeKey": "began"
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(event == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -14,12 +14,14 @@ struct RecordingViewModelTests {
|
|||||||
@Suite("Initial state")
|
@Suite("Initial state")
|
||||||
struct InitialState {
|
struct InitialState {
|
||||||
|
|
||||||
@Test func `initial state is not recording with a zeroed timer`() {
|
@Test
|
||||||
|
func `initial state is not recording with a zeroed timer`() {
|
||||||
let model = Model()
|
let model = Model()
|
||||||
|
|
||||||
#expect(model.state == .notRecording)
|
#expect(model.state == .notRecording)
|
||||||
#expect(model.elapsedSeconds == 0)
|
#expect(model.elapsedSeconds == 0)
|
||||||
#expect(model.error == nil)
|
#expect(model.error == nil)
|
||||||
|
#expect(model.textAlertMessage == nil)
|
||||||
#expect(model.textTimer == "00:00")
|
#expect(model.textTimer == "00:00")
|
||||||
#expect(model.transcription == nil)
|
#expect(model.transcription == nil)
|
||||||
}
|
}
|
||||||
@@ -68,7 +70,7 @@ struct RecordingViewModelTests {
|
|||||||
|
|
||||||
@Test(arguments: zip(
|
@Test(arguments: zip(
|
||||||
[Model.State.notRecording, .recording, .paused, .processing],
|
[Model.State.notRecording, .recording, .paused, .processing],
|
||||||
[Model.State.notRecording, .recording, .notRecording, .processing]
|
[Model.State.notRecording, .recording, .notRecording, .notRecording]
|
||||||
))
|
))
|
||||||
func `pressed discard transitions to the expected state`(
|
func `pressed discard transitions to the expected state`(
|
||||||
from initial: Model.State,
|
from initial: Model.State,
|
||||||
@@ -136,6 +138,46 @@ struct RecordingViewModelTests {
|
|||||||
#expect(model.isProcessing == expected)
|
#expect(model.isProcessing == expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test(arguments: zip(
|
||||||
|
[Model.State.notRecording, .recording, .paused, .processing],
|
||||||
|
[
|
||||||
|
LocalizedStringResource.viewRecordingButtonMainLabelRecord,
|
||||||
|
.viewRecordingButtonMainLabelPause,
|
||||||
|
.viewRecordingButtonMainLabelResume,
|
||||||
|
.viewRecordingButtonMainLabelRecord,
|
||||||
|
]
|
||||||
|
))
|
||||||
|
func `main label matches the state`(
|
||||||
|
for state: Model.State,
|
||||||
|
expected: LocalizedStringResource
|
||||||
|
) {
|
||||||
|
let model = Model()
|
||||||
|
|
||||||
|
model.drive(to: state)
|
||||||
|
|
||||||
|
#expect(model.labelMain == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: zip(
|
||||||
|
[Model.State.notRecording, .recording, .paused, .processing],
|
||||||
|
[
|
||||||
|
LocalizedStringResource.viewRecordingButtonSendLabelSend,
|
||||||
|
.viewRecordingButtonSendLabelSend,
|
||||||
|
.viewRecordingButtonSendLabelSend,
|
||||||
|
.viewRecordingButtonSendLabelProcessing,
|
||||||
|
]
|
||||||
|
))
|
||||||
|
func `send label matches the state`(
|
||||||
|
for state: Model.State,
|
||||||
|
expected: LocalizedStringResource
|
||||||
|
) {
|
||||||
|
let model = Model()
|
||||||
|
|
||||||
|
model.drive(to: state)
|
||||||
|
|
||||||
|
#expect(model.labelSend == expected)
|
||||||
|
}
|
||||||
|
|
||||||
@Test(arguments: zip(
|
@Test(arguments: zip(
|
||||||
[Model.State.notRecording, .recording, .paused, .processing],
|
[Model.State.notRecording, .recording, .paused, .processing],
|
||||||
[false, false, false, true]
|
[false, false, false, true]
|
||||||
@@ -155,7 +197,7 @@ struct RecordingViewModelTests {
|
|||||||
[Model.State.notRecording, .recording, .paused, .processing],
|
[Model.State.notRecording, .recording, .paused, .processing],
|
||||||
[false, false, true, true]
|
[false, false, true, true]
|
||||||
))
|
))
|
||||||
func `discard button is visible only while paused`(
|
func `discard button is visible while paused or processing`(
|
||||||
for state: Model.State,
|
for state: Model.State,
|
||||||
expected: Bool
|
expected: Bool
|
||||||
) {
|
) {
|
||||||
@@ -204,7 +246,8 @@ struct RecordingViewModelTests {
|
|||||||
@Suite("Timer")
|
@Suite("Timer")
|
||||||
struct Timer {
|
struct Timer {
|
||||||
|
|
||||||
@Test func `ticks while recording`() async throws {
|
@Test
|
||||||
|
func `ticks while recording`() async throws {
|
||||||
let model = Model()
|
let model = Model()
|
||||||
|
|
||||||
model.pressedMain()
|
model.pressedMain()
|
||||||
@@ -217,7 +260,8 @@ struct RecordingViewModelTests {
|
|||||||
model.pressedMain()
|
model.pressedMain()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `stops while paused`() async throws {
|
@Test
|
||||||
|
func `stops while paused`() async throws {
|
||||||
let model = Model()
|
let model = Model()
|
||||||
|
|
||||||
model.pressedMain()
|
model.pressedMain()
|
||||||
@@ -234,7 +278,8 @@ struct RecordingViewModelTests {
|
|||||||
#expect(model.elapsedSeconds == secondsWhenPaused)
|
#expect(model.elapsedSeconds == secondsWhenPaused)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `keeps elapsed seconds when resumed`() async throws {
|
@Test
|
||||||
|
func `keeps elapsed seconds when resumed`() async throws {
|
||||||
let model = Model()
|
let model = Model()
|
||||||
|
|
||||||
model.pressedMain()
|
model.pressedMain()
|
||||||
@@ -252,7 +297,8 @@ struct RecordingViewModelTests {
|
|||||||
model.pressedMain()
|
model.pressedMain()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `restarts for a new recording`() async throws {
|
@Test
|
||||||
|
func `restarts for a new recording`() async throws {
|
||||||
let model = Model(
|
let model = Model(
|
||||||
transcribe: TranscribingMock()
|
transcribe: TranscribingMock()
|
||||||
)
|
)
|
||||||
@@ -283,7 +329,8 @@ struct RecordingViewModelTests {
|
|||||||
@Suite("Capturer")
|
@Suite("Capturer")
|
||||||
struct Capturer {
|
struct Capturer {
|
||||||
|
|
||||||
@Test func `starts the capture for a new recording`() async throws {
|
@Test
|
||||||
|
func `starts the capture for a new recording`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
let model = Model(capturer: capturer)
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
@@ -296,7 +343,8 @@ struct RecordingViewModelTests {
|
|||||||
model.pressedMain()
|
model.pressedMain()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `pauses the capture while paused`() async throws {
|
@Test
|
||||||
|
func `pauses the capture while paused`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
let model = Model(capturer: capturer)
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
@@ -308,7 +356,8 @@ struct RecordingViewModelTests {
|
|||||||
#expect(capturer.calls == ["start", "pause"])
|
#expect(capturer.calls == ["start", "pause"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `resumes the capture after a pause`() async throws {
|
@Test
|
||||||
|
func `resumes the capture after a pause`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
let model = Model(capturer: capturer)
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
@@ -323,7 +372,8 @@ struct RecordingViewModelTests {
|
|||||||
model.pressedMain()
|
model.pressedMain()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `stops the capture when the input is sent`() async throws {
|
@Test
|
||||||
|
func `stops the capture when the input is sent`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
let model = Model(
|
let model = Model(
|
||||||
capturer: capturer,
|
capturer: capturer,
|
||||||
@@ -337,7 +387,8 @@ struct RecordingViewModelTests {
|
|||||||
#expect(capturer.calls == ["start", "pause", "stop"])
|
#expect(capturer.calls == ["start", "pause", "stop"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `stops the capture when a paused recording is discarded`() async throws {
|
@Test
|
||||||
|
func `stops the capture when a paused recording is discarded`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
let model = Model(capturer: capturer)
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
@@ -352,7 +403,8 @@ struct RecordingViewModelTests {
|
|||||||
#expect(model.elapsedSeconds == 0)
|
#expect(model.elapsedSeconds == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `serializes the calls when states change rapidly`() async throws {
|
@Test
|
||||||
|
func `serializes the calls when states change rapidly`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
|
|
||||||
capturer.delay = .seconds(0.2)
|
capturer.delay = .seconds(0.2)
|
||||||
@@ -374,7 +426,8 @@ struct RecordingViewModelTests {
|
|||||||
model.pressedMain()
|
model.pressedMain()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `falls back to not recording when the capture fails`() async throws {
|
@Test
|
||||||
|
func `falls back to not recording when the capture fails`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
|
|
||||||
capturer.error = ErrorMock()
|
capturer.error = ErrorMock()
|
||||||
@@ -396,7 +449,8 @@ struct RecordingViewModelTests {
|
|||||||
@Suite("Errors")
|
@Suite("Errors")
|
||||||
struct Errors {
|
struct Errors {
|
||||||
|
|
||||||
@Test func `surfaces a capture failure`() async throws {
|
@Test
|
||||||
|
func `surfaces a capture failure`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
|
|
||||||
capturer.error = ErrorMock()
|
capturer.error = ErrorMock()
|
||||||
@@ -409,9 +463,11 @@ struct RecordingViewModelTests {
|
|||||||
|
|
||||||
#expect(model.error == .captureFailed)
|
#expect(model.error == .captureFailed)
|
||||||
#expect(model.state == .notRecording)
|
#expect(model.state == .notRecording)
|
||||||
|
#expect(model.textAlertMessage == .viewRecordingAlertErrorMessageCapture)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `surfaces a denied microphone permission`() async throws {
|
@Test
|
||||||
|
func `surfaces a denied microphone permission`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
|
|
||||||
capturer.error = AudioCapturingError.permissionNotGranted
|
capturer.error = AudioCapturingError.permissionNotGranted
|
||||||
@@ -423,9 +479,11 @@ struct RecordingViewModelTests {
|
|||||||
try await Task.sleep(for: .seconds(0.1))
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
#expect(model.error == .permissionDenied)
|
#expect(model.error == .permissionDenied)
|
||||||
|
#expect(model.textAlertMessage == .viewRecordingAlertErrorMessagePermission)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `surfaces unavailable speech model assets`() async throws {
|
@Test
|
||||||
|
func `surfaces unavailable speech model assets`() async throws {
|
||||||
let transcriber = TranscribingMock()
|
let transcriber = TranscribingMock()
|
||||||
|
|
||||||
transcriber.error = AudioTranscribingError.assetsNotInstalled
|
transcriber.error = AudioTranscribingError.assetsNotInstalled
|
||||||
@@ -439,10 +497,12 @@ struct RecordingViewModelTests {
|
|||||||
try await Task.sleep(for: .seconds(0.5))
|
try await Task.sleep(for: .seconds(0.5))
|
||||||
|
|
||||||
#expect(model.error == .assetsUnavailable)
|
#expect(model.error == .assetsUnavailable)
|
||||||
|
#expect(model.textAlertMessage == .viewRecordingAlertErrorMessageAssets)
|
||||||
#expect(model.transcription == nil)
|
#expect(model.transcription == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `surfaces a transcription failure`() async throws {
|
@Test
|
||||||
|
func `surfaces a transcription failure`() async throws {
|
||||||
let transcriber = TranscribingMock()
|
let transcriber = TranscribingMock()
|
||||||
|
|
||||||
transcriber.error = ErrorMock()
|
transcriber.error = ErrorMock()
|
||||||
@@ -456,10 +516,12 @@ struct RecordingViewModelTests {
|
|||||||
try await Task.sleep(for: .seconds(0.5))
|
try await Task.sleep(for: .seconds(0.5))
|
||||||
|
|
||||||
#expect(model.error == .transcriptionFailed)
|
#expect(model.error == .transcriptionFailed)
|
||||||
|
#expect(model.textAlertMessage == .viewRecordingAlertErrorMessageTranscription)
|
||||||
#expect(model.transcription == nil)
|
#expect(model.transcription == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `clears the error when dismissed`() async throws {
|
@Test
|
||||||
|
func `clears the error when dismissed`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
|
|
||||||
capturer.error = ErrorMock()
|
capturer.error = ErrorMock()
|
||||||
@@ -475,9 +537,11 @@ struct RecordingViewModelTests {
|
|||||||
model.dismissedError()
|
model.dismissedError()
|
||||||
|
|
||||||
#expect(model.error == nil)
|
#expect(model.error == nil)
|
||||||
|
#expect(model.textAlertMessage == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `clears the error when a new recording starts`() async throws {
|
@Test
|
||||||
|
func `clears the error when a new recording starts`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
|
|
||||||
capturer.error = ErrorMock()
|
capturer.error = ErrorMock()
|
||||||
@@ -506,7 +570,8 @@ struct RecordingViewModelTests {
|
|||||||
@Suite("Interruptions")
|
@Suite("Interruptions")
|
||||||
struct Interruptions {
|
struct Interruptions {
|
||||||
|
|
||||||
@Test func `pauses an ongoing recording when the capture is interrupted`() async throws {
|
@Test
|
||||||
|
func `pauses an ongoing recording when the capture is interrupted`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
let model = Model(capturer: capturer)
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
@@ -522,7 +587,8 @@ struct RecordingViewModelTests {
|
|||||||
#expect(capturer.calls == ["start", "pause"])
|
#expect(capturer.calls == ["start", "pause"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `changes nothing when the capture is interrupted while paused`() async throws {
|
@Test
|
||||||
|
func `changes nothing when the capture is interrupted while paused`() async throws {
|
||||||
let capturer = CapturingMock()
|
let capturer = CapturingMock()
|
||||||
let model = Model(capturer: capturer)
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
@@ -539,6 +605,68 @@ struct RecordingViewModelTests {
|
|||||||
#expect(capturer.calls == ["start", "pause"])
|
#expect(capturer.calls == ["start", "pause"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `resumes an interrupted recording when the interruption ends with the resume hint`() async throws {
|
||||||
|
let capturer = CapturingMock()
|
||||||
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
|
model.pressedMain()
|
||||||
|
|
||||||
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
|
capturer.interrupt()
|
||||||
|
|
||||||
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
|
capturer.endInterruption(shouldResume: true)
|
||||||
|
|
||||||
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
|
#expect(model.state == .recording)
|
||||||
|
#expect(capturer.calls == ["start", "pause", "resume"])
|
||||||
|
|
||||||
|
model.pressedMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `stays paused when the interruption ends without the resume hint`() async throws {
|
||||||
|
let capturer = CapturingMock()
|
||||||
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
|
model.pressedMain()
|
||||||
|
|
||||||
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
|
capturer.interrupt()
|
||||||
|
|
||||||
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
|
capturer.endInterruption(shouldResume: false)
|
||||||
|
|
||||||
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
|
#expect(model.state == .paused)
|
||||||
|
#expect(capturer.calls == ["start", "pause"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `never resumes a recording the user paused`() async throws {
|
||||||
|
let capturer = CapturingMock()
|
||||||
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
|
model.pressedMain()
|
||||||
|
model.pressedMain()
|
||||||
|
|
||||||
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
|
capturer.endInterruption(shouldResume: true)
|
||||||
|
|
||||||
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
|
#expect(model.state == .paused)
|
||||||
|
#expect(capturer.calls == ["start", "pause"])
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Processing
|
// MARK: Processing
|
||||||
@@ -547,7 +675,8 @@ struct RecordingViewModelTests {
|
|||||||
@Suite("Processing")
|
@Suite("Processing")
|
||||||
struct Processing {
|
struct Processing {
|
||||||
|
|
||||||
@Test func `returns to not recording with a reset timer and a transcription`() async throws {
|
@Test
|
||||||
|
func `returns to not recording with a reset timer and a transcription`() async throws {
|
||||||
let model = Model(
|
let model = Model(
|
||||||
transcribe: TranscribingMock()
|
transcribe: TranscribingMock()
|
||||||
)
|
)
|
||||||
@@ -569,7 +698,8 @@ struct RecordingViewModelTests {
|
|||||||
#expect(model.transcription != nil)
|
#expect(model.transcription != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `clears the transcription when the transcriber fails`() async throws {
|
@Test
|
||||||
|
func `clears the transcription when the transcriber fails`() async throws {
|
||||||
let transcriber = TranscribingMock()
|
let transcriber = TranscribingMock()
|
||||||
|
|
||||||
transcriber.error = ErrorMock()
|
transcriber.error = ErrorMock()
|
||||||
@@ -587,7 +717,34 @@ struct RecordingViewModelTests {
|
|||||||
#expect(model.transcription == nil)
|
#expect(model.transcription == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `clears the transcription when a new recording starts`() async throws {
|
@Test
|
||||||
|
func `discarding while processing cancels the transcription`() async throws {
|
||||||
|
let capturer = CapturingMock()
|
||||||
|
let transcriber = TranscribingMock()
|
||||||
|
|
||||||
|
transcriber.delay = .seconds(0.5)
|
||||||
|
|
||||||
|
let model = Model(
|
||||||
|
capturer: capturer,
|
||||||
|
transcribe: transcriber
|
||||||
|
)
|
||||||
|
|
||||||
|
model.drive(to: .processing)
|
||||||
|
|
||||||
|
try await Task.sleep(for: .seconds(0.2))
|
||||||
|
|
||||||
|
model.pressedDiscard()
|
||||||
|
|
||||||
|
try await Task.sleep(for: .seconds(0.6))
|
||||||
|
|
||||||
|
#expect(model.state == .notRecording)
|
||||||
|
#expect(model.elapsedSeconds == 0)
|
||||||
|
#expect(model.error == nil)
|
||||||
|
#expect(model.transcription == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `clears the transcription when a new recording starts`() async throws {
|
||||||
let model = Model(
|
let model = Model(
|
||||||
transcribe: TranscribingMock()
|
transcribe: TranscribingMock()
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user