Tests for audio records screen

This commit is contained in:
Selim Mustafaev 2025-04-05 20:17:06 +03:00
parent 65de09845e
commit 6ce8c31635
8 changed files with 389 additions and 46 deletions

View File

@ -13,6 +13,7 @@ struct AudioRecordView: View {
let record: AudioRecordViewModel let record: AudioRecordViewModel
let progress: Double let progress: Double
let onPlay: () -> Void
var body: some View { var body: some View {
@ -37,7 +38,7 @@ struct AudioRecordView: View {
var playButton: some View { var playButton: some View {
Button { Button {
record.onPlay() onPlay()
} label: { } label: {
Image(systemName: record.isPlaying ? "pause.fill" : "play.fill") Image(systemName: record.isPlaying ? "pause.fill" : "play.fill")
.padding() .padding()
@ -71,7 +72,8 @@ struct AudioRecordView: View {
raw: "бла-бла", raw: "бла-бла",
duration: 145, duration: 145,
event: nil event: nil
), onPlay: {}), )),
progress: 0.5 progress: 0.5,
onPlay: {}
) )
} }

View File

@ -16,10 +16,8 @@ struct AudioRecordViewModel: Identifiable {
var duration: String? var duration: String?
var number: String? var number: String?
var date: String var date: String
var onPlay: () -> Void
var url: URL?
init(dto: AudioRecordDto, isPlaying: Bool = false, onPlay: @escaping () -> Void) { init(dto: AudioRecordDto, isPlaying: Bool = false) {
self.id = dto.id self.id = dto.id
self.duration = Formatters.time.string(from: dto.duration) self.duration = Formatters.time.string(from: dto.duration)
@ -28,7 +26,5 @@ struct AudioRecordViewModel: Identifiable {
from: Date(timeIntervalSince1970: dto.addedDate) from: Date(timeIntervalSince1970: dto.addedDate)
) )
self.isPlaying = isPlaying self.isPlaying = isPlaying
self.onPlay = onPlay
self.url = try? FileManager.default.url(for: dto.path, in: Constants.audioRecordsFolder)
} }
} }

View File

@ -18,14 +18,28 @@ struct RecordsScreen: View {
@State var selectedRecordId: String = "" @State var selectedRecordId: String = ""
var body: some View { var body: some View {
List(viewModel.recordModels) { record in List {
AudioRecordView(record: record, progress: viewModel.progress) ForEach(viewModel.recordSections) { section in
Section(header: Text(section.header)) {
ForEach(section.elements) { model in
AudioRecordView(
record: .init(
dto: model,
isPlaying: model.id == viewModel.playingRecord?.id
),
progress: viewModel.progress
) {
viewModel.onPlayTapped(record: model)
}
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
.swipeActions(allowsFullSwipe: false) { .swipeActions(allowsFullSwipe: false) {
makeActions(for: record) makeActions(for: model)
} }
.contextMenu { .contextMenu {
makeActions(for: record, useLabels: true) makeActions(for: model, useLabels: true)
}
}
}
} }
} }
.listStyle(.inset) .listStyle(.inset)
@ -61,7 +75,7 @@ struct RecordsScreen: View {
} }
@ViewBuilder @ViewBuilder
func makeActions(for record: AudioRecordViewModel, useLabels: Bool = false) -> some View { func makeActions(for record: AudioRecordDto, useLabels: Bool = false) -> some View {
if useLabels { if useLabels {
makeMenuActions(for: record) makeMenuActions(for: record)
@ -87,22 +101,22 @@ struct RecordsScreen: View {
} }
@ViewBuilder @ViewBuilder
func makeMenuActions(for record: AudioRecordViewModel) -> some View { func makeMenuActions(for record: AudioRecordDto) -> some View {
Button { Button {
viewModel.showRawRecognizedText(id: record.id) viewModel.showRawRecognizedText(record)
} label: { } label: {
Label("Show recognized text", systemImage: "textformat") Label("Show recognized text", systemImage: "textformat")
} }
Button { Button {
viewModel.showOnMap(id: record.id) viewModel.showOnMap(record)
} label: { } label: {
Label("Show on map", systemImage: "map") Label("Show on map", systemImage: "map")
} }
Button { Button {
viewModel.check(id: record.id) viewModel.check(record)
} label: { } label: {
Label("Check", systemImage: "eye") Label("Check", systemImage: "eye")
} }

View File

@ -25,15 +25,9 @@ final class RecordsViewModel: ACHudContainer {
var playingRecord: AudioRecordDto? var playingRecord: AudioRecordDto?
var progress: Double = 0 var progress: Double = 0
var recordModels: [AudioRecordViewModel] { var recordSections: [DateSection<AudioRecordDto>] {
return records.map { record in
AudioRecordViewModel( records.groupedByDate(type: .updatedDate)
dto: record,
isPlaying: record.id == playingRecord?.id
) { [weak self] in
self?.onPlayTapped(record: record)
}
}
} }
init(recordService: VehicleRecordServiceProtocol, init(recordService: VehicleRecordServiceProtocol,
@ -78,13 +72,11 @@ final class RecordsViewModel: ACHudContainer {
do { do {
try recordPlayer.play(record: record, onStop: { [weak self] dto, error in try recordPlayer.play(record: record, onStop: { [weak self] dto, error in
self?.playingRecord = nil self?.playingRecord = nil
//self?.progress = 0
if let error { if let error {
self?.hud = .error(error) self?.hud = .error(error)
} }
}, onProgress: { [weak self] progress in }, onProgress: { [weak self] progress in
self?.progress = progress self?.progress = progress
print("==== progress: \(progress)")
}) })
if playingRecord != record { if playingRecord != record {
@ -115,28 +107,21 @@ final class RecordsViewModel: ACHudContainer {
} }
} }
func showRawRecognizedText(id: String) { func showRawRecognizedText(_ record: AudioRecordDto) {
guard let record = records.first(where: { $0.id == id }) else {
return
}
hud = .message(record.rawText) hud = .message(record.rawText)
} }
func showOnMap(id: String) { func showOnMap(_ record: AudioRecordDto) {
guard let record = records.first(where: { $0.id == id }), guard let event = record.event else {
let event = record.event
else {
return return
} }
coordinator?.showOnMap(event: event) coordinator?.showOnMap(event: event)
} }
func check(id: String) { func check(_ record: AudioRecordDto) {
guard let record = records.first(where: { $0.id == id }), guard let number = record.number else {
let number = record.number
else {
return return
} }

View File

@ -17,6 +17,10 @@ public struct AudioRecordDto: Decodable, Sendable, Equatable {
public var duration: TimeInterval = 0 public var duration: TimeInterval = 0
public var event: VehicleEventDto? public var event: VehicleEventDto?
public var url: URL? {
try? FileManager.default.url(for: path, in: Constants.audioRecordsFolder)
}
public init( public init(
path: String, path: String,
number: String?, number: String?,

View File

@ -6,8 +6,9 @@
// Copyright © 2025 Selim Mustafaev. All rights reserved. // Copyright © 2025 Selim Mustafaev. All rights reserved.
// //
import Foundation import Mockable
@Mockable
public protocol RecordPlayerServiceProtocol { public protocol RecordPlayerServiceProtocol {
var currentPlayingId: String? { get } var currentPlayingId: String? { get }

View File

@ -6,8 +6,9 @@
// Copyright © 2025 Selim Mustafaev. All rights reserved. // Copyright © 2025 Selim Mustafaev. All rights reserved.
// //
import Foundation import Mockable
@Mockable
public protocol VehicleRecordServiceProtocol: Sendable { public protocol VehicleRecordServiceProtocol: Sendable {
func requestPermissionsIfNeeded() async func requestPermissionsIfNeeded() async

View File

@ -0,0 +1,340 @@
//
// AudioRecordsTests.swift
// AutoCatTests
//
// Created by Selim Mustafaev on 05.04.2025.
// Copyright © 2025 Selim Mustafaev. All rights reserved.
//
import Testing
import Mockable
import AutoCatCore
@testable import AutoCat
@MainActor
struct AudioRecordsTests {
let recordServiceMock = MockVehicleRecordServiceProtocol()
let storageServiceMock = MockStorageServiceProtocol()
let recordPlayerMock = MockRecordPlayerServiceProtocol()
let viewModel: RecordsViewModel
init() async throws {
viewModel = .init(
recordService: recordServiceMock,
storageService: storageServiceMock,
recordPlayer: recordPlayerMock
)
}
@Test("Initial load")
func initialLoad() async throws {
given(storageServiceMock)
.loadRecords()
.willReturn([AudioRecordDto.default])
given(recordServiceMock)
.requestPermissionsIfNeeded()
.willReturn()
await viewModel.onAppear()
verify(storageServiceMock)
.loadRecords()
.called(.once)
verify(recordServiceMock)
.requestPermissionsIfNeeded()
.called(.once)
#expect(viewModel.records.count == 1)
#expect(viewModel.hud == nil)
}
@Test("Initial load error")
func initialLoadError() async throws {
given(storageServiceMock)
.loadRecords()
.willThrow(TestError.generic)
given(recordServiceMock)
.requestPermissionsIfNeeded()
.willReturn()
await viewModel.onAppear()
verify(storageServiceMock)
.loadRecords()
.called(.once)
verify(recordServiceMock)
.requestPermissionsIfNeeded()
.called(.once)
#expect(viewModel.records.isEmpty)
#expect(viewModel.hud == .error(TestError.generic))
}
@Test("Start recording")
func startRecording() async throws {
given(recordServiceMock)
.startRecording()
.willReturn()
await viewModel.startRecording()
verify(recordServiceMock)
.startRecording()
.called(.once)
#expect(viewModel.showRecordingAlert == true)
#expect(viewModel.hud == nil)
}
@Test("Start recording error")
func startRecordingError() async throws {
given(recordServiceMock)
.startRecording()
.willThrow(TestError.generic)
await viewModel.startRecording()
verify(recordServiceMock)
.startRecording()
.called(.once)
#expect(viewModel.showRecordingAlert == false)
#expect(viewModel.hud == .error(TestError.generic))
}
@Test("Stop recording")
func stopRecording() async throws {
given(recordServiceMock)
.stopRecording()
.willReturn(.default)
given(storageServiceMock)
.add(record: .any)
.willReturn()
given(storageServiceMock)
.loadRecords()
.willReturn([.default])
await viewModel.stopRecording()
verify(recordServiceMock)
.stopRecording()
.called(.once)
verify(storageServiceMock)
.add(record: .any)
.called(.once)
verify(storageServiceMock)
.loadRecords()
.called(.once)
#expect(viewModel.records.count == 1)
#expect(viewModel.hud == nil)
}
@Test("Stop recording (recording error)")
func stopRecordingError() async throws {
given(recordServiceMock)
.stopRecording()
.willThrow(TestError.generic)
await viewModel.stopRecording()
verify(recordServiceMock)
.stopRecording()
.called(.once)
verify(storageServiceMock)
.add(record: .any)
.called(.never)
verify(storageServiceMock)
.loadRecords()
.called(.never)
#expect(viewModel.records.isEmpty)
#expect(viewModel.hud == .error(TestError.generic))
}
@Test("Stop recording (add error)")
func stopRecordingAddError() async throws {
given(recordServiceMock)
.stopRecording()
.willReturn(.default)
given(storageServiceMock)
.add(record: .any)
.willThrow(TestError.generic)
await viewModel.stopRecording()
verify(recordServiceMock)
.stopRecording()
.called(.once)
verify(storageServiceMock)
.add(record: .any)
.called(.once)
verify(storageServiceMock)
.loadRecords()
.called(.never)
#expect(viewModel.records.isEmpty)
#expect(viewModel.hud == .error(TestError.generic))
}
@Test("Stop recording (load error)")
func stopRecordingLoadError() async throws {
given(recordServiceMock)
.stopRecording()
.willReturn(.default)
given(storageServiceMock)
.add(record: .any)
.willReturn()
given(storageServiceMock)
.loadRecords()
.willThrow(TestError.generic)
await viewModel.stopRecording()
verify(recordServiceMock)
.stopRecording()
.called(.once)
verify(storageServiceMock)
.add(record: .any)
.called(.once)
verify(storageServiceMock)
.loadRecords()
.called(.once)
#expect(viewModel.records.isEmpty)
#expect(viewModel.hud == .error(TestError.generic))
}
@Test("Cancel recording")
func cancelRecording() async throws {
given(recordServiceMock)
.cancelRecording()
.willReturn()
await viewModel.cancelRecording()
verify(recordServiceMock)
.cancelRecording()
.called(.once)
}
@Test("Delete record")
func deleteRecord() async throws {
viewModel.records = [.default]
given(storageServiceMock)
.deleteRecord(id: .any)
.willReturn()
await viewModel.deleteRecord(id: AudioRecordDto.default.id)
verify(storageServiceMock)
.deleteRecord(id: .any)
.called(.once)
#expect(viewModel.records.isEmpty)
#expect(viewModel.hud == nil)
}
@Test("Delete record (error)")
func deleteRecordError() async throws {
viewModel.records = [.default]
given(storageServiceMock)
.deleteRecord(id: .any)
.willThrow(TestError.generic)
await viewModel.deleteRecord(id: AudioRecordDto.default.id)
verify(storageServiceMock)
.deleteRecord(id: .any)
.called(.once)
#expect(viewModel.records.count == 1)
#expect(viewModel.hud == .error(TestError.generic))
}
@Test("Edit record")
func editRecord() async throws {
var editedRecord: AudioRecordDto = .default
editedRecord.number = "123"
viewModel.records = [.default]
given(storageServiceMock)
.updateRecord(id: .any, number: .any)
.willReturn(editedRecord)
await viewModel.editRecord(id: AudioRecordDto.default.id, number: "123")
verify(storageServiceMock)
.updateRecord(id: .any, number: .any)
.called(.once)
#expect(viewModel.records == [editedRecord])
#expect(viewModel.hud == nil)
}
@Test("Edit record (error)")
func editRecordError() async throws {
let record: AudioRecordDto = .default
viewModel.records = [record]
given(storageServiceMock)
.updateRecord(id: .any, number: .any)
.willThrow(TestError.generic)
await viewModel.editRecord(id: AudioRecordDto.default.id, number: "123")
verify(storageServiceMock)
.updateRecord(id: .any, number: .any)
.called(.once)
#expect(viewModel.records == [record])
#expect(viewModel.hud == .error(TestError.generic))
}
@Test("Show recognized text")
func showRecognizedText() async throws {
var record: AudioRecordDto = .default
record.rawText = "Test text"
viewModel.showRawRecognizedText(record)
#expect(viewModel.hud == .message("Test text"))
}
}