diff --git a/Projects/Calendar/CalendarFeature/Tests/CalendarDomainTests.swift b/Projects/Calendar/CalendarFeature/Tests/CalendarDomainTests.swift new file mode 100644 index 0000000..c2a6b2f --- /dev/null +++ b/Projects/Calendar/CalendarFeature/Tests/CalendarDomainTests.swift @@ -0,0 +1,169 @@ +// +// CalendarDomainTests.swift +// CalendarFeature +// +// Created by 이지훈 on 7/24/25. +// Copyright © 2025 com.noweekend. All rights reserved. +// + + +// +// CalendarDomainTests.swift +// CalendarFeatureTests +// +// Created by 이지훈 on 7/24/25. +// Copyright © 2025 com.noweekend. All rights reserved. +// + +import XCTest +@testable import CalendarFeature +@testable import CalendarDomain +import Foundation + +// MARK: - Domain Layer Tests (UseCase) +/** + `CalendarUseCase`의 로직을 검증하는 테스트 클래스입니다. + UseCase가 Repository를 올바르게 호출하고, 전달된 데이터를 적절히 가공하며, 에러를 잘 처리하는지 확인합니다. + */ +final class CalendarDomainTests: XCTestCase { + + /// 테스트 대상 시스템(System Under Test)으로, `CalendarUseCase`의 인스턴스입니다. + private var sut: CalendarUseCase! + /// `CalendarUseCase`가 의존하는 Repository의 Mock 객체입니다. + private var mockRepository: MockCalendarRepository! + + /** + 각 테스트 케이스가 실행되기 전에 호출됩니다. + 테스트에 필요한 `mockRepository`와 `sut`를 초기화합니다. + */ + override func setUp() { + super.setUp() + mockRepository = MockCalendarRepository() + sut = CalendarUseCase(calendarRepository: mockRepository) + } + + /** + 각 테스트 케이스가 완료된 후에 호출됩니다. + 사용한 객체들을 nil로 만들어 메모리에서 해제하고, 테스트 간의 독립성을 보장합니다. + */ + override func tearDown() { + sut = nil + mockRepository = nil + super.tearDown() + } + + // MARK: - Week Range Calculation Tests + /** + `getWeeklySchedules` 호출 시, 주어진 날짜에 대해 정확한 주(월요일 ~ 일요일)의 시작일과 종료일을 계산하여 리포지토리를 호출하는지 테스트합니다. + */ + func test_getWeeklySchedules_shouldCalculateCorrectWeekRange() async throws { + // Given: 테스트를 위한 특정 날짜(2025년 7월 24일 목요일)와 Mock 데이터 설정 + let testDate = createDate(year: 2025, month: 7, day: 24) // 목요일 + + mockRepository.mockSchedules = [ + createMockDailySchedule(date: "2025-07-24") + ] + + // When: 주간 스케줄을 조회하는 UseCase 메소드 실행 + let result = try await sut.getWeeklySchedules(for: testDate) + + // Then: 리포지토리가 예상대로 호출되었는지, 파라미터가 정확한지 검증 + XCTAssertEqual(mockRepository.getSchedulesCallCount, 1, "리포지토리의 getSchedules가 한 번만 호출되어야 합니다.") + + guard let params = mockRepository.lastGetSchedulesParams else { + XCTFail("리포지토리 메소드는 파라미터와 함께 호출되어야 합니다.") + return + } + + // 2025년 7월 24일(목)이 속한 주는 7월 21일(월)부터 7월 27일(일)까지여야 합니다. + XCTAssertEqual(params.startDate, "2025-07-21", "계산된 주 시작일이 정확해야 합니다.") + XCTAssertEqual(params.endDate, "2025-07-27", "계산된 주 종료일이 정확해야 합니다.") + XCTAssertEqual(result.count, 1, "반환된 결과의 수가 Mock 데이터와 일치해야 합니다.") + } + + // MARK: - Schedule CRUD Tests + /** + `createSchedule` 호출 시, 모든 파라미터를 정확하게 `CreateScheduleRequest` 객체로 만들어 리포지토리에 전달하는지 테스트합니다. + */ + func test_createSchedule_shouldCallRepositoryWithCorrectParameters() async throws { + // Given: 스케줄 생성을 위한 파라미터와 예상 결과 설정 + let title = "테스트 미팅" + let date = createDate(year: 2025, month: 7, day: 24) + let startTime = createDateTime(year: 2025, month: 7, day: 24, hour: 14, minute: 0) + let endTime = createDateTime(year: 2025, month: 7, day: 24, hour: 15, minute: 0) + let category = ScheduleCategory.company + let temperature = 75 + let allDay = false + let alarmOption = AlarmOption.fifteenMinutesBefore + + let expectedSchedule = Schedule(id: "test-schedule-1", title: title, startTime: startTime, endTime: endTime, category: category, temperature: temperature, allDay: allDay, alarmOption: alarmOption, completed: false) + + mockRepository.mockCreatedSchedule = expectedSchedule + + // When: 스케줄 생성 UseCase 메소드 실행 + let result = try await sut.createSchedule(title: title, date: date, startTime: startTime, endTime: endTime, category: category, temperature: temperature, allDay: allDay, alarmOption: alarmOption) + + // Then: 리포지토리 호출 및 전달된 데이터 검증 + XCTAssertEqual(mockRepository.createScheduleCallCount, 1, "리포지토리의 createSchedule이 한 번만 호출되어야 합니다.") + + guard let request = mockRepository.lastCreatedScheduleRequest else { + XCTFail("생성된 스케줄 요청 객체가 리포지토리에 전달되어야 합니다.") + return + } + + XCTAssertEqual(request.title, title) + XCTAssertEqual(request.category, category) + XCTAssertEqual(request.temperature, temperature) + XCTAssertEqual(result.id, expectedSchedule.id, "반환된 결과가 Mock 객체와 일치해야 합니다.") + } + + // MARK: - Error Handling Tests + /** + 리포지토리에서 에러가 발생했을 때, UseCase가 해당 에러를 호출한 쪽으로 올바르게 다시 던지는지(re-throw) 테스트합니다. + */ + func test_createSchedule_shouldThrowErrorWhenRepositoryFails() async { + // Given: 리포지토리가 에러를 발생시키도록 설정 + mockRepository.shouldThrowError = true + + // When & Then: 에러가 발생할 것을 예상하고 테스트 실행 + do { + _ = try await sut.createSchedule(title: "Test", date: Date(), startTime: Date(), endTime: Date().addingTimeInterval(3600), category: .company, temperature: 50, allDay: false, alarmOption: .none) + XCTFail("예외가 발생해야 하지만, 정상적으로 실행되었습니다.") + } catch { + // 에러가 발생하더라도 리포지토리 호출 시도는 이루어졌어야 합니다. + XCTAssertEqual(mockRepository.createScheduleCallCount, 1, "에러가 발생하더라도 리포지토리 메소드는 호출되어야 합니다.") + XCTAssert(error is NSError, "던져진 에러가 예상된 타입이어야 합니다.") + } + } +} + +// MARK: - Test Helpers +/** + 테스트 코드의 가독성과 재사용성을 높이기 위한 헬퍼 메소드들을 모아둔 private extension입니다. + */ +private extension CalendarDomainTests { + /// 특정 년/월/일로 `Date` 객체를 생성합니다. (시간은 0시) + func createDate(year: Int, month: Int, day: Int) -> Date { + var calendar = Calendar.current + calendar.timeZone = TimeZone(identifier: "Asia/Seoul") ?? TimeZone.current + + let components = DateComponents(year: year, month: month, day: day) + return calendar.date(from: components) ?? Date() + } + + /// 특정 년/월/일/시/분으로 `Date` 객체를 생성합니다. + func createDateTime(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date { + var calendar = Calendar.current + calendar.timeZone = TimeZone(identifier: "Asia/Seoul") ?? TimeZone.current + + let components = DateComponents(year: year, month: month, day: day, hour: hour, minute: minute) + return calendar.date(from: components) ?? Date() + } + + /// 테스트용 `DailySchedule` 모의 객체를 생성합니다. + func createMockDailySchedule(date: String) -> DailySchedule { + let mockSchedule = Schedule(id: "mock-schedule", title: "Mock Schedule", startTime: Date(), endTime: Date().addingTimeInterval(3600), category: .company, temperature: 50, allDay: false, alarmOption: .none, completed: false) + + return DailySchedule(date: date, dailyTemperature: 50, schedules: [mockSchedule]) + } +} diff --git a/Projects/Calendar/CalendarFeature/Tests/CalendarFeatureOnlyTests.swift b/Projects/Calendar/CalendarFeature/Tests/CalendarFeatureOnlyTests.swift new file mode 100644 index 0000000..66516f1 --- /dev/null +++ b/Projects/Calendar/CalendarFeature/Tests/CalendarFeatureOnlyTests.swift @@ -0,0 +1,362 @@ +// +// CalendarFeatureTests.swift +// CalendarFeatureTests +// +// Created by 이지훈 on 7/24/25. +// Copyright © 2025 com.noweekend. All rights reserved. +// + +import XCTest +import Combine +import SwiftUI +@testable import CalendarFeature +@testable import CalendarDomain +@testable import DesignSystem +@testable import DIContainer +import Foundation + +// MARK: - Feature Layer 단위 테스트 (Store 상태 관리) +/** + CalendarStore의 상태 관리 로직을 단위 테스트로 검증하는 클래스 + DI 의존성 없이 Store의 순수한 상태 변경 로직에 집중 + + 주요 테스트 영역: + - 초기 상태 검증 + - 상태 변경 로직의 정확성 + - Navigation Effect 발생 조건 + - Computed Property의 올바른 계산 + */ +@MainActor +final class CalendarFeatureTests: XCTestCase { + + private var sut: CalendarStore! + private var cancellables: Set! + private var capturedEffects: [CalendarEffect] = [] + private var mockUseCase: MockCalendarUseCase! + + override func setUp() { + super.setUp() + + setupTestDI() + + sut = CalendarStore() + cancellables = Set() + capturedEffects = [] + + // Effect 스트림 구독 + sut.effect + .sink { [weak self] effect in + self?.capturedEffects.append(effect) + } + .store(in: &cancellables) + } + + override func tearDown() { + cancellables?.removeAll() + capturedEffects.removeAll() + sut = nil + mockUseCase = nil + super.tearDown() + } + + /** + 테스트용 DI Container 설정 + 각 테스트마다 격리된 Mock UseCase 인스턴스 제공 + */ + private func setupTestDI() { + mockUseCase = MockCalendarUseCase() + + DIContainer.shared.container.register(CalendarUseCaseProtocol.self) { _ in + return self.mockUseCase + }.inObjectScope(.transient) + } + + // MARK: - 초기 상태 테스트 + + /** + CalendarStore 초기 상태 검증 + + 검증 사항: + - 모든 초기값이 예상된 기본값으로 설정됨 + - 컬렉션 타입들이 빈 상태로 초기화됨 + - Boolean 플래그들이 올바른 기본값을 가짐 + */ + func test_initialState_hasCorrectDefaults() { + // Then: 모든 초기 상태값 검증 + XCTAssertEqual(sut.state.selectedToggle, .week, "초기 토글은 주 단위여야 함") + XCTAssertEqual(sut.state.dailySchedules.count, 0, "초기 스케줄 목록은 비어있어야 함") + XCTAssertEqual(sut.state.todoItems.count, 0, "초기 할일 목록은 비어있어야 함") + XCTAssertEqual(sut.state.recommendedCategories.count, 0, "초기 추천 카테고리는 비어있어야 함") + XCTAssertFalse(sut.state.showTaskEditSheet, "초기에는 할일 수정 시트가 숨겨져야 함") + XCTAssertFalse(sut.state.showCategorySelection, "초기에는 카테고리 선택이 숨겨져야 함") + XCTAssertEqual(sut.state.scrollOffset, 0, "초기 스크롤 오프셋은 0이어야 함") + XCTAssertFalse(sut.state.isScrolling, "초기에는 스크롤 중이 아니어야 함") + XCTAssertNil(sut.state.selectedTaskIndex, "초기에는 선택된 할일이 없어야 함") + XCTAssertFalse(sut.state.isLoading, "초기에는 로딩 상태가 아니어야 함") + } + + /** + State 직접 수정 로직 검증 + + 시나리오: + - updateState 메서드를 통한 다양한 상태값 변경 + - 변경된 값들이 올바르게 반영되는지 확인 + + 검증 사항: + - 각 상태 프로퍼티가 설정한 값으로 정확히 변경됨 + - 날짜 비교 시 적절한 정밀도로 검증됨 + */ + func test_updateState_modifiesStateCorrectly() { + // Given: 변경할 상태값들 준비 + let newToggle = CalendarNavigationBar.ToggleOption.month + let testDate = createDate(year: 2025, month: 8, day: 15) + + // When: 상태 직접 수정 + sut.updateState { state in + state.selectedToggle = newToggle + state.selectedDate = testDate + state.showCategorySelection = true + state.scrollOffset = 100 + state.isScrolling = true + } + + // Then: 모든 변경사항 검증 + XCTAssertEqual(sut.state.selectedToggle, newToggle, "토글 상태가 올바르게 변경되어야 함") + XCTAssertEqual(sut.state.selectedDate.timeIntervalSince1970, testDate.timeIntervalSince1970, accuracy: 1, + "선택된 날짜가 올바르게 변경되어야 함") + XCTAssertTrue(sut.state.showCategorySelection, "카테고리 선택 표시가 활성화되어야 함") + XCTAssertEqual(sut.state.scrollOffset, 100, "스크롤 오프셋이 올바르게 설정되어야 함") + XCTAssertTrue(sut.state.isScrolling, "스크롤 상태가 활성화되어야 함") + } + + // MARK: - Navigation Effect 테스트 + + /** + 월 단위 모드에서 날짜 선택 시 상세 화면 네비게이션 테스트 + + 시나리오: + - 캘린더가 월 단위 모드일 때 특정 날짜 선택 + - 해당 날짜의 상세 화면으로 네비게이션 Effect 발생 확인 + + 검증 사항: + - navigateToDateDetail Effect가 정확히 한 번 발생함 + - Effect에 포함된 날짜가 선택한 날짜와 일치함 + */ + func test_dateSelected_withMonthToggle_navigatesToDateDetail() async { + // Given: 월 단위 모드로 설정 + sut.updateState { state in + state.selectedToggle = .month + } + let testDate = createDate(year: 2025, month: 8, day: 15) + + // When: 특정 날짜 선택 + sut.send(.dateSelected(testDate)) + await waitForAsyncOperations() + + // Then: 네비게이션 Effect 검증 + XCTAssertEqual(capturedEffects.count, 1, "정확히 하나의 Effect가 발생해야 함") + + if case .navigateToDateDetail(let date) = capturedEffects.first { + XCTAssertEqual(date.timeIntervalSince1970, testDate.timeIntervalSince1970, accuracy: 1, + "Effect의 날짜가 선택한 날짜와 일치해야 함") + } else { + XCTFail("navigateToDateDetail Effect가 발생해야 함, 실제: \(capturedEffects)") + } + } + + /** + 직접 입력 버튼 탭 시 할일 생성 화면 네비게이션 테스트 + + 시나리오: + - 카테고리 선택 모드에서 "직접 입력" 버튼 탭 + - 카테고리 선택 모드 종료 및 할일 생성 화면 네비게이션 + + 검증 사항: + - 카테고리 선택 상태가 false로 변경됨 + - navigateToTaskCreate Effect가 올바른 날짜와 함께 발생함 + */ + func test_directInputTapped_navigatesToTaskCreate() async { + // Given: 카테고리 선택 모드 활성화 및 날짜 설정 + let testDate = createDate(year: 2025, month: 7, day: 24) + sut.updateState { state in + state.showCategorySelection = true + state.selectedDate = testDate + } + + // When: 직접 입력 버튼 탭 + sut.send(.directInputTapped) + await waitForAsyncOperations() + + // Then: 상태 변경 및 네비게이션 Effect 검증 + XCTAssertFalse(sut.state.showCategorySelection, "카테고리 선택이 비활성화되어야 함") + XCTAssertEqual(capturedEffects.count, 1, "정확히 하나의 Effect가 발생해야 함") + + if case .navigateToTaskCreate(let date) = capturedEffects.first { + XCTAssertEqual(date.timeIntervalSince1970, testDate.timeIntervalSince1970, accuracy: 1, + "Effect의 날짜가 현재 선택된 날짜와 일치해야 함") + } else { + XCTFail("navigateToTaskCreate Effect가 발생해야 함, 실제: \(capturedEffects)") + } + } + + // MARK: - 할일 관리 테스트 + + /** + 할일 더보기 버튼 탭 시 수정 시트 표시 테스트 + + 시나리오: + - 하나의 할일이 있는 상태에서 더보기 버튼 탭 + - 할일 수정 시트 표시 및 선택된 할일 인덱스 설정 + + 검증 사항: + - 할일 수정 시트가 표시됨 + - 선택된 할일 인덱스가 올바르게 설정됨 + */ + func test_taskMoreTapped_showsTaskEditSheet() async { + // Given: 할일 목록 준비 + sut.updateState { state in + state.todoItems = [createMockTodoItem(id: 1, title: "테스트 할일")] + } + + // When: 더보기 버튼 탭 + sut.send(.taskMoreTapped(0)) + await waitForAsyncOperations() + + // Then: 수정 시트 표시 상태 검증 + XCTAssertTrue(sut.state.showTaskEditSheet, "할일 수정 시트가 표시되어야 함") + XCTAssertEqual(sut.state.selectedTaskIndex, 0, "선택된 할일 인덱스가 0이어야 함") + } + + /** + 잘못된 인덱스로 할일 더보기 버튼 탭 시 안전성 테스트 + + 시나리오: + - 빈 할일 목록에서 존재하지 않는 인덱스로 더보기 버튼 탭 + - 시스템이 크래시 없이 안전하게 처리하는지 확인 + + 검증 사항: + - 애플리케이션이 크래시하지 않음 + - 수정 시트가 표시되지 않음 + - 선택된 할일 인덱스가 설정되지 않음 + */ + func test_taskMoreTapped_withInvalidIndex_handlesGracefully() async { + // Given: 빈 할일 목록 + sut.updateState { state in + state.todoItems = [] + } + + // When: 존재하지 않는 인덱스로 더보기 버튼 탭 + sut.send(.taskMoreTapped(999)) + await waitForAsyncOperations() + + // Then: 안전한 처리 검증 (크래시 없이 정상 동작) + XCTAssertFalse(sut.state.showTaskEditSheet, "수정 시트가 표시되지 않아야 함") + XCTAssertNil(sut.state.selectedTaskIndex, "선택된 할일 인덱스가 설정되지 않아야 함") + } + + // MARK: - 카테고리 선택 테스트 + + /** + 카테고리 선택 토글 동작 테스트 + + 시나리오: + - 초기 상태(비활성화)에서 카테고리 선택 토글 + - 상태가 활성화로 변경되는지 확인 + + 검증 사항: + - 카테고리 선택 상태가 토글됨 + */ + func test_categorySelectionToggled_togglesState() async { + // Given: 초기 상태 (카테고리 선택 비활성화) + XCTAssertFalse(sut.state.showCategorySelection, "초기에는 카테고리 선택이 비활성화되어야 함") + + // When: 카테고리 선택 토글 + sut.send(.categorySelectionToggled) + await waitForAsyncOperations() + + // Then: 상태 변경 검증 + XCTAssertTrue(sut.state.showCategorySelection, "카테고리 선택이 활성화되어야 함") + } + + // MARK: - Computed Property 테스트 + + /** + 플로팅 버튼 확장 상태 계산 로직 테스트 + + 시나리오: + - 스크롤 오프셋이 0이고 스크롤 중이 아닌 상태 + - 플로팅 버튼이 확장 상태여야 함 + + 검증 사항: + - isFloatingButtonExpanded가 true를 반환함 + */ + func test_isFloatingButtonExpanded_withZeroOffsetAndNotScrolling_returnsTrue() { + // Given: 플로팅 버튼 확장 조건 설정 + sut.updateState { state in + state.scrollOffset = 0 + state.isScrolling = false + } + + // Then: 확장 상태 검증 + XCTAssertTrue(sut.state.isFloatingButtonExpanded, "스크롤 최상단에서 플로팅 버튼이 확장되어야 함") + } + + /** + 현재 날짜 문자열 포맷 검증 테스트 + + 시나리오: + - 특정 날짜를 선택된 날짜로 설정 + - 날짜 문자열이 "yyyy년 M월" 형식으로 올바르게 포맷팅되는지 확인 + + 검증 사항: + - currentDateString이 예상된 형식으로 반환됨 + */ + func test_currentDateString_returnsCorrectFormat() { + // Given: 특정 날짜 설정 + let testDate = createDate(year: 2025, month: 7, day: 24) + sut.updateState { state in + state.selectedDate = testDate + } + + // Then: 날짜 문자열 포맷 검증 + XCTAssertEqual(sut.state.currentDateString, "2025년 7월", "날짜가 올바른 형식으로 포맷되어야 함") + } + + // MARK: - 헬퍼 메서드 + + /** + 비동기 작업 완료를 위한 최소 대기 시간 + */ + private func waitForAsyncOperations() async { + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + } + + /** + 테스트용 날짜 생성 (서울 시간대 기준) + */ + private func createDate(year: Int, month: Int, day: Int) -> Date { + var calendar = Calendar.current + calendar.timeZone = TimeZone(identifier: "Asia/Seoul") ?? TimeZone.current + + let components = DateComponents(year: year, month: month, day: day) + return calendar.date(from: components) ?? Date() + } + + /** + 테스트용 Mock TodoItem 생성 + */ + private func createMockTodoItem(id: Int, title: String, category: String? = nil, scheduleId: String? = nil) -> DesignSystem.TodoItem { + let todoCategory = category.map { + DesignSystem.TodoCategory(name: $0, color: DS.Colors.TaskItem.orange) + } + + return DesignSystem.TodoItem( + id: id, + title: title, + isCompleted: false, + category: todoCategory, + time: "오전 9:00", + scheduleId: scheduleId + ) + } +} diff --git a/Projects/Calendar/CalendarFeature/Tests/CalendarFeatureTests.swift b/Projects/Calendar/CalendarFeature/Tests/CalendarFeatureTests.swift deleted file mode 100644 index 90c5efe..0000000 --- a/Projects/Calendar/CalendarFeature/Tests/CalendarFeatureTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// CalendarFeatureTests.swift -// CalendarExampleApp -// -// Created by 이지훈 on 7/22/25. -// Copyright © 2025 com.noweekend. All rights reserved. -// - -import XCTest -@testable import CalendarFeature - -final class CalendarFeatureTests: XCTestCase { - func testExample() { - XCTAssertTrue(true) - } -} diff --git a/Projects/Calendar/CalendarFeature/Tests/CalendarIntegrationTests.swift b/Projects/Calendar/CalendarFeature/Tests/CalendarIntegrationTests.swift new file mode 100644 index 0000000..c9b4440 --- /dev/null +++ b/Projects/Calendar/CalendarFeature/Tests/CalendarIntegrationTests.swift @@ -0,0 +1,442 @@ +// +// CalendarIntegrationTests.swift +// CalendarFeatureTests +// +// Created by 이지훈 on 7/24/25. +// Copyright © 2025 com.noweekend. All rights reserved. +// + +import XCTest +import Combine +@testable import CalendarFeature +@testable import CalendarDomain +@testable import DesignSystem +@testable import DIContainer +@testable import NWNetwork +import Foundation + +// MARK: - 동시성 테스트를 위한 향상된 Mock Repository +/** + 동시성 상황에서의 실제 네트워크 요청과 유사한 동작을 시뮬레이션하는 Mock Repository + + 주요 기능: + - 현실적인 네트워크 지연 시뮬레이션 + - 동시 요청 추적 및 상태 관리 + - 작업 완료 순서 기록 및 검증 + - Race condition 테스트 지원 + */ +class ConcurrencyMockRepository: CalendarRepositoryProtocol { + + // MARK: - 동시성 상태 관리 + private var operationQueue = DispatchQueue(label: "mock.repository", qos: .userInitiated) + private var activeOperations: Set = [] + private var completionOrder: [String] = [] + + // MARK: - 네트워크 지연 시뮬레이션 설정 + var shouldSimulateRealisticTiming = false + var baseDelay: TimeInterval = 0.05 // 기본 50ms 지연 + var delayVariation: TimeInterval = 0.03 // ±30ms 랜덤 변동 + + // MARK: - Mock 결과 설정 + var updateScheduleStateResult: Result = .success(MockHelpers.createMockSchedule()) + + // MARK: - 호출 추적 + var updateScheduleStateCallCount = 0 + + // MARK: - 작업 상태 콜백 + var onOperationStarted: ((String) -> Void)? + var onOperationCompleted: ((String) -> Void)? + var onAllOperationsCompleted: (() -> Void)? + + /** + 스케줄 상태 업데이트 요청을 처리하며, 동시성 테스트를 위한 지연 시뮬레이션 + + - Parameters: + - id: 업데이트할 스케줄 ID + - isComplete: 완료 상태 여부 + - Returns: 업데이트된 Schedule 객체 + - Throws: 설정된 에러 또는 네트워크 관련 에러 + */ + func updateScheduleState(id: String, isComplete: Bool) async throws -> Schedule { + let operationId = "\(id)-\(isComplete)-\(UUID().uuidString.prefix(8))" + + return try await withCheckedThrowingContinuation { continuation in + operationQueue.async { [weak self] in + guard let self = self else { + continuation.resume(throwing: NSError(domain: "MockError", code: -1)) + return + } + + // 작업 시작 추적 + self.activeOperations.insert(operationId) + self.updateScheduleStateCallCount += 1 + + Task { @MainActor in + self.onOperationStarted?(operationId) + } + + Task { + do { + // 네트워크 지연 시뮬레이션 + if self.shouldSimulateRealisticTiming { + let randomDelay = self.baseDelay + Double.random(in: -self.delayVariation...self.delayVariation) + let delayNanoseconds = UInt64(max(0, randomDelay) * 1_000_000_000) + try await Task.sleep(nanoseconds: delayNanoseconds) + } + + // Mock 결과 생성 + let baseSchedule = try self.updateScheduleStateResult.get() + let updatedSchedule = Schedule( + id: id, + title: baseSchedule.title, + startTime: baseSchedule.startTime, + endTime: baseSchedule.endTime, + category: baseSchedule.category, + temperature: baseSchedule.temperature, + allDay: baseSchedule.allDay, + alarmOption: baseSchedule.alarmOption, + completed: isComplete + ) + + // 작업 완료 처리 + await self.markOperationCompleted(operationId) + continuation.resume(returning: updatedSchedule) + + } catch { + await self.markOperationCompleted(operationId) + continuation.resume(throwing: error) + } + } + } + } + } + + /** + 작업 완료를 표시하고 완료 순서를 기록 + + - Parameter operationId: 완료된 작업의 고유 ID + */ + @MainActor + private func markOperationCompleted(_ operationId: String) { + activeOperations.remove(operationId) + completionOrder.append(operationId) + + onOperationCompleted?(operationId) + + // 모든 작업이 완료되면 콜백 호출 + if activeOperations.isEmpty { + onAllOperationsCompleted?() + } + } + + /** + 모든 활성 작업이 완료될 때까지 대기 + + - Parameter timeout: 최대 대기 시간 (기본 5초) + - Returns: 모든 작업이 완료되면 true, 타임아웃 시 false + */ + func waitForAllOperationsToComplete(timeout: TimeInterval = 5.0) async -> Bool { + return await withCheckedContinuation { continuation in + var isResumed = false + let lock = NSLock() + + // 타임아웃 처리 + let timeoutTask = Task { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + lock.lock() + defer { lock.unlock() } + if !isResumed { + isResumed = true + continuation.resume(returning: false) + } + } + + // 완료 콜백 설정 + onAllOperationsCompleted = { + lock.lock() + defer { lock.unlock() } + if !isResumed { + isResumed = true + timeoutTask.cancel() + continuation.resume(returning: true) + } + } + + // 이미 모든 작업이 완료된 경우 즉시 반환 + lock.lock() + defer { lock.unlock() } + if activeOperations.isEmpty && !isResumed { + isResumed = true + timeoutTask.cancel() + continuation.resume(returning: true) + } + } + } + + /** + 테스트를 위한 상태 초기화 + */ + func reset() { + operationQueue.sync { + activeOperations.removeAll() + completionOrder.removeAll() + updateScheduleStateCallCount = 0 + onOperationStarted = nil + onOperationCompleted = nil + onAllOperationsCompleted = nil + } + } + + // MARK: - 기타 프로토콜 메서드 (테스트에 불필요한 간단 구현) + func getSchedules(startDate: String, endDate: String) async throws -> [DailySchedule] { [] } + func createSchedule(request: CreateScheduleRequest) async throws -> Schedule { MockHelpers.createMockSchedule() } + func updateSchedule(id: String, request: UpdateScheduleRequest) async throws -> Schedule { MockHelpers.createMockSchedule() } + func deleteSchedule(id: String) async throws { } + func getRecommendedTags() async throws -> RecommendTagResponse { MockHelpers.createMockRecommendTags() } +} + +// MARK: - 통합 테스트 (Store + UseCase + Repository 연동) +/** + CalendarStore의 실제 UseCase와 Repository와의 통합 동작을 검증하는 테스트 클래스 + + 주요 테스트 영역: + - 동시성 시나리오에서의 상태 일관성 + - 네트워크 에러 상황에서의 복구 로직 + - 복잡한 사용자 시나리오의 End-to-End 동작 + */ +@MainActor +final class CalendarIntegrationTests: XCTestCase { + + private var sut: CalendarStore! + private var mockRepository: ConcurrencyMockRepository! + private var cancellables: Set! + private var capturedEffects: [CalendarEffect] = [] + + override func setUp() { + super.setUp() + + mockRepository = ConcurrencyMockRepository() + setupTestDI() + + sut = CalendarStore() + cancellables = Set() + capturedEffects = [] + + // Effect 스트림 구독 + sut.effect + .sink { [weak self] effect in + self?.capturedEffects.append(effect) + } + .store(in: &cancellables) + } + + override func tearDown() { + mockRepository.reset() + cancellables?.removeAll() + capturedEffects.removeAll() + sut = nil + mockRepository = nil + super.tearDown() + } + + /** + 테스트용 DI Container 설정 + 실제 Repository 대신 Mock Repository를 주입하여 격리된 테스트 환경 구성 + */ + private func setupTestDI() { + DIContainer.shared.container.register(CalendarRepositoryProtocol.self) { _ in + return self.mockRepository + }.inObjectScope(.transient) + + DIContainer.shared.container.register(CalendarUseCaseProtocol.self) { resolver in + let repository = resolver.resolve(CalendarRepositoryProtocol.self)! + return CalendarUseCase(calendarRepository: repository) + }.inObjectScope(.transient) + } + + // MARK: - 동시성 테스트 + + /** + 다중 할일 동시 업데이트 시나리오 테스트 + + 시나리오: + 1. 5개의 할일이 있는 상태 + 2. 모든 할일을 동시에 완료 처리 + 3. 네트워크 지연을 시뮬레이션하여 현실적인 조건 재현 + + 검증 사항: + - 모든 API 호출이 올바르게 수행됨 + - 동시성 상황에서도 상태 일관성 유지 + - 모든 할일이 최종적으로 완료 상태로 변경됨 + */ + func test_concurrentTaskUpdates_maintainsStateConsistency() async { + // Given: 5개의 할일 아이템 준비 + let todoItems = (1...5).map { index in + createMockTodoItem(id: index, scheduleId: "schedule-\(index)") + } + + sut.updateState { state in + state.todoItems = todoItems + } + + // 현실적인 네트워크 지연 시뮬레이션 활성화 + mockRepository.shouldSimulateRealisticTiming = true + + // 작업 완료 추적을 위한 상태 설정 + let operationExpectation = XCTestExpectation(description: "모든 동시 작업이 완료되어야 함") + operationExpectation.expectedFulfillmentCount = 5 + + var completedOperations: Set = [] + mockRepository.onOperationCompleted = { operationId in + completedOperations.insert(operationId) + operationExpectation.fulfill() + } + + // When: 모든 할일을 동시에 완료 처리 + await withTaskGroup(of: Void.self) { group in + for i in 0..<5 { + group.addTask { [weak self] in + await self?.sut.send(.taskCompletionToggled(i)) + } + } + } + + // Then: 모든 작업 완료 대기 및 검증 + await fulfillment(of: [operationExpectation], timeout: 10.0) + + // Repository 레벨에서 모든 작업 완료 확인 + let allCompleted = await mockRepository.waitForAllOperationsToComplete(timeout: 2.0) + XCTAssertTrue(allCompleted, "모든 Repository 작업이 완료되어야 함") + + // 동시성 동작 검증 + XCTAssertEqual(completedOperations.count, 5, "5개의 작업이 모두 완료되어야 함") + XCTAssertEqual(mockRepository.updateScheduleStateCallCount, 5, "Repository가 정확히 5번 호출되어야 함") + + // 최종 상태 일관성 검증 + let completedCount = sut.state.todoItems.filter { $0.isCompleted }.count + XCTAssertEqual(completedCount, 5, "모든 할일이 완료 상태여야 함") + } + + /** + 단일 할일에 대한 빠른 연속 토글 테스트 (Race Condition 시나리오) + + 시나리오: + 1. 하나의 할일에 대해 빠른 연속으로 3번 토글 + 2. 각 요청 사이에 충분한 지연을 두어 Race Condition 유발 + + 검증 사항: + - 모든 토글 요청이 올바르게 처리됨 + - 최종 상태가 예측 가능한 범위 내에 있음 + - 시스템이 동시 요청을 안전하게 처리함 + */ + func test_rapidTaskToggling_handlesRaceConditions() async { + // Given: 단일 할일 준비 + let todoItem = createMockTodoItem(id: 1, scheduleId: "schedule-1") + sut.updateState { state in + state.todoItems = [todoItem] + } + + // Race condition 유발을 위한 긴 지연 설정 + mockRepository.shouldSimulateRealisticTiming = true + mockRepository.baseDelay = 0.1 + + let rapidToggleExpectation = XCTestExpectation(description: "빠른 연속 토글 작업이 완료되어야 함") + rapidToggleExpectation.expectedFulfillmentCount = 3 + + mockRepository.onOperationCompleted = { _ in + rapidToggleExpectation.fulfill() + } + + // When: 동일한 할일을 빠르게 3번 토글 + let toggleTasks = (0..<3).map { _ in + Task { + sut.send(.taskCompletionToggled(0)) + } + } + + // 모든 토글 작업 시작 + for task in toggleTasks { + await task.value + } + + // Then: 모든 작업 완료 대기 및 검증 + await fulfillment(of: [rapidToggleExpectation], timeout: 5.0) + + XCTAssertEqual(mockRepository.updateScheduleStateCallCount, 3, "모든 토글 요청이 처리되어야 함") + + // 최종 상태가 유효한 범위 내에 있는지 확인 (true 또는 false) + let finalState = sut.state.todoItems[0].isCompleted + XCTAssertTrue([true, false].contains(finalState), "최종 상태가 예측 가능한 범위 내에 있어야 함") + } + + // MARK: - 에러 처리 테스트 + + /** + 네트워크 에러 발생 시 상태 복구 로직 테스트 + + 시나리오: + 1. 할일 완료 상태 변경 시도 + 2. 네트워크 에러 발생으로 API 호출 실패 + 3. UI 상태가 원래대로 복구되고 에러 Effect 발생 + + 검증 사항: + - 에러 발생 시 할일 상태가 원래대로 복구됨 + - 적절한 에러 Effect가 발생함 + - 시스템이 안정적으로 에러 상황을 처리함 + */ + func test_taskCompletionToggled_withNetworkError_recoversStateAndShowsError() async { + // Given: 하나의 할일과 네트워크 에러 시뮬레이션 + let todoItem = createMockTodoItem(id: 1, scheduleId: "schedule-1") + sut.updateState { state in + state.todoItems = [todoItem] + } + + let networkError = NetworkError.serverError("네트워크 연결 실패") + mockRepository.updateScheduleStateResult = .failure(networkError) + + let originalCompletionState = sut.state.todoItems[0].isCompleted + + // When: 할일 완료 상태 토글 시도 + sut.send(.taskCompletionToggled(0)) + await waitForAsyncOperations() + + // Then: 상태 복구 및 에러 처리 검증 + XCTAssertEqual(sut.state.todoItems[0].isCompleted, originalCompletionState, + "에러 발생 시 할일 상태가 원래대로 복구되어야 함") + + // 에러 Effect 발생 확인 + let errorEffects = capturedEffects.compactMap { effect in + if case .showError = effect { return effect } + return nil + } + XCTAssertEqual(errorEffects.count, 1, "에러 Effect가 정확히 한 번 발생해야 함") + } + + // MARK: - 헬퍼 메서드 + + /** + 비동기 작업 완료를 위한 대기 시간 + */ + private func waitForAsyncOperations() async { + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + } + + /** + 테스트용 Mock TodoItem 생성 + + - Parameters: + - id: 할일 ID + - scheduleId: 연결된 스케줄 ID (옵셔널) + - Returns: 테스트용 TodoItem 인스턴스 + */ + private func createMockTodoItem(id: Int, scheduleId: String? = nil) -> DesignSystem.TodoItem { + return DesignSystem.TodoItem( + id: id, + title: "테스트 할일 \(id)", + isCompleted: false, + category: DesignSystem.TodoCategory(name: "테스트", color: .blue), + time: "오전 9:00", + scheduleId: scheduleId + ) + } +} diff --git a/Projects/Calendar/CalendarFeature/Tests/MockCalendarRepository.swift b/Projects/Calendar/CalendarFeature/Tests/MockCalendarRepository.swift new file mode 100644 index 0000000..42291d8 --- /dev/null +++ b/Projects/Calendar/CalendarFeature/Tests/MockCalendarRepository.swift @@ -0,0 +1,259 @@ +// +// MockCalendarRepository.swift +// CalendarFeature +// +// Created by 이지훈 on 7/24/25. +// Copyright © 2025 com.noweekend. All rights reserved. +// + +import Foundation +@testable import CalendarDomain +@testable import CalendarFeature + +// MARK: - Mock Repository +/** + CalendarRepositoryProtocol의 테스트용 Mock 객체입니다. + 실제 네트워크나 데이터베이스 대신, 미리 정해진 데이터를 반환하여 유즈케이스나 뷰모델을 독립적으로 테스트할 수 있도록 합니다. + */ +class MockCalendarRepository: CalendarRepositoryProtocol { + + // MARK: - Mock Data Storage + /// `getSchedules` 호출 시 반환될 모의 일일 스케줄 데이터 배열입니다. + var mockSchedules: [DailySchedule] = [] + /// `createSchedule` 성공 시 반환될 모의 스케줄 객체입니다. + var mockCreatedSchedule: Schedule? + /// `updateSchedule` 또는 `updateScheduleState` 성공 시 반환될 모의 스케줄 객체입니다. + var mockUpdatedSchedule: Schedule? + /// `getRecommendedTags` 성공 시 반환될 모의 추천 태그 응답입니다. + var mockRecommendTags: RecommendTagResponse? + + // MARK: - Call Tracking + /// 각 프로토콜 메소드가 호출된 횟수를 기록하여 테스트에서 검증용으로 사용합니다. + var getSchedulesCallCount = 0 + var createScheduleCallCount = 0 + var updateScheduleCallCount = 0 + var deleteScheduleCallCount = 0 + var updateScheduleStateCallCount = 0 + + /// 마지막으로 호출된 메소드의 파라미터를 저장하여, 올바른 인자와 함께 호출되었는지 검증하는 데 사용합니다. + var lastGetSchedulesParams: (startDate: String, endDate: String)? + var lastCreatedScheduleRequest: CreateScheduleRequest? + var lastUpdatedScheduleParams: (id: String, request: UpdateScheduleRequest)? + var lastDeletedScheduleId: String? + var lastUpdatedStateParams: (id: String, isComplete: Bool)? + + // MARK: - Error Simulation + /// `true`로 설정하면 모든 메소드가 에러를 발생시킵니다. 에러 핸들링 로직 테스트에 사용됩니다. + var shouldThrowError = false + /// `shouldThrowError`가 true일 때 발생시킬 특정 에러 객체입니다. + var errorToThrow: Error = NSError(domain: "MockError", code: 500, userInfo: [NSLocalizedDescriptionKey: "Mock error"]) + + // MARK: - Protocol Implementation + /// 기간별 스케줄 조회 동작을 시뮬레이션합니다. + func getSchedules(startDate: String, endDate: String) async throws -> [DailySchedule] { + getSchedulesCallCount += 1 + lastGetSchedulesParams = (startDate, endDate) + + if shouldThrowError { throw errorToThrow } + return mockSchedules + } + + /// 스케줄 생성 동작을 시뮬레이션합니다. + func createSchedule(request: CreateScheduleRequest) async throws -> Schedule { + createScheduleCallCount += 1 + lastCreatedScheduleRequest = request + + if shouldThrowError { throw errorToThrow } + + // 미리 설정된 mock 객체가 없으면, 요청을 기반으로 새로운 mock 객체를 생성하여 반환합니다. + return mockCreatedSchedule ?? createMockSchedule(from: request) + } + + /// 스케줄 수정 동작을 시뮬레이션합니다. + func updateSchedule(id: String, request: UpdateScheduleRequest) async throws -> Schedule { + updateScheduleCallCount += 1 + lastUpdatedScheduleParams = (id, request) + + if shouldThrowError { throw errorToThrow } + return mockUpdatedSchedule ?? createMockUpdatedSchedule(id: id, from: request) + } + + /// 스케줄 삭제 동작을 시뮬레이션합니다. + func deleteSchedule(id: String) async throws { + deleteScheduleCallCount += 1 + lastDeletedScheduleId = id + + if shouldThrowError { throw errorToThrow } + } + + /// 추천 태그 조회 동작을 시뮬레이션합니다. + func getRecommendedTags() async throws -> RecommendTagResponse { + if shouldThrowError { throw errorToThrow } + return mockRecommendTags ?? createMockRecommendTags() + } + + /// 스케줄 완료 상태 변경 동작을 시뮬레이션합니다. + func updateScheduleState(id: String, isComplete: Bool) async throws -> Schedule { + updateScheduleStateCallCount += 1 + lastUpdatedStateParams = (id, isComplete) + + if shouldThrowError { throw errorToThrow } + return mockUpdatedSchedule ?? createMockScheduleWithCompletion(id: id, isComplete: isComplete) + } + + // MARK: - Helper Methods + /// 생성 요청 객체로부터 모의 스케줄을 생성합니다. + private func createMockSchedule(from request: CreateScheduleRequest) -> Schedule { + Schedule(id: "mock-\(UUID().uuidString)", title: request.title, startTime: request.startTime, endTime: request.endTime, category: request.category, temperature: request.temperature, allDay: request.allDay, alarmOption: request.alarmOption, completed: false) + } + + /// 수정 요청 객체로부터 모의 스케줄을 생성합니다. + private func createMockUpdatedSchedule(id: String, from request: UpdateScheduleRequest) -> Schedule { + Schedule(id: id, title: request.title, startTime: request.startTime, endTime: request.endTime, category: request.category, temperature: request.temperature, allDay: request.allDay, alarmOption: request.alarmOption, completed: false) + } + + /// 특정 완료 상태를 가진 모의 스케줄을 생성합니다. + private func createMockScheduleWithCompletion(id: String, isComplete: Bool) -> Schedule { + Schedule(id: id, title: "Mock Schedule", startTime: Date(), endTime: Date().addingTimeInterval(3600), category: .company, temperature: 50, allDay: false, alarmOption: .none, completed: isComplete) + } + + /// 기본 모의 추천 태그 응답을 생성합니다. + private func createMockRecommendTags() -> RecommendTagResponse { + RecommendTagResponse(result: "SUCCESS", data: RecommendTagData(firstRecommendTag: RecommendTag(content: "운동하기"), secondRecommendTag: RecommendTag(content: "회의하기"), thirdRecommendTag: RecommendTag(content: "휴식하기")), error: nil) + } + + // MARK: - Test Helper Methods + /// 각 테스트 케이스가 끝난 후 Mock 객체의 모든 상태를 초기화하여 테스트 간 독립성을 보장합니다. + func reset() { + mockSchedules = [] + mockCreatedSchedule = nil + mockUpdatedSchedule = nil + mockRecommendTags = nil + + getSchedulesCallCount = 0 + createScheduleCallCount = 0 + updateScheduleCallCount = 0 + deleteScheduleCallCount = 0 + updateScheduleStateCallCount = 0 + + lastGetSchedulesParams = nil + lastCreatedScheduleRequest = nil + lastUpdatedScheduleParams = nil + lastDeletedScheduleId = nil + lastUpdatedStateParams = nil + + shouldThrowError = false + } +} + +// MARK: - Mock UseCase +/** + CalendarUseCaseProtocol의 테스트용 Mock 객체입니다. + 뷰모델 테스트에서 유즈케이스의 응답(성공 또는 실패)을 미리 정의하여, 뷰모델이 각 상황에 맞게 올바르게 상태를 변경하는지 검증하는 데 사용됩니다. + */ +class MockCalendarUseCase: CalendarUseCaseProtocol { + + // MARK: - Mock Results + /// 각 유즈케이스 메소드가 반환할 `Result` 타입의 결과 값입니다. `.success` 또는 `.failure`를 설정하여 테스트 시나리오를 제어할 수 있습니다. + var getWeeklySchedulesResult: Result<[DailySchedule], Error> = .success([]) + var getMonthlySchedulesResult: Result<[DailySchedule], Error> = .success([]) + var getSchedulesForDateRangeResult: Result<[DailySchedule], Error> = .success([]) + var createScheduleResult: Result = .success(MockHelpers.createMockSchedule()) + var updateScheduleResult: Result = .success(MockHelpers.createMockSchedule()) + var deleteScheduleResult: Result = .success(()) + var getRecommendedTagsResult: Result = .success(MockHelpers.createMockRecommendTags()) + var updateScheduleStateResult: Result = .success(MockHelpers.createMockSchedule()) + + // MARK: - Call Tracking + /// 각 유즈케이스 메소드가 호출된 횟수를 기록합니다. + var getWeeklySchedulesCallCount = 0 + var getMonthlySchedulesCallCount = 0 + var getSchedulesForDateRangeCallCount = 0 + var createScheduleCallCount = 0 + var updateScheduleCallCount = 0 + var deleteScheduleCallCount = 0 + var getRecommendedTagsCallCount = 0 + var updateScheduleStateCallCount = 0 + + // MARK: - Protocol Implementation + /// 주간 스케줄 조회 동작을 시뮬레이션하고 미리 설정된 결과를 반환합니다. + func getWeeklySchedules(for date: Date) async throws -> [DailySchedule] { + getWeeklySchedulesCallCount += 1 + return try getWeeklySchedulesResult.get() + } + + /// 월간 스케줄 조회 동작을 시뮬레이션하고 미리 설정된 결과를 반환합니다. + func getMonthlySchedules(for date: Date) async throws -> [DailySchedule] { + getMonthlySchedulesCallCount += 1 + return try getMonthlySchedulesResult.get() + } + + /// 기간 스케줄 조회 동작을 시뮬레이션하고 미리 설정된 결과를 반환합니다. + func getSchedulesForDateRange(startDate: Date, endDate: Date) async throws -> [DailySchedule] { + getSchedulesForDateRangeCallCount += 1 + return try getSchedulesForDateRangeResult.get() + } + + /// 스케줄 생성 동작을 시뮬레이션하고 미리 설정된 결과를 반환합니다. + func createSchedule(title: String, date: Date, startTime: Date, endTime: Date, category: ScheduleCategory, temperature: Int, allDay: Bool, alarmOption: AlarmOption) async throws -> Schedule { + createScheduleCallCount += 1 + return try createScheduleResult.get() + } + + /// 스케줄 수정 동작을 시뮬레이션하고 미리 설정된 결과를 반환합니다. + func updateSchedule(id: String, title: String, startTime: Date, endTime: Date, category: ScheduleCategory, temperature: Int, allDay: Bool, alarmOption: AlarmOption) async throws -> Schedule { + updateScheduleCallCount += 1 + return try updateScheduleResult.get() + } + + /// 스케줄 삭제 동작을 시뮬레이션하고 미리 설정된 결과를 반환합니다. + func deleteSchedule(id: String) async throws { + deleteScheduleCallCount += 1 + try deleteScheduleResult.get() + } + + /// 추천 태그 조회 동작을 시뮬레이션하고 미리 설정된 결과를 반환합니다. + func getRecommendedTags() async throws -> RecommendTagResponse { + getRecommendedTagsCallCount += 1 + return try getRecommendedTagsResult.get() + } + + /// 스케줄 완료 상태 변경 동작을 시뮬레이션하고 미리 설정된 결과를 반환합니다. + func updateScheduleState(id: String, isComplete: Bool) async throws -> Schedule { + updateScheduleStateCallCount += 1 + return try updateScheduleStateResult.get() + } +} + +// MARK: - Mock Helpers +/// 테스트에 필요한 다양한 종류의 모의 데이터를 쉽게 생성하기 위한 정적 헬퍼 메소드들을 제공합니다. +enum MockHelpers { + /// 기본 모의 `Schedule` 객체를 생성합니다. + static func createMockSchedule(id: String = "mock-schedule", title: String = "Mock Schedule", category: ScheduleCategory = .company, temperature: Int = 50, completed: Bool = false) -> Schedule { + Schedule(id: id, title: title, startTime: Date(), endTime: Date().addingTimeInterval(3600), category: category, temperature: temperature, allDay: false, alarmOption: .none, completed: completed) + } + + /// 기본 모의 `RecommendTagResponse` 객체를 생성합니다. + static func createMockRecommendTags() -> RecommendTagResponse { + RecommendTagResponse(result: "SUCCESS", data: RecommendTagData(firstRecommendTag: RecommendTag(content: "운동하기"), secondRecommendTag: RecommendTag(content: "회의하기"), thirdRecommendTag: RecommendTag(content: "휴식하기")), error: nil) + } + + /// 기본 모의 `DailySchedule` 객체를 생성합니다. + static func createMockDailySchedule(date: String, schedules: [Schedule] = []) -> DailySchedule { + let defaultSchedules = schedules.isEmpty ? [createMockSchedule()] : schedules + return DailySchedule(date: date, dailyTemperature: 50, schedules: defaultSchedules) + } +} + +// MARK: - Result Extension +extension Result { + /// `Result` 타입의 값을 추출하거나, 실패 시 에러를 던지는 편의 메소드입니다. + func get() throws -> Success { + switch self { + case .success(let value): + return value + case .failure(let error): + throw error + } + } +}