diff --git a/Heim/Application/Application/Source/SceneDelegate.swift b/Heim/Application/Application/Source/SceneDelegate.swift index c4047606..07f0da39 100644 --- a/Heim/Application/Application/Source/SceneDelegate.swift +++ b/Heim/Application/Application/Source/SceneDelegate.swift @@ -241,6 +241,10 @@ private extension SceneDelegate { return DefaultSettingCoordinator(navigationController: navigationController) } + DIContainer.shared.register(type: RecordManagerProtocol.self) { _ in + return DefaultRecordManager() + } + DIContainer.shared.register(type: RecordCoordinator.self) { _ in return DefaultRecordCoordinator(navigationController: recordNavigationController) } diff --git a/Heim/Presentation/Presentation.xcodeproj/project.pbxproj b/Heim/Presentation/Presentation.xcodeproj/project.pbxproj index 1a40bc70..04fa9578 100644 --- a/Heim/Presentation/Presentation.xcodeproj/project.pbxproj +++ b/Heim/Presentation/Presentation.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 4093ABB42CDB9CCA00F7E060 /* Domain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B307B4C02CD9B76A001C5040 /* Domain.framework */; }; 4093ABB52CDB9CCA00F7E060 /* Domain.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = B307B4C02CD9B76A001C5040 /* Domain.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 807206302CDB50F100123BFB /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8072062F2CDB50F100123BFB /* SnapKit */; }; + 80920C562D0192050068C81A /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 80920C552D0192050068C81A /* Lottie */; }; B307B3FE2CD9B4B7001C5040 /* Presentation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B307B3F32CD9B4B7001C5040 /* Presentation.framework */; }; /* End PBXBuildFile section */ @@ -71,6 +72,7 @@ buildActionMask = 2147483647; files = ( B307B3FE2CD9B4B7001C5040 /* Presentation.framework in Frameworks */, + 80920C562D0192050068C81A /* Lottie in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -161,6 +163,7 @@ ); name = PresentationTests; packageProductDependencies = ( + 80920C552D0192050068C81A /* Lottie */, ); productName = PresentationTests; productReference = B307B3FD2CD9B4B7001C5040 /* PresentationTests.xctest */; @@ -182,6 +185,7 @@ }; B307B3FC2CD9B4B7001C5040 = { CreatedOnToolsVersion = 16.0; + LastSwiftMigration = 1600; }; }; }; @@ -197,6 +201,7 @@ packageReferences = ( 8072062E2CDB50F100123BFB /* XCRemoteSwiftPackageReference "SnapKit" */, B307B6BC2CDC7D48001C5040 /* XCRemoteSwiftPackageReference "SwiftLint" */, + 80920C542D0192050068C81A /* XCRemoteSwiftPackageReference "lottie-spm" */, ); preferredProjectObjectVersion = 77; productRefGroup = B307B3F42CD9B4B7001C5040 /* Products */; @@ -481,32 +486,45 @@ B307B40C2CD9B4B7001C5040 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = B3PWYBKFUK; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = kr.codesquad.boostcamp9.Heim.PresentationTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; B307B40D2CD9B4B7001C5040 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = B3PWYBKFUK; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = kr.codesquad.boostcamp9.Heim.PresentationTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -551,6 +569,14 @@ minimumVersion = 5.7.1; }; }; + 80920C542D0192050068C81A /* XCRemoteSwiftPackageReference "lottie-spm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/airbnb/lottie-spm.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.5.0; + }; + }; B307B6BC2CDC7D48001C5040 /* XCRemoteSwiftPackageReference "SwiftLint" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/realm/SwiftLint"; @@ -567,6 +593,11 @@ package = 8072062E2CDB50F100123BFB /* XCRemoteSwiftPackageReference "SnapKit" */; productName = SnapKit; }; + 80920C552D0192050068C81A /* Lottie */ = { + isa = XCSwiftPackageProductDependency; + package = 80920C542D0192050068C81A /* XCRemoteSwiftPackageReference "lottie-spm" */; + productName = Lottie; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = B307B3EA2CD9B4B7001C5040 /* Project object */; diff --git a/Heim/Presentation/Presentation.xcodeproj/xcshareddata/xcschemes/Presentation.xcscheme b/Heim/Presentation/Presentation.xcodeproj/xcshareddata/xcschemes/Presentation.xcscheme new file mode 100644 index 00000000..9d78b3ec --- /dev/null +++ b/Heim/Presentation/Presentation.xcodeproj/xcshareddata/xcschemes/Presentation.xcscheme @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Heim/Presentation/Presentation/Record/RecordFeature/Coordinator/RecordCoordinator.swift b/Heim/Presentation/Presentation/Record/RecordFeature/Coordinator/RecordCoordinator.swift index d5ea98f4..2425f42e 100644 --- a/Heim/Presentation/Presentation/Record/RecordFeature/Coordinator/RecordCoordinator.swift +++ b/Heim/Presentation/Presentation/Record/RecordFeature/Coordinator/RecordCoordinator.swift @@ -58,7 +58,11 @@ public final class DefaultRecordCoordinator: RecordCoordinator { // MARK: - Private private extension DefaultRecordCoordinator { func createRecordViewController() -> RecordViewController? { - let viewModel = RecordViewModel() + guard let recordManager = DIContainer.shared.resolve(type: RecordManagerProtocol.self) else { + return nil + } + + let viewModel = RecordViewModel(recordManager: recordManager) let viewController = RecordViewController(viewModel: viewModel) viewController.coordinator = self return viewController diff --git a/Heim/Presentation/Presentation/Record/RecordFeature/Manager/RecordManager.swift b/Heim/Presentation/Presentation/Record/RecordFeature/Manager/RecordManager.swift index 29e61fde..335b10d9 100644 --- a/Heim/Presentation/Presentation/Record/RecordFeature/Manager/RecordManager.swift +++ b/Heim/Presentation/Presentation/Record/RecordFeature/Manager/RecordManager.swift @@ -10,7 +10,19 @@ import Domain import UIKit import Speech -final class RecordManager { +public protocol RecordManagerProtocol { + var recognizedText: String { get } + var minuteAndSeconds: Int { get } + var voice: Voice? { get } + var formattedTime: String { get } + + func setupSpeech() async throws + func startRecording() throws + func stopRecording() + func resetAll() +} + +public final class DefaultRecordManager: RecordManagerProtocol { // MARK: - 음성 인식을 위한 Properties private let speechRecognizer: SFSpeechRecognizer private let audioEngine: AVAudioEngine @@ -18,8 +30,8 @@ final class RecordManager { private var recognitionTask: SFSpeechRecognitionTask? // MARK: - 인식된 텍스트, 경과한 시간 - private(set) var recognizedText: String - private(set) var minuteAndSeconds: Int + private(set) public var recognizedText: String + private(set) public var minuteAndSeconds: Int // MARK: - 음성 녹음을 위한 Properties private var audioRecorder: AVAudioRecorder? @@ -27,15 +39,15 @@ final class RecordManager { private var timer: Timer? // MARK: - 음성 데이터 - private(set) var voice: Voice? + private(set) public var voice: Voice? - var formattedTime: String { + public var formattedTime: String { let minutes = minuteAndSeconds / 60 let seconds = minuteAndSeconds % 60 return String(format: "%02d:%02d", minutes, seconds) } - init(locale: Locale = Locale(identifier: "ko-KR")) { + public init(locale: Locale = Locale(identifier: "ko-KR")) { self.speechRecognizer = SFSpeechRecognizer(locale: locale)! self.audioEngine = AVAudioEngine() self.recognizedText = "" @@ -47,7 +59,7 @@ final class RecordManager { } // MARK: - 녹음과정 준비 - func setupSpeech() async throws { + public func setupSpeech() async throws { let speechStatus = await withCheckedContinuation { continuation in SFSpeechRecognizer.requestAuthorization { status in continuation.resume(returning: status) @@ -68,7 +80,7 @@ final class RecordManager { } } - func startRecording() throws { + public func startRecording() throws { if recognitionRequest == nil { // MARK: - 새로운 녹음 시작 try setupNewRecording() @@ -79,7 +91,7 @@ final class RecordManager { } // MARK: - 일시중지 - func stopRecording() { + public func stopRecording() { audioEngine.pause() audioRecorder?.stop() @@ -93,7 +105,7 @@ final class RecordManager { timer = nil } - func resetAll() { + public func resetAll() { audioEngine.stop() audioEngine.inputNode.removeTap(onBus: 0) @@ -116,7 +128,7 @@ final class RecordManager { } } -private extension RecordManager { +private extension DefaultRecordManager { // MARK: - 녹음 재개 func resumeRecording() throws { do { @@ -139,19 +151,19 @@ private extension RecordManager { // 오디오 세션 설정 let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.playAndRecord, - mode: .default, - options: [.defaultToSpeaker, .allowBluetooth]) + mode: .default, + options: [.defaultToSpeaker, .allowBluetooth]) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) - + // PCM 설정 let settings: [String: Any] = [ - AVFormatIDKey: Int(kAudioFormatLinearPCM), - AVSampleRateKey: 44100.0, - AVNumberOfChannelsKey: 2, // 스테레오로 변경 - AVLinearPCMBitDepthKey: 16, - AVLinearPCMIsFloatKey: false, - AVLinearPCMIsBigEndianKey: false, - AVEncoderAudioQualityKey: AVAudioQuality.max.rawValue + AVFormatIDKey: Int(kAudioFormatLinearPCM), + AVSampleRateKey: 44100.0, + AVNumberOfChannelsKey: 2, // 스테레오로 변경 + AVLinearPCMBitDepthKey: 16, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false, + AVEncoderAudioQualityKey: AVAudioQuality.max.rawValue ] // 기존 파일이 있다면 제거 @@ -219,3 +231,4 @@ private extension RecordManager { } } } + diff --git a/Heim/Presentation/Presentation/Record/RecordFeature/ViewModel/RecordViewModel.swift b/Heim/Presentation/Presentation/Record/RecordFeature/ViewModel/RecordViewModel.swift index 8a07a01f..f085dc81 100644 --- a/Heim/Presentation/Presentation/Record/RecordFeature/ViewModel/RecordViewModel.swift +++ b/Heim/Presentation/Presentation/Record/RecordFeature/ViewModel/RecordViewModel.swift @@ -28,7 +28,7 @@ public final class RecordViewModel: ViewModel { } @Published public var state: State - private var recordManager: RecordManager + private var recordManager: RecordManagerProtocol private var timer: Timer? private var isPaused: Bool = false @@ -36,9 +36,9 @@ public final class RecordViewModel: ViewModel { private var recognizedText: String? // MARK: - Initializer - public init() { + public init(recordManager: RecordManagerProtocol) { self.state = State() - self.recordManager = RecordManager() + self.recordManager = recordManager Task { try await recordManager.setupSpeech() @@ -133,3 +133,4 @@ private extension RecordViewModel { timer = nil } } + diff --git a/Heim/Presentation/PresentationTests/Mock/MockRecordManager.swift b/Heim/Presentation/PresentationTests/Mock/MockRecordManager.swift new file mode 100644 index 00000000..97a3a1b2 --- /dev/null +++ b/Heim/Presentation/PresentationTests/Mock/MockRecordManager.swift @@ -0,0 +1,46 @@ +// +// MockRecordManager.swift +// PresentationTests +// +// Created by 박성근 on 12/5/24. +// + +import Foundation +import Presentation +import Domain + +class MockRecordManager: RecordManagerProtocol { + var recognizedText: String = "" + var minuteAndSeconds: Int = 0 + var voice: Voice? + + var formattedTime: String { + let minutes = minuteAndSeconds / 60 + let seconds = minuteAndSeconds % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + var setupSpeechCalled = false + var startRecordingCalled = false + var stopRecordingCalled = false + var resetAllCalled = false + + func setupSpeech() async throws { + setupSpeechCalled = true + } + + func startRecording() throws { + startRecordingCalled = true + } + + func stopRecording() { + stopRecordingCalled = true + } + + func resetAll() { + resetAllCalled = true + recognizedText = "" + minuteAndSeconds = 0 + voice = nil + } +} diff --git a/Heim/Presentation/PresentationTests/PresentationTests.swift b/Heim/Presentation/PresentationTests/PresentationTests.swift deleted file mode 100644 index d35f1092..00000000 --- a/Heim/Presentation/PresentationTests/PresentationTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// PresentationTests.swift -// PresentationTests -// -// Created by 정지용 on 11/5/24. -// - -import XCTest -@testable import Presentation - -final class PresentationTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} diff --git a/Heim/Presentation/PresentationTests/RecordViewModelTests.swift b/Heim/Presentation/PresentationTests/RecordViewModelTests.swift new file mode 100644 index 00000000..35eed453 --- /dev/null +++ b/Heim/Presentation/PresentationTests/RecordViewModelTests.swift @@ -0,0 +1,136 @@ +// +// RecordViewModelTests.swift +// PresentationTests +// +// Created by 박성근 on 12/5/24. +// + +import XCTest +@testable import Presentation +import Domain + +final class RecordViewModelTest: XCTestCase { + private var viewModel: RecordViewModel! + private var mockRecordManager: MockRecordManager! + + override func setUp() { + super.setUp() + mockRecordManager = MockRecordManager() + viewModel = RecordViewModel(recordManager: mockRecordManager) + } + + override func tearDown() { + viewModel = nil + mockRecordManager = nil + super.tearDown() + } + + // MARK: - Initial State Tests + func test_givenNewViewModel_whenInitialized_thenStateIsCorrectlySet() { + // Then + XCTAssertFalse(viewModel.state.isRecording) + XCTAssertFalse(viewModel.state.canMoveToNext) + XCTAssertEqual(viewModel.state.timeText, "00:00") + XCTAssertFalse(viewModel.state.isErrorPresent) + XCTAssertTrue(viewModel.state.isAuthorized) + } + + // MARK: - Recording State Tests + func test_givenViewModel_WhenStartRecording_ThenStateUpdatesCorrectly() { + // When + viewModel.action(.startRecording) + + // Then + XCTAssertTrue(mockRecordManager.startRecordingCalled) + XCTAssertTrue(viewModel.state.isRecording) + XCTAssertFalse(viewModel.state.canMoveToNext) + } + + func test_GivenRecordingState_WhenStopRecording_ThenStateUpdatesCorrectly() { + // Given + viewModel.action(.startRecording) + + // When + viewModel.action(.stopRecording) + + // Then + XCTAssertTrue(mockRecordManager.stopRecordingCalled) + XCTAssertFalse(viewModel.state.isRecording) + XCTAssertTrue(viewModel.state.canMoveToNext) + } + + func test_GivenRecordedState_WhenRefresh_ThenStateResetsToInitial() { + // Given + viewModel.action(.startRecording) + viewModel.action(.stopRecording) + + // When + viewModel.action(.refresh) + + // Then + XCTAssertTrue(mockRecordManager.resetAllCalled) + XCTAssertFalse(viewModel.state.isRecording) + XCTAssertFalse(viewModel.state.canMoveToNext) + XCTAssertEqual(viewModel.state.timeText, "00:00") + } + + // MARK: - Error Handling Tests + func test_GivenErrorState_WhenClearError_ThenErrorStateIsFalse() { + // Given + viewModel.state.isErrorPresent = true + + // When + viewModel.action(.clearError) + + // Then + XCTAssertFalse(viewModel.state.isErrorPresent) + } + + func test_GivenNoVoiceData_WhenRequestVoiceData_ThenErrorStateIsTrue() { + // Given + mockRecordManager.voice = nil + + // When + let voice = viewModel.voiceData() + + // Then + XCTAssertNil(voice) + XCTAssertTrue(viewModel.state.isErrorPresent) + } + + func test_GivenViewModel_WhenRequestRecognizedText_ThenReturnsManagerText() { + // Given + let testText = "Test recognized text" + mockRecordManager.recognizedText = testText + + // When + let recognizedText = viewModel.recognizedTextData() + + // Then + XCTAssertEqual(recognizedText, testText) + } + + // MARK: - Complete Recording Flow Test + func test_GivenInitialState_WhenPerformRecordingSequence_ThenStateTransitionsCorrectly() async { + // Given + XCTAssertFalse(viewModel.state.isRecording) + XCTAssertFalse(viewModel.state.canMoveToNext) + + // When + viewModel.action(.startRecording) + + // Then + XCTAssertTrue(mockRecordManager.setupSpeechCalled) + XCTAssertTrue(mockRecordManager.startRecordingCalled) + XCTAssertTrue(viewModel.state.isRecording) + XCTAssertFalse(viewModel.state.canMoveToNext) + + // When + viewModel.action(.stopRecording) + + // Then + XCTAssertTrue(mockRecordManager.stopRecordingCalled) + XCTAssertFalse(viewModel.state.isRecording) + XCTAssertTrue(viewModel.state.canMoveToNext) + } +}