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/Manager/MockRecordManager.swift b/Heim/Presentation/PresentationTests/Mock/Manager/MockRecordManager.swift new file mode 100644 index 00000000..97a3a1b2 --- /dev/null +++ b/Heim/Presentation/PresentationTests/Mock/Manager/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/Mock/UseCase/MockDiaryUseCase.swift b/Heim/Presentation/PresentationTests/Mock/UseCase/MockDiaryUseCase.swift new file mode 100644 index 00000000..173a1ffc --- /dev/null +++ b/Heim/Presentation/PresentationTests/Mock/UseCase/MockDiaryUseCase.swift @@ -0,0 +1,81 @@ +// +// MockDiaryUseCase.swift +// Presentation +// +// Created by 박성근 on 12/6/24. +// + +import Foundation +import Domain +@testable import Presentation + +final class MockDiaryUseCase: DiaryUseCase { + var diaryRepository: DiaryRepository { + fatalError("Not implemented") + } + + // MARK: - Call Counts + var saveDiaryCallCount = 0 + var deleteDiaryCallCount = 0 + var readDiariesCallCount = 0 + var readTotalDiariesCallCount = 0 + var fetchContinuousCountCallCount = 0 + var fetchMonthCountCallCount = 0 + + // MARK: - Mock Data + var mockDiaries: [Diary] = [] + var mockContinuousCount: Int = 0 + var mockMonthCount: Int = 0 + var shouldThrowError = false + + enum MockError: Error { + case testError + } + + // MARK: - DiaryUseCase Methods + func saveDiary(data: Diary) async throws { + saveDiaryCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + } + + func deleteDiary(calendarDate: CalendarDate) async throws { + deleteDiaryCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + } + + func readDiaries(calendarDate: CalendarDate) async throws -> [Diary] { + readDiariesCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + return mockDiaries + } + + func readTotalDiaries() async throws -> [Diary] { + readTotalDiariesCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + return mockDiaries + } + + func fetchContinuousCount() async throws -> Int { + fetchContinuousCountCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + return mockContinuousCount + } + + func fetchMonthCount() async throws -> Int { + fetchMonthCountCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + return mockMonthCount + } +} diff --git a/Heim/Presentation/PresentationTests/Mock/UseCase/MockEmotionClasffiyUseCase.swift b/Heim/Presentation/PresentationTests/Mock/UseCase/MockEmotionClasffiyUseCase.swift new file mode 100644 index 00000000..ce1d051c --- /dev/null +++ b/Heim/Presentation/PresentationTests/Mock/UseCase/MockEmotionClasffiyUseCase.swift @@ -0,0 +1,28 @@ +// +// MockEmotionClasffiyUseCase.swift +// Presentation +// +// Created by 박성근 on 12/6/24. +// + +import Foundation +import Domain +@testable import Presentation + +final class MockEmotionClassifyUseCase: EmotionClassifyUseCase { + var validateCallCount = 0 + var mockEmotion: Emotion = .happiness + var shouldThrowError = false + + enum MockError: Error { + case testError + } + + func validate(_ input: String) async throws -> Emotion { + validateCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + return mockEmotion + } +} diff --git a/Heim/Presentation/PresentationTests/Mock/UseCase/MockGenerativeEmotionPromptUseCase.swift b/Heim/Presentation/PresentationTests/Mock/UseCase/MockGenerativeEmotionPromptUseCase.swift new file mode 100644 index 00000000..5c505ad2 --- /dev/null +++ b/Heim/Presentation/PresentationTests/Mock/UseCase/MockGenerativeEmotionPromptUseCase.swift @@ -0,0 +1,28 @@ +// +// MockGenerativeEmotionPromptUseCase.swift +// Presentation +// +// Created by 박성근 on 12/6/24. +// + +import Foundation +import Domain +@testable import Presentation + +final class MockGenerativeEmotionPromptUseCase: GenerativeEmotionPromptUseCase { + var generateCallCount = 0 + var mockReply = "Test Reply" + var shouldThrowError = false + + enum MockError: Error { + case testError + } + + func generate(_ input: String) async throws -> String? { + generateCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + return mockReply + } +} diff --git a/Heim/Presentation/PresentationTests/Mock/UseCase/MockGenerativeSummaryPromptUseCase.swift b/Heim/Presentation/PresentationTests/Mock/UseCase/MockGenerativeSummaryPromptUseCase.swift new file mode 100644 index 00000000..df6a560e --- /dev/null +++ b/Heim/Presentation/PresentationTests/Mock/UseCase/MockGenerativeSummaryPromptUseCase.swift @@ -0,0 +1,28 @@ +// +// MockGenerativeSummaryPromptUseCase.swift +// Presentation +// +// Created by 박성근 on 12/6/24. +// + +import Foundation +import Domain +@testable import Presentation + +final class MockGenerativeSummaryPromptUseCase: GenerativeSummaryPromptUseCase { + var generateCallCount = 0 + var mockSummary = "Test Summary" + var shouldThrowError = false + + enum MockError: Error { + case testError + } + + func generate(_ input: String) async throws -> String? { + generateCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + return mockSummary + } +} diff --git a/Heim/Presentation/PresentationTests/Mock/UseCase/MockSettingUseCase.swift b/Heim/Presentation/PresentationTests/Mock/UseCase/MockSettingUseCase.swift new file mode 100644 index 00000000..36ec7ff4 --- /dev/null +++ b/Heim/Presentation/PresentationTests/Mock/UseCase/MockSettingUseCase.swift @@ -0,0 +1,82 @@ +// +// MockSettingUseCase.swift +// Presentation +// +// Created by 박성근 on 12/5/24. +// + +import Foundation +import Domain +@testable import Presentation + +class MockSettingUseCase: SettingUseCase { + var settingRepository: SettingRepository { + fatalError("Not implemented") + } + + var userRepository: UserRepository { + fatalError("Not implemented") + } + + var fetchUserNameCallCount = 0 + var updateUserNameCallCount = 0 + var isConnectedCloudCallCount = 0 + var updateCloudStateCallCount = 0 + var removeCacheDataCallCount = 0 + var resetDataCallCount = 0 + + var mockUserName = "TestUser" + var mockIsConnectedCloud = true + var shouldThrowError = false + + enum MockError: Error { + case testError + } + + func fetchUserName() async throws -> String { + fetchUserNameCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + return mockUserName + } + + func updateUserName(to name: String) async throws -> String { + updateUserNameCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + mockUserName = name + return name + } + + func isConnectedCloud() async throws -> Bool { + isConnectedCloudCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + return mockIsConnectedCloud + } + + func updateCloudState(isConnected: Bool) async throws { + updateCloudStateCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + mockIsConnectedCloud = isConnected + } + + func removeCacheData() async throws { + removeCacheDataCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + } + + func resetData() async throws { + resetDataCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + } +} diff --git a/Heim/Presentation/PresentationTests/Mock/UseCase/MockUserUseCase.swift b/Heim/Presentation/PresentationTests/Mock/UseCase/MockUserUseCase.swift new file mode 100644 index 00000000..bef73b50 --- /dev/null +++ b/Heim/Presentation/PresentationTests/Mock/UseCase/MockUserUseCase.swift @@ -0,0 +1,34 @@ +// +// MockUserUseCase.swift +// Presentation +// +// Created by 박성근 on 12/6/24. +// + +import Foundation +import Domain +@testable import Presentation + +final class MockUserUseCase: UserUseCase { + var userRepository: UserRepository { + fatalError("Not implemented") + } + + var fetchUserNameCallCount = 0 + var mockUserName = "TestUser" + var shouldThrowError = false + + enum MockError: Error { + case testError + } + + func fetchUserName() async throws -> String { + fetchUserNameCallCount += 1 + if shouldThrowError { + throw MockError.testError + } + return mockUserName + } + + func updateUserName(to name: String) async throws -> String { name } +} 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/ViewModel/AnalyzeResultViewModelTests.swift b/Heim/Presentation/PresentationTests/ViewModel/AnalyzeResultViewModelTests.swift new file mode 100644 index 00000000..4a03a3fb --- /dev/null +++ b/Heim/Presentation/PresentationTests/ViewModel/AnalyzeResultViewModelTests.swift @@ -0,0 +1,139 @@ +// +// AnalyzeResultViewModelTests.swift +// PresentationTests +// +// Created by 박성근 on 12/6/24. +// + +import XCTest +@testable import Presentation +import Domain + +final class AnalyzeResultViewModelTests: XCTestCase { + private var sut: AnalyzeResultViewModel! + private var mockDiaryUseCase: MockDiaryUseCase! + private var mockUserUseCase: MockUserUseCase! + private var testDiary: Diary! + + override func setUp() { + super.setUp() + mockDiaryUseCase = MockDiaryUseCase() + mockUserUseCase = MockUserUseCase() + + let testDate = CalendarDate( + year: 2024, + month: 12, + day: 6, + hour: 12, + minute: 0, + second: 0 + ) + + testDiary = Diary( + calendarDate: testDate, + emotion: .happiness, + emotionReport: EmotionReport(text: "Test Report"), + voice: Voice(audioBuffer: Data()), + summary: Summary(text: "Test Summary") + ) + + sut = AnalyzeResultViewModel( + diaryUseCase: mockDiaryUseCase, + userUseCase: mockUserUseCase, + diary: testDiary + ) + } + + override func tearDown() { + sut = nil + mockDiaryUseCase = nil + mockUserUseCase = nil + testDiary = nil + super.tearDown() + } + + // MARK: - Initial State Tests + func test_initialState() { + XCTAssertEqual(sut.state.userName, "") + XCTAssertEqual(sut.state.description, "") + XCTAssertEqual(sut.state.content, "") + XCTAssertFalse(sut.state.isErrorPresent) + } + + // MARK: - Fetch Diary Tests + func test_givenSuccessfulResponse_whenFetchDiary_thenUpdatesState() async throws { + // When + sut.action(.fetchDiary) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.state.userName, "TestUser") + XCTAssertEqual(sut.state.description, "happiness") + XCTAssertEqual(sut.state.content, "Test Report") + XCTAssertEqual(mockUserUseCase.fetchUserNameCallCount, 1) + } + + func test_givenUserFetchError_whenFetchDiary_thenUsesDefaultUserName() async throws { + // Given + mockUserUseCase.shouldThrowError = true + + // When + sut.action(.fetchDiary) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.state.userName, "User") + XCTAssertEqual(sut.state.description, "happiness") + XCTAssertEqual(sut.state.content, "Test Report") + XCTAssertEqual(mockUserUseCase.fetchUserNameCallCount, 1) + } + + // MARK: - Save Diary Tests + func test_givenSuccessfulResponse_whenSaveDiary_thenCompletesWithoutError() async throws { + // When + sut.action(.fetchDiary) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertFalse(sut.state.isErrorPresent) + } + + // MARK: - Save Diary Tests + func test_givenError_whenSavingDiary_thenSetsErrorState() async throws { + // Given + mockDiaryUseCase.shouldThrowError = true + + // When + sut.action(.fetchDiary) // fetchDiary 내부에서 saveDiary가 호출됨 + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertTrue(sut.state.isErrorPresent) + } + + func test_givenSuccess_whenSavingDiary_thenDoesNotSetErrorState() async throws { + // Given + mockDiaryUseCase.shouldThrowError = false + + // When + sut.action(.fetchDiary) // fetchDiary 내부에서 saveDiary가 호출됨 + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertFalse(sut.state.isErrorPresent) + } + + // MARK: - Clear Error Tests + func test_whenClearError_thenResetsErrorState() { + // Given + sut.state.isErrorPresent = true + + // When + sut.action(.clearError) + + // Then + XCTAssertFalse(sut.state.isErrorPresent) + } +} diff --git a/Heim/Presentation/PresentationTests/ViewModel/DiaryDetailViewModelTests.swift b/Heim/Presentation/PresentationTests/ViewModel/DiaryDetailViewModelTests.swift new file mode 100644 index 00000000..3806e83e --- /dev/null +++ b/Heim/Presentation/PresentationTests/ViewModel/DiaryDetailViewModelTests.swift @@ -0,0 +1,134 @@ +// +// DiaryDetailViewModelTests.swift +// PresentationTests +// +// Created by 박성근 on 12/6/24. +// + +import XCTest +@testable import Presentation +import Domain + +final class DiaryDetailViewModelTests: XCTestCase { + private var sut: DiaryDetailViewModel! + private var mockDiaryUseCase: MockDiaryUseCase! + private var mockUserUseCase: MockUserUseCase! + private var testDiary: Diary! + + override func setUp() { + super.setUp() + mockDiaryUseCase = MockDiaryUseCase() + mockUserUseCase = MockUserUseCase() + + let testDate = CalendarDate( + year: 2024, + month: 12, + day: 6, + hour: 12, + minute: 0, + second: 0 + ) + + testDiary = Diary( + calendarDate: testDate, + emotion: .happiness, + emotionReport: EmotionReport(text: "Test Report"), + voice: Voice(audioBuffer: Data()), + summary: Summary(text: "Test Summary") + ) + + sut = DiaryDetailViewModel( + diaryUseCase: mockDiaryUseCase, + userUseCase: mockUserUseCase, + diary: testDiary + ) + } + + override func tearDown() { + sut = nil + mockDiaryUseCase = nil + mockUserUseCase = nil + testDiary = nil + super.tearDown() + } + + // MARK: - Initial State Tests + func test_initialState() { + XCTAssertEqual(sut.state.calendarDate, "") + XCTAssertEqual(sut.state.emotion, "") + XCTAssertEqual(sut.state.description, "") + XCTAssertEqual(sut.state.content, "") + XCTAssertFalse(sut.state.isDeleted) + XCTAssertFalse(sut.state.isErrorPresent) + } + + // MARK: - Fetch Diary Tests + func test_givenSuccessfulResponse_whenFetchDiary_thenUpdatesState() async throws { + // When + sut.action(.fetchDiary) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.state.calendarDate, "2024년 12월 6일") + XCTAssertEqual(sut.state.emotion, "happiness") + XCTAssertEqual(sut.state.content, "Test Summary") + XCTAssertEqual(mockUserUseCase.fetchUserNameCallCount, 1) + } + + func test_givenError_whenFetchDiary_thenUsesDefaultUserName() async throws { + // Given + mockUserUseCase.shouldThrowError = true + + // When + sut.action(.fetchDiary) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.userName, "User") + XCTAssertEqual(sut.state.calendarDate, "2024년 12월 6일") + XCTAssertEqual(sut.state.emotion, "happiness") + XCTAssertEqual(sut.state.content, "Test Summary") + } + + // MARK: - Delete Diary Tests + func test_givenSuccessfulResponse_whenDeleteDiary_thenUpdatesState() async throws { + // When + sut.action(.deleteDiary) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertTrue(sut.state.isDeleted) + XCTAssertFalse(sut.state.isErrorPresent) + XCTAssertEqual(mockDiaryUseCase.deleteDiaryCallCount, 1) + } + + func test_givenError_whenDeleteDiary_thenSetsErrorState() async throws { + // Given + mockDiaryUseCase.shouldThrowError = true + + // When + sut.action(.deleteDiary) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertFalse(sut.state.isDeleted) + XCTAssertTrue(sut.state.isErrorPresent) + XCTAssertEqual(mockDiaryUseCase.deleteDiaryCallCount, 1) + } + + // MARK: - Clear Error Tests + func test_whenClearError_thenResetsErrorState() { + // Given + sut.state.isErrorPresent = true + + // When + sut.action(.clearError) + + // Then + XCTAssertFalse(sut.state.isErrorPresent) + } +} diff --git a/Heim/Presentation/PresentationTests/ViewModel/EmotionAnalyzeViewModelTests.swift b/Heim/Presentation/PresentationTests/ViewModel/EmotionAnalyzeViewModelTests.swift new file mode 100644 index 00000000..77241950 --- /dev/null +++ b/Heim/Presentation/PresentationTests/ViewModel/EmotionAnalyzeViewModelTests.swift @@ -0,0 +1,118 @@ +// +// EmotionAnalyzeViewModelTests.swift +// PresentationTests +// +// Created by 박성근 on 12/6/24. +// + +import XCTest +@testable import Presentation +import Domain + +final class EmotionAnalyzeViewModelTests: XCTestCase { + private var sut: EmotionAnalyzeViewModel! + private var mockClassifyUseCase: MockEmotionClassifyUseCase! + private var mockEmotionUseCase: MockGenerativeEmotionPromptUseCase! + private var mockSummaryUseCase: MockGenerativeSummaryPromptUseCase! + private let testRecognizedText = "Test Text" + private let testVoice = Voice(audioBuffer: Data()) + + override func setUp() { + super.setUp() + mockClassifyUseCase = MockEmotionClassifyUseCase() + mockEmotionUseCase = MockGenerativeEmotionPromptUseCase() + mockSummaryUseCase = MockGenerativeSummaryPromptUseCase() + + sut = EmotionAnalyzeViewModel( + recognizedText: testRecognizedText, + voice: testVoice, + classifyUseCase: mockClassifyUseCase, + emotionUseCase: mockEmotionUseCase, + summaryUseCase: mockSummaryUseCase + ) + } + + override func tearDown() { + sut = nil + mockClassifyUseCase = nil + mockEmotionUseCase = nil + mockSummaryUseCase = nil + super.tearDown() + } + + // MARK: - Initial State Tests + func test_initialState() { + XCTAssertTrue(sut.state.isAnalyzing) + XCTAssertFalse(sut.state.isErrorPresent) + } + + // MARK: - Analysis Tests + func test_givenSuccessfulResponse_whenAnalyze_thenUpdatesState() async throws { + // Given + mockClassifyUseCase.mockEmotion = .happiness + mockEmotionUseCase.mockReply = "Happy Reply" + mockSummaryUseCase.mockSummary = "Happy Summary" + + // When + sut.action(.analyze) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertFalse(sut.state.isAnalyzing) + XCTAssertFalse(sut.state.isErrorPresent) + XCTAssertEqual(mockClassifyUseCase.validateCallCount, 1) + XCTAssertEqual(mockEmotionUseCase.generateCallCount, 1) + XCTAssertEqual(mockSummaryUseCase.generateCallCount, 1) + + let diary = sut.diaryData() + XCTAssertEqual(diary.emotion, .happiness) + XCTAssertEqual(diary.emotionReport.text, "Happy Reply") + XCTAssertEqual(diary.summary.text, "Happy Summary") + } + + func test_givenClassifyError_whenAnalyze_thenSetsErrorState() async throws { + // Given + mockClassifyUseCase.shouldThrowError = true + + // When + sut.action(.analyze) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertFalse(sut.state.isAnalyzing) + XCTAssertTrue(sut.state.isErrorPresent) + XCTAssertEqual(mockClassifyUseCase.validateCallCount, 1) + } + + func test_givenEmotionPromptError_whenAnalyze_thenSetsErrorState() async throws { + // Given + mockEmotionUseCase.shouldThrowError = true + + // When + sut.action(.analyze) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertFalse(sut.state.isAnalyzing) + XCTAssertTrue(sut.state.isErrorPresent) + XCTAssertEqual(mockEmotionUseCase.generateCallCount, 1) + } + + func test_givenSummaryPromptError_whenAnalyze_thenSetsErrorState() async throws { + // Given + mockSummaryUseCase.shouldThrowError = true + + // When + sut.action(.analyze) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertFalse(sut.state.isAnalyzing) + XCTAssertTrue(sut.state.isErrorPresent) + XCTAssertEqual(mockSummaryUseCase.generateCallCount, 1) + } +} diff --git a/Heim/Presentation/PresentationTests/ViewModel/RecordViewModelTests.swift b/Heim/Presentation/PresentationTests/ViewModel/RecordViewModelTests.swift new file mode 100644 index 00000000..ded60a4c --- /dev/null +++ b/Heim/Presentation/PresentationTests/ViewModel/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 sut: RecordViewModel! + private var mockRecordManager: MockRecordManager! + + override func setUp() { + super.setUp() + mockRecordManager = MockRecordManager() + sut = RecordViewModel(recordManager: mockRecordManager) + } + + override func tearDown() { + sut = nil + mockRecordManager = nil + super.tearDown() + } + + // MARK: - Initial State Tests + func test_givenNewViewModel_whenInitialized_thenStateIsCorrectlySet() { + // Then + XCTAssertFalse(sut.state.isRecording) + XCTAssertFalse(sut.state.canMoveToNext) + XCTAssertEqual(sut.state.timeText, "00:00") + XCTAssertFalse(sut.state.isErrorPresent) + XCTAssertTrue(sut.state.isAuthorized) + } + + // MARK: - Recording State Tests + func test_givenViewModel_WhenStartRecording_ThenStateUpdatesCorrectly() { + // When + sut.action(.startRecording) + + // Then + XCTAssertTrue(mockRecordManager.startRecordingCalled) + XCTAssertTrue(sut.state.isRecording) + XCTAssertFalse(sut.state.canMoveToNext) + } + + func test_GivenRecordingState_WhenStopRecording_ThenStateUpdatesCorrectly() { + // Given + sut.action(.startRecording) + + // When + sut.action(.stopRecording) + + // Then + XCTAssertTrue(mockRecordManager.stopRecordingCalled) + XCTAssertFalse(sut.state.isRecording) + XCTAssertTrue(sut.state.canMoveToNext) + } + + func test_GivenRecordedState_WhenRefresh_ThenStateResetsToInitial() { + // Given + sut.action(.startRecording) + sut.action(.stopRecording) + + // When + sut.action(.refresh) + + // Then + XCTAssertTrue(mockRecordManager.resetAllCalled) + XCTAssertFalse(sut.state.isRecording) + XCTAssertFalse(sut.state.canMoveToNext) + XCTAssertEqual(sut.state.timeText, "00:00") + } + + // MARK: - Error Handling Tests + func test_GivenErrorState_WhenClearError_ThenErrorStateIsFalse() { + // Given + sut.state.isErrorPresent = true + + // When + sut.action(.clearError) + + // Then + XCTAssertFalse(sut.state.isErrorPresent) + } + + func test_GivenNoVoiceData_WhenRequestVoiceData_ThenErrorStateIsTrue() { + // Given + mockRecordManager.voice = nil + + // When + let voice = sut.voiceData() + + // Then + XCTAssertNil(voice) + XCTAssertTrue(sut.state.isErrorPresent) + } + + func test_GivenViewModel_WhenRequestRecognizedText_ThenReturnsManagerText() { + // Given + let testText = "Test recognized text" + mockRecordManager.recognizedText = testText + + // When + let recognizedText = sut.recognizedTextData() + + // Then + XCTAssertEqual(recognizedText, testText) + } + + // MARK: - Complete Recording Flow Test + func test_GivenInitialState_WhenPerformRecordingSequence_ThenStateTransitionsCorrectly() async { + // Given + XCTAssertFalse(sut.state.isRecording) + XCTAssertFalse(sut.state.canMoveToNext) + + // When + sut.action(.startRecording) + + // Then + XCTAssertTrue(mockRecordManager.setupSpeechCalled) + XCTAssertTrue(mockRecordManager.startRecordingCalled) + XCTAssertTrue(sut.state.isRecording) + XCTAssertFalse(sut.state.canMoveToNext) + + // When + sut.action(.stopRecording) + + // Then + XCTAssertTrue(mockRecordManager.stopRecordingCalled) + XCTAssertFalse(sut.state.isRecording) + XCTAssertTrue(sut.state.canMoveToNext) + } +} diff --git a/Heim/Presentation/PresentationTests/ViewModel/ReportViewModelTests.swift b/Heim/Presentation/PresentationTests/ViewModel/ReportViewModelTests.swift new file mode 100644 index 00000000..60d96829 --- /dev/null +++ b/Heim/Presentation/PresentationTests/ViewModel/ReportViewModelTests.swift @@ -0,0 +1,196 @@ +// +// ReportViewModelTests.swift +// PresentationTests +// +// Created by 박성근 on 12/6/24. +// + +import XCTest +@testable import Presentation +import Domain + +final class ReportViewModelTests: XCTestCase { + private var sut: ReportViewModel! + private var mockUserUseCase: MockUserUseCase! + private var mockDiaryUseCase: MockDiaryUseCase! + private var testDiaries: [Diary]! + + override func setUp() { + super.setUp() + mockUserUseCase = MockUserUseCase() + mockDiaryUseCase = MockDiaryUseCase() + setupTestDiaries() + sut = ReportViewModel(userUseCase: mockUserUseCase, diaryUseCase: mockDiaryUseCase) + } + + override func tearDown() { + sut = nil + mockUserUseCase = nil + mockDiaryUseCase = nil + testDiaries = nil + super.tearDown() + } + + private func setupTestDiaries() { + let testDate = CalendarDate( + year: 2024, + month: 12, + day: 6, + hour: 12, + minute: 0, + second: 0 + ) + + testDiaries = [ + Diary( + calendarDate: testDate, + emotion: .happiness, + emotionReport: EmotionReport(text: "Happy Report"), + voice: Voice(audioBuffer: Data()), + summary: Summary(text: "Happy Summary") + ), + Diary( + calendarDate: testDate, + emotion: .happiness, + emotionReport: EmotionReport(text: "Another Happy Report"), + voice: Voice(audioBuffer: Data()), + summary: Summary(text: "Another Happy Summary") + ) + ] + } + + // MARK: - Initial State Tests + func test_initialState() { + XCTAssertEqual(sut.state.userName, "") + XCTAssertEqual(sut.state.totalCount, "0") + XCTAssertEqual(sut.state.continuousCount, "0") + XCTAssertEqual(sut.state.monthCount, "0") + XCTAssertTrue(sut.state.emotionCountDictionary.isEmpty) + XCTAssertEqual(sut.state.mainEmotionTitle, "") + XCTAssertEqual(sut.state.reply, "") + } + + // MARK: - Fetch User Name Tests + func test_givenSuccessfulResponse_whenFetchUserName_thenUpdatesState() async throws { + // Given + mockUserUseCase.mockUserName = "TestUser" + + // When + sut.action(.fetchUserName) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.state.userName, "TestUser") + XCTAssertEqual(mockUserUseCase.fetchUserNameCallCount, 1) + } + + func test_givenError_whenFetchUserName_thenSetsDefaultName() async throws { + // Given + mockUserUseCase.shouldThrowError = true + + // When + sut.action(.fetchUserName) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.state.userName, "User") + XCTAssertEqual(mockUserUseCase.fetchUserNameCallCount, 1) + } + + // MARK: - Fetch Total Count Tests + func test_givenSuccessfulResponse_whenFetchTotalDiaryCount_thenUpdatesState() async throws { + // Given + mockDiaryUseCase.mockDiaries = testDiaries + + // When + sut.action(.fetchTotalDiaryCount) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.state.totalCount, "2") + XCTAssertEqual(sut.state.mainEmotionTitle, "기쁨") + XCTAssertEqual(sut.state.emotionCountDictionary[.happiness], 2) + XCTAssertEqual(mockDiaryUseCase.readTotalDiariesCallCount, 1) + } + + func test_givenError_whenFetchTotalDiaryCount_thenSetsDefaultValues() async throws { + // Given + mockDiaryUseCase.shouldThrowError = true + + // When + sut.action(.fetchTotalDiaryCount) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.state.totalCount, "0") + XCTAssertEqual(sut.state.mainEmotionTitle, "") + XCTAssertEqual(sut.state.emotionCountDictionary.count, 7) + // 모든 감정의 카운트가 0인지 확인 + Emotion.allCases.filter { $0 != .none }.forEach { emotion in + XCTAssertEqual(sut.state.emotionCountDictionary[emotion], 0) + } + XCTAssertEqual(mockDiaryUseCase.readTotalDiariesCallCount, 1) + } + + // MARK: - Fetch Continuous Count Tests + func test_givenSuccessfulResponse_whenFetchContinuousCount_thenUpdatesState() async throws { + // Given + mockDiaryUseCase.mockContinuousCount = 5 + + // When + sut.action(.fetchContinuousCount) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.state.continuousCount, "5") + XCTAssertEqual(mockDiaryUseCase.fetchContinuousCountCallCount, 1) + } + + func test_givenError_whenFetchContinuousCount_thenSetsDefaultValue() async throws { + // Given + mockDiaryUseCase.shouldThrowError = true + + // When + sut.action(.fetchContinuousCount) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.state.continuousCount, "0") + XCTAssertEqual(mockDiaryUseCase.fetchContinuousCountCallCount, 1) + } + + // MARK: - Fetch Month Count Tests + func test_givenSuccessfulResponse_whenFetchMonthCount_thenUpdatesState() async throws { + // Given + mockDiaryUseCase.mockMonthCount = 15 + + // When + sut.action(.fetchMonthCount) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.state.monthCount, "15") + XCTAssertEqual(mockDiaryUseCase.fetchMonthCountCallCount, 1) + } + + func test_givenError_whenFetchMonthCount_thenSetsDefaultValue() async throws { + // Given + mockDiaryUseCase.shouldThrowError = true + + // When + sut.action(.fetchMonthCount) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(sut.state.monthCount, "0") + XCTAssertEqual(mockDiaryUseCase.fetchMonthCountCallCount, 1) + } +} diff --git a/Heim/Presentation/PresentationTests/ViewModel/SettingViewModelTests.swift b/Heim/Presentation/PresentationTests/ViewModel/SettingViewModelTests.swift new file mode 100644 index 00000000..fca68bb6 --- /dev/null +++ b/Heim/Presentation/PresentationTests/ViewModel/SettingViewModelTests.swift @@ -0,0 +1,102 @@ +// +// SettingViewModelTests.swift +// PresentationTests +// +// Created by 박성근 on 12/5/24. +// + +import XCTest +@testable import Presentation +import Domain + +final class SettingViewModelTests: XCTestCase { + private var sut: SettingViewModel! + private var mockUseCase: MockSettingUseCase! + + override func setUp() { + super.setUp() + mockUseCase = MockSettingUseCase() + sut = SettingViewModel(useCase: mockUseCase) + } + + override func tearDown() { + sut = nil + mockUseCase = nil + super.tearDown() + } + + // MARK: - Initial State Tests + func test_initialState() { + XCTAssertEqual(sut.state.userName, "") + XCTAssertFalse(sut.state.isConnectedCloud) + XCTAssertFalse(sut.state.isErrorPresent) + } + + // MARK: - Fetch User Name Tests + func test_givenError_whenFetchUserName_thenSetsDefaultUserName() async throws { + // Given + mockUseCase.shouldThrowError = true + + // When + sut.action(.fetchUserName) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertEqual(sut.state.userName, "User") + XCTAssertEqual(mockUseCase.fetchUserNameCallCount, 1) + } + + // MARK: - Update User Name Tests + func test_givenValidName_whenUpdateUserName_thenUpdatesState() async throws { + // Given + let newName = "New User" + + // When + sut.action(.updateUserName(newName)) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertEqual(sut.state.userName, newName) + XCTAssertEqual(mockUseCase.updateUserNameCallCount, 1) + XCTAssertFalse(sut.state.isErrorPresent) + } + + func test_givenError_whenUpdateUserName_thenSetsErrorState() async throws { + // Given + mockUseCase.shouldThrowError = true + + // When + sut.action(.updateUserName("New User")) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertTrue(sut.state.isErrorPresent) + XCTAssertEqual(mockUseCase.updateUserNameCallCount, 1) + } + + // MARK: - Reset Data Tests + func test_givenError_whenResetData_thenSetsErrorState() async throws { + // Given + mockUseCase.shouldThrowError = true + + // When + sut.action(.resetData) + + // Then + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertTrue(sut.state.isErrorPresent) + XCTAssertEqual(mockUseCase.resetDataCallCount, 1) + } + + // MARK: - Clear Error Tests + func test_whenClearError_thenResetsErrorState() { + // Given + sut.state.isErrorPresent = true + + // When + sut.action(.clearError) + + // Then + XCTAssertFalse(sut.state.isErrorPresent) + } +}