From 6ce8c31635b2a12ff7925b2558f89204a1c8bccc Mon Sep 17 00:00:00 2001 From: Selim Mustafaev Date: Sat, 5 Apr 2025 20:17:06 +0300 Subject: [PATCH] Tests for audio records screen --- .../AudioRecordView/AudioRecordView.swift | 8 +- .../AudioRecordViewModel.swift | 6 +- .../Screens/RecordsScreen/RecordsScreen.swift | 40 ++- .../RecordsScreen/RecordsViewModel.swift | 31 +- AutoCatCore/Models/DTO/AudioRecordDto.swift | 4 + .../RecordPlayerServiceProtocol.swift | 3 +- .../VehicleRecordServiceProtocol.swift | 3 +- AutoCatTests/AudioRecordsTests.swift | 340 ++++++++++++++++++ 8 files changed, 389 insertions(+), 46 deletions(-) create mode 100644 AutoCatTests/AudioRecordsTests.swift diff --git a/AutoCat/Screens/RecordsScreen/AudioRecordView/AudioRecordView.swift b/AutoCat/Screens/RecordsScreen/AudioRecordView/AudioRecordView.swift index d5cdcb3..c64e2ea 100644 --- a/AutoCat/Screens/RecordsScreen/AudioRecordView/AudioRecordView.swift +++ b/AutoCat/Screens/RecordsScreen/AudioRecordView/AudioRecordView.swift @@ -13,6 +13,7 @@ struct AudioRecordView: View { let record: AudioRecordViewModel let progress: Double + let onPlay: () -> Void var body: some View { @@ -37,7 +38,7 @@ struct AudioRecordView: View { var playButton: some View { Button { - record.onPlay() + onPlay() } label: { Image(systemName: record.isPlaying ? "pause.fill" : "play.fill") .padding() @@ -71,7 +72,8 @@ struct AudioRecordView: View { raw: "бла-бла", duration: 145, event: nil - ), onPlay: {}), - progress: 0.5 + )), + progress: 0.5, + onPlay: {} ) } diff --git a/AutoCat/Screens/RecordsScreen/AudioRecordView/AudioRecordViewModel.swift b/AutoCat/Screens/RecordsScreen/AudioRecordView/AudioRecordViewModel.swift index 360e7bb..1d75f80 100644 --- a/AutoCat/Screens/RecordsScreen/AudioRecordView/AudioRecordViewModel.swift +++ b/AutoCat/Screens/RecordsScreen/AudioRecordView/AudioRecordViewModel.swift @@ -16,10 +16,8 @@ struct AudioRecordViewModel: Identifiable { var duration: String? var number: 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.duration = Formatters.time.string(from: dto.duration) @@ -28,7 +26,5 @@ struct AudioRecordViewModel: Identifiable { from: Date(timeIntervalSince1970: dto.addedDate) ) self.isPlaying = isPlaying - self.onPlay = onPlay - self.url = try? FileManager.default.url(for: dto.path, in: Constants.audioRecordsFolder) } } diff --git a/AutoCat/Screens/RecordsScreen/RecordsScreen.swift b/AutoCat/Screens/RecordsScreen/RecordsScreen.swift index 919439f..d9c032d 100644 --- a/AutoCat/Screens/RecordsScreen/RecordsScreen.swift +++ b/AutoCat/Screens/RecordsScreen/RecordsScreen.swift @@ -18,15 +18,29 @@ struct RecordsScreen: View { @State var selectedRecordId: String = "" var body: some View { - List(viewModel.recordModels) { record in - AudioRecordView(record: record, progress: viewModel.progress) - .listRowInsets(EdgeInsets()) - .swipeActions(allowsFullSwipe: false) { - makeActions(for: record) - } - .contextMenu { - makeActions(for: record, useLabels: true) + List { + 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()) + .swipeActions(allowsFullSwipe: false) { + makeActions(for: model) + } + .contextMenu { + makeActions(for: model, useLabels: true) + } + } } + } } .listStyle(.inset) .hud($viewModel.hud) @@ -61,7 +75,7 @@ struct RecordsScreen: View { } @ViewBuilder - func makeActions(for record: AudioRecordViewModel, useLabels: Bool = false) -> some View { + func makeActions(for record: AudioRecordDto, useLabels: Bool = false) -> some View { if useLabels { makeMenuActions(for: record) @@ -87,22 +101,22 @@ struct RecordsScreen: View { } @ViewBuilder - func makeMenuActions(for record: AudioRecordViewModel) -> some View { + func makeMenuActions(for record: AudioRecordDto) -> some View { Button { - viewModel.showRawRecognizedText(id: record.id) + viewModel.showRawRecognizedText(record) } label: { Label("Show recognized text", systemImage: "textformat") } Button { - viewModel.showOnMap(id: record.id) + viewModel.showOnMap(record) } label: { Label("Show on map", systemImage: "map") } Button { - viewModel.check(id: record.id) + viewModel.check(record) } label: { Label("Check", systemImage: "eye") } diff --git a/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift b/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift index be2f501..d8a719d 100644 --- a/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift +++ b/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift @@ -25,15 +25,9 @@ final class RecordsViewModel: ACHudContainer { var playingRecord: AudioRecordDto? var progress: Double = 0 - var recordModels: [AudioRecordViewModel] { - return records.map { record in - AudioRecordViewModel( - dto: record, - isPlaying: record.id == playingRecord?.id - ) { [weak self] in - self?.onPlayTapped(record: record) - } - } + var recordSections: [DateSection] { + + records.groupedByDate(type: .updatedDate) } init(recordService: VehicleRecordServiceProtocol, @@ -78,13 +72,11 @@ final class RecordsViewModel: ACHudContainer { do { try recordPlayer.play(record: record, onStop: { [weak self] dto, error in self?.playingRecord = nil - //self?.progress = 0 if let error { self?.hud = .error(error) } }, onProgress: { [weak self] progress in self?.progress = progress - print("==== progress: \(progress)") }) if playingRecord != record { @@ -115,28 +107,21 @@ final class RecordsViewModel: ACHudContainer { } } - func showRawRecognizedText(id: String) { - guard let record = records.first(where: { $0.id == id }) else { - return - } + func showRawRecognizedText(_ record: AudioRecordDto) { hud = .message(record.rawText) } - func showOnMap(id: String) { - guard let record = records.first(where: { $0.id == id }), - let event = record.event - else { + func showOnMap(_ record: AudioRecordDto) { + guard let event = record.event else { return } coordinator?.showOnMap(event: event) } - func check(id: String) { - guard let record = records.first(where: { $0.id == id }), - let number = record.number - else { + func check(_ record: AudioRecordDto) { + guard let number = record.number else { return } diff --git a/AutoCatCore/Models/DTO/AudioRecordDto.swift b/AutoCatCore/Models/DTO/AudioRecordDto.swift index d9aa521..3fba8b1 100644 --- a/AutoCatCore/Models/DTO/AudioRecordDto.swift +++ b/AutoCatCore/Models/DTO/AudioRecordDto.swift @@ -17,6 +17,10 @@ public struct AudioRecordDto: Decodable, Sendable, Equatable { public var duration: TimeInterval = 0 public var event: VehicleEventDto? + public var url: URL? { + try? FileManager.default.url(for: path, in: Constants.audioRecordsFolder) + } + public init( path: String, number: String?, diff --git a/AutoCatCore/Services/RecordPlayerService/RecordPlayerServiceProtocol.swift b/AutoCatCore/Services/RecordPlayerService/RecordPlayerServiceProtocol.swift index 62dbf12..d0e5e4a 100644 --- a/AutoCatCore/Services/RecordPlayerService/RecordPlayerServiceProtocol.swift +++ b/AutoCatCore/Services/RecordPlayerService/RecordPlayerServiceProtocol.swift @@ -6,8 +6,9 @@ // Copyright © 2025 Selim Mustafaev. All rights reserved. // -import Foundation +import Mockable +@Mockable public protocol RecordPlayerServiceProtocol { var currentPlayingId: String? { get } diff --git a/AutoCatCore/Services/VehicleRecordService/VehicleRecordServiceProtocol.swift b/AutoCatCore/Services/VehicleRecordService/VehicleRecordServiceProtocol.swift index 344bce7..efb954b 100644 --- a/AutoCatCore/Services/VehicleRecordService/VehicleRecordServiceProtocol.swift +++ b/AutoCatCore/Services/VehicleRecordService/VehicleRecordServiceProtocol.swift @@ -6,8 +6,9 @@ // Copyright © 2025 Selim Mustafaev. All rights reserved. // -import Foundation +import Mockable +@Mockable public protocol VehicleRecordServiceProtocol: Sendable { func requestPermissionsIfNeeded() async diff --git a/AutoCatTests/AudioRecordsTests.swift b/AutoCatTests/AudioRecordsTests.swift new file mode 100644 index 0000000..06e58aa --- /dev/null +++ b/AutoCatTests/AudioRecordsTests.swift @@ -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")) + } +}