Implemented the RecordingViewModel view model in the Recording package target.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
extension 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
|
||||
/// elapsed recording time, and processes the recorded input once it is sent.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Model {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The current state of the recording flow.
|
||||
var state: State = .notRecording
|
||||
|
||||
/// The number of seconds spent recording, excluding any time spent paused.
|
||||
private(set) var secondsElapsed: Int = 0
|
||||
|
||||
/// The task that increments ``secondsElapsed`` every second while recording.
|
||||
@ObservationIgnored
|
||||
private var timerTask: Task<Void, Never>?
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The image resource for the main button: a pause icon while recording, a record icon otherwise.
|
||||
var iconMain: ImageResource {
|
||||
switch state {
|
||||
case .notRecording, .paused, .processing: .Icon.record
|
||||
case .recording: .Icon.pause
|
||||
}
|
||||
}
|
||||
|
||||
/// The image resource for the send button while paused, or `nil` when the button should show a progress indicator instead.
|
||||
var iconSend: ImageResource? {
|
||||
switch state {
|
||||
case .paused: .Icon.send
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the recorded input is currently being processed.
|
||||
var isProcessing: Bool {
|
||||
state == .processing
|
||||
}
|
||||
|
||||
/// Whether the main button should be disabled.
|
||||
var shouldDisableMain: Bool {
|
||||
state == .processing
|
||||
}
|
||||
|
||||
/// Whether the send button should be visible.
|
||||
var shouldShowSend: Bool {
|
||||
state != .notRecording
|
||||
&& state != .recording
|
||||
}
|
||||
|
||||
/// Whether the timer label should be visible.
|
||||
var shouldShowTimer: Bool {
|
||||
state != .notRecording
|
||||
}
|
||||
|
||||
/// The elapsed recording time, formatted as `mm:ss` for the timer label.
|
||||
var timerText: String {
|
||||
Duration
|
||||
.seconds(secondsElapsed)
|
||||
.formatted(.time(pattern: .minuteSecond(padMinuteToLength: 2)))
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Handles a press of the main button.
|
||||
///
|
||||
/// Starts a recording when idle, pauses an ongoing recording, or resumes
|
||||
/// a paused one. Does nothing while the input is being processed.
|
||||
func pressedMain() {
|
||||
guard state != .processing else {
|
||||
return
|
||||
}
|
||||
|
||||
switch state {
|
||||
case .notRecording:
|
||||
state = .recording
|
||||
case .recording:
|
||||
state = .paused
|
||||
case .paused:
|
||||
state = .recording
|
||||
case .processing:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a press of the send button.
|
||||
///
|
||||
/// Moves a paused recording into processing. Does nothing in any other state.
|
||||
func pressedSend() {
|
||||
guard state == .paused else {
|
||||
return
|
||||
}
|
||||
|
||||
switch state {
|
||||
case .paused:
|
||||
state = .processing
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Reacts to a change of ``state``, expected to be called from the view whenever it observes one.
|
||||
///
|
||||
/// Starts the timer when a recording begins or resumes, stops it in every other state, and kicks off ``processInput()`` when the input is sent.
|
||||
///
|
||||
/// - Parameter shouldRestartTimer: Whether ``secondsElapsed`` should be reset to zero before the timer starts, which is the case for
|
||||
/// a new recording as opposed to one resuming from a pause.
|
||||
func updatedState(
|
||||
shouldRestartTimer: Bool
|
||||
) {
|
||||
switch state {
|
||||
case .recording:
|
||||
startTimer(shouldRestartTimer)
|
||||
default:
|
||||
stopTimer()
|
||||
|
||||
if state == .processing {
|
||||
Task {
|
||||
await processInput()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension RecordingView.Model {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Processes the recorded input, returning the model to the not-recording state when finished.
|
||||
///
|
||||
/// Currently a placeholder that simulates the work with a five-second delay.
|
||||
func processInput() async {
|
||||
guard state == .processing else {
|
||||
return
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
|
||||
state = .notRecording
|
||||
}
|
||||
|
||||
/// Starts the timer task, which increments ``secondsElapsed`` once per second until it is cancelled. Any previously running timer task is cancelled first.
|
||||
///
|
||||
/// - Parameter shouldRestartTimer: Whether ``secondsElapsed`` should be reset to zero before the timer starts.
|
||||
func startTimer(
|
||||
_ shouldRestartTimer: Bool
|
||||
) {
|
||||
if shouldRestartTimer {
|
||||
secondsElapsed = 0
|
||||
}
|
||||
|
||||
timerTask?.cancel()
|
||||
timerTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
|
||||
guard let self, !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
self.secondsElapsed += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the timer task, if any, keeping ``secondsElapsed`` at its current value.
|
||||
func stopTimer() {
|
||||
timerTask?.cancel()
|
||||
timerTask = nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - States
|
||||
|
||||
extension RecordingView.Model {
|
||||
/// The states of the recording flow.
|
||||
enum State {
|
||||
/// No recording is in progress.
|
||||
case notRecording
|
||||
/// A recording is in progress and the timer is running.
|
||||
case recording
|
||||
/// The recording is paused, and can be either resumed or sent.
|
||||
case paused
|
||||
/// The recorded input is being processed.
|
||||
case processing
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import Testing
|
||||
|
||||
@testable import Recording
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
// Swift Testing Documentation
|
||||
// https://developer.apple.com/documentation/testing
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
|
||||
@testable import Recording
|
||||
|
||||
@Suite("Recording view model")
|
||||
struct RecordingViewModelTests {
|
||||
|
||||
typealias Model = RecordingView.Model
|
||||
|
||||
// MARK: Initial state
|
||||
|
||||
@MainActor
|
||||
@Suite("Initial state")
|
||||
struct InitialState {
|
||||
|
||||
@Test func `initial state is not recording with a zeroed timer`() {
|
||||
let model = Model()
|
||||
|
||||
#expect(model.state == .notRecording)
|
||||
#expect(model.secondsElapsed == 0)
|
||||
#expect(model.timerText == "00:00")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Button presses
|
||||
|
||||
@MainActor
|
||||
@Suite("Button presses")
|
||||
struct ButtonPresses {
|
||||
|
||||
@Test(arguments: zip(
|
||||
[Model.State.notRecording, .recording, .paused, .processing],
|
||||
[Model.State.recording, .paused, .recording, .processing]
|
||||
))
|
||||
func `pressed main transitions to the expected state`(
|
||||
from initial: Model.State,
|
||||
to expected: Model.State
|
||||
) {
|
||||
let model = Model()
|
||||
|
||||
model.state = initial
|
||||
|
||||
model.pressedMain()
|
||||
|
||||
#expect(model.state == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
[Model.State.notRecording, .recording, .paused, .processing],
|
||||
[Model.State.notRecording, .recording, .processing, .processing]
|
||||
))
|
||||
func `pressed send transitions to the expected state`(
|
||||
from initial: Model.State,
|
||||
to expected: Model.State
|
||||
) {
|
||||
let model = Model()
|
||||
|
||||
model.state = initial
|
||||
|
||||
model.pressedSend()
|
||||
|
||||
#expect(model.state == expected)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Computed properties
|
||||
|
||||
@MainActor
|
||||
@Suite("Computed properties")
|
||||
struct ComputedProperties {
|
||||
|
||||
@Test(arguments: zip(
|
||||
[Model.State.notRecording, .recording, .paused, .processing],
|
||||
[ImageResource.Icon.record, .Icon.pause, .Icon.record, .Icon.record]
|
||||
))
|
||||
func `main icon matches the state`(
|
||||
for state: Model.State,
|
||||
expected: ImageResource
|
||||
) {
|
||||
let model = Model()
|
||||
|
||||
model.state = state
|
||||
|
||||
#expect(model.iconMain == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
[Model.State.notRecording, .recording, .paused, .processing],
|
||||
[nil, nil, ImageResource.Icon.send, nil]
|
||||
))
|
||||
func `send icon matches the state`(
|
||||
for state: Model.State,
|
||||
expected: ImageResource?
|
||||
) {
|
||||
let model = Model()
|
||||
|
||||
model.state = state
|
||||
|
||||
#expect(model.iconSend == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
[Model.State.notRecording, .recording, .paused, .processing],
|
||||
[false, false, false, true]
|
||||
))
|
||||
func `is processing only while processing`(
|
||||
for state: Model.State,
|
||||
expected: Bool
|
||||
) {
|
||||
let model = Model()
|
||||
|
||||
model.state = state
|
||||
|
||||
#expect(model.isProcessing == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
[Model.State.notRecording, .recording, .paused, .processing],
|
||||
[false, false, false, true]
|
||||
))
|
||||
func `main button is disabled only while processing`(
|
||||
for state: Model.State,
|
||||
expected: Bool
|
||||
) {
|
||||
let model = Model()
|
||||
|
||||
model.state = state
|
||||
|
||||
#expect(model.shouldDisableMain == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
[Model.State.notRecording, .recording, .paused, .processing],
|
||||
[false, false, true, true]
|
||||
))
|
||||
func `send button is visible while paused or processing`(
|
||||
for state: Model.State,
|
||||
expected: Bool
|
||||
) {
|
||||
let model = Model()
|
||||
|
||||
model.state = state
|
||||
|
||||
#expect(model.shouldShowSend == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
[Model.State.notRecording, .recording, .paused, .processing],
|
||||
[false, true, true, true]
|
||||
))
|
||||
func `timer is visible unless not recording`(
|
||||
for state: Model.State,
|
||||
expected: Bool
|
||||
) {
|
||||
let model = Model()
|
||||
|
||||
model.state = state
|
||||
|
||||
#expect(model.shouldShowTimer == expected)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Timer
|
||||
|
||||
@MainActor
|
||||
@Suite("Timer")
|
||||
struct Timer {
|
||||
|
||||
@Test func `ticks while recording`() async throws {
|
||||
let model = Model()
|
||||
|
||||
model.state = .recording
|
||||
model.updatedState(shouldRestartTimer: true)
|
||||
|
||||
try await Task.sleep(for: .seconds(1.2))
|
||||
|
||||
#expect(model.secondsElapsed >= 1)
|
||||
#expect(model.timerText == "00:01")
|
||||
|
||||
model.state = .notRecording
|
||||
model.updatedState(shouldRestartTimer: false)
|
||||
}
|
||||
|
||||
@Test func `stops while paused`() async throws {
|
||||
let model = Model()
|
||||
|
||||
model.state = .recording
|
||||
model.updatedState(shouldRestartTimer: true)
|
||||
|
||||
try await Task.sleep(for: .seconds(1.2))
|
||||
|
||||
model.state = .paused
|
||||
model.updatedState(shouldRestartTimer: false)
|
||||
|
||||
let secondsWhenPaused = model.secondsElapsed
|
||||
|
||||
try await Task.sleep(for: .seconds(1.2))
|
||||
|
||||
#expect(secondsWhenPaused >= 1)
|
||||
#expect(model.secondsElapsed == secondsWhenPaused)
|
||||
}
|
||||
|
||||
@Test func `keeps elapsed seconds when resumed`() async throws {
|
||||
let model = Model()
|
||||
|
||||
model.state = .recording
|
||||
model.updatedState(shouldRestartTimer: true)
|
||||
|
||||
try await Task.sleep(for: .seconds(1.2))
|
||||
|
||||
model.state = .paused
|
||||
model.updatedState(shouldRestartTimer: false)
|
||||
|
||||
let secondsWhenPaused = model.secondsElapsed
|
||||
|
||||
model.state = .recording
|
||||
model.updatedState(shouldRestartTimer: false)
|
||||
|
||||
#expect(model.secondsElapsed == secondsWhenPaused)
|
||||
|
||||
model.state = .notRecording
|
||||
model.updatedState(shouldRestartTimer: false)
|
||||
}
|
||||
|
||||
@Test func `restarts for a new recording`() async throws {
|
||||
let model = Model()
|
||||
|
||||
model.state = .recording
|
||||
model.updatedState(shouldRestartTimer: true)
|
||||
|
||||
try await Task.sleep(for: .seconds(1.2))
|
||||
|
||||
model.state = .paused
|
||||
model.updatedState(shouldRestartTimer: false)
|
||||
|
||||
model.state = .recording
|
||||
model.updatedState(shouldRestartTimer: true)
|
||||
|
||||
#expect(model.secondsElapsed == 0)
|
||||
|
||||
model.state = .notRecording
|
||||
model.updatedState(shouldRestartTimer: false)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Processing
|
||||
|
||||
@MainActor
|
||||
@Suite("Processing")
|
||||
struct Processing {
|
||||
|
||||
@Test func `returns to not recording`() async throws {
|
||||
let model = Model()
|
||||
|
||||
model.state = .processing
|
||||
model.updatedState(shouldRestartTimer: false)
|
||||
|
||||
try await Task.sleep(for: .seconds(2.5))
|
||||
|
||||
#expect(model.state == .notRecording)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user