diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuidanceViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuidanceViewModel.swift new file mode 100644 index 0000000..d250d79 --- /dev/null +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuidanceViewModel.swift @@ -0,0 +1,96 @@ +import Foundation +import Observation + +@Observable +@MainActor +final class WorkoutExerciseGuidanceViewModel { + private let service: ExerciseGuidanceReading? + + private(set) var firstTimeStatuses: [String: ExerciseFirstTimeStatusResponse] = [:] + private(set) var firstTimeStatusErrors: [String: String] = [:] + private(set) var loadingFirstTimeStatusExerciseIDs: Set = [] + private(set) var exerciseGuides: [String: ExerciseGuideResponse] = [:] + private(set) var exerciseGuideErrors: [String: String] = [:] + private(set) var loadingExerciseGuideIDs: Set = [] + + init(service: ExerciseGuidanceReading?) { + self.service = service + } + + func shouldShowFirstExercisePrompt(for exercise: WorkoutSessionExercise) -> Bool { + guard let status = firstTimeStatuses[exercise.id], status.exerciseId == exercise.id else { + return !exercise.hasPreviousPerformance + } + return status.isFirstTime && status.guideAvailable + } + + func loadFirstTimeStatus(for exerciseID: String, in plan: WorkoutSessionPlan) async { + guard let service, + plan.exercises.contains(where: { $0.id == exerciseID }), + !loadingFirstTimeStatusExerciseIDs.contains(exerciseID) else { return } + + loadingFirstTimeStatusExerciseIDs.insert(exerciseID) + defer { loadingFirstTimeStatusExerciseIDs.remove(exerciseID) } + + do { + let response = try await service.firstTimeStatus(exerciseId: exerciseID) + guard response.exerciseId == exerciseID else { + throw APIClientError.invalidResponse + } + + firstTimeStatuses[exerciseID] = response + firstTimeStatusErrors.removeValue(forKey: exerciseID) + } catch { + guard !Task.isCancelled else { return } + firstTimeStatusErrors[exerciseID] = Self.errorMessage( + for: error, + fallback: "첫 기록 상태를 불러오지 못했어요." + ) + } + } + + func exerciseGuide(for exerciseID: String) -> ExerciseGuideResponse? { + exerciseGuides[exerciseID] + } + + func exerciseGuideError(for exerciseID: String) -> String? { + exerciseGuideErrors[exerciseID] + } + + func isLoadingExerciseGuide(for exerciseID: String) -> Bool { + loadingExerciseGuideIDs.contains(exerciseID) + } + + func loadExerciseGuide(for exerciseID: String, in plan: WorkoutSessionPlan) async { + guard let service, + plan.exercises.contains(where: { $0.id == exerciseID }), + !loadingExerciseGuideIDs.contains(exerciseID) else { return } + + loadingExerciseGuideIDs.insert(exerciseID) + defer { loadingExerciseGuideIDs.remove(exerciseID) } + + do { + let response = try await service.guide(exerciseId: exerciseID) + guard response.exerciseId == exerciseID else { + throw APIClientError.invalidResponse + } + + exerciseGuides[exerciseID] = response + exerciseGuideErrors.removeValue(forKey: exerciseID) + } catch { + guard !Task.isCancelled else { return } + exerciseGuideErrors[exerciseID] = Self.errorMessage( + for: error, + fallback: "자세 가이드를 불러오지 못했어요." + ) + } + } + + private static func errorMessage(for error: Error, fallback: String) -> String { + if let apiError = error as? APIClientError, + case let .server(_, detail) = apiError { + return detail.message + } + return fallback + } +} diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuideScreen.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuideScreen.swift index 666bd6b..4541c85 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuideScreen.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutExerciseGuideScreen.swift @@ -1,9 +1,30 @@ +import AVKit import SwiftUI struct WorkoutExerciseGuideScreen: View { let exercise: WorkoutSessionExercise + let guide: ExerciseGuideResponse? + let guideError: String? + let isLoadingGuide: Bool + let onRetryGuide: () -> Void let onClose: () -> Void + init( + exercise: WorkoutSessionExercise, + guide: ExerciseGuideResponse? = nil, + guideError: String? = nil, + isLoadingGuide: Bool = false, + onRetryGuide: @escaping () -> Void = {}, + onClose: @escaping () -> Void + ) { + self.exercise = exercise + self.guide = guide + self.guideError = guideError + self.isLoadingGuide = isLoadingGuide + self.onRetryGuide = onRetryGuide + self.onClose = onClose + } + var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 19) { @@ -19,21 +40,29 @@ struct WorkoutExerciseGuideScreen: View { private var videoHero: some View { ZStack { - Image(exercise.imageAssetName) - .resizable() - .scaledToFill() - .frame(maxWidth: .infinity) - .frame(height: 300) - .clipped() + if let mediaURL = validMediaURL { + WorkoutGuideVideoPlayer(url: mediaURL) + .frame(maxWidth: .infinity) + .frame(height: 300) + .clipped() + .accessibilityIdentifier("workout.guide.video-player") + } else { + Image(exercise.imageAssetName) + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity) + .frame(height: 300) + .clipped() - Text("영상 준비 중") - .font(GymupTypography.uiBold) - .foregroundStyle(GymupColors.primaryText) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(GymupColors.canvas.opacity(0.85), in: Capsule()) - .accessibilityLabel("자세 영상은 준비 중이에요. 아래 자세 설명을 확인해 주세요.") - .accessibilityIdentifier("workout.guide.video-unavailable") + Text("영상 준비 중") + .font(GymupTypography.uiBold) + .foregroundStyle(GymupColors.primaryText) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(GymupColors.canvas.opacity(0.85), in: Capsule()) + .accessibilityLabel("자세 영상은 준비 중이에요. 아래 자세 설명을 확인해 주세요.") + .accessibilityIdentifier("workout.guide.video-unavailable") + } } .frame(height: 300) .overlay(alignment: .topTrailing) { @@ -63,6 +92,16 @@ struct WorkoutExerciseGuideScreen: View { .foregroundStyle(GymupColors.secondaryText) } + if let guideError { + WorkoutSessionRecordRetryBanner( + message: guideError, + isLoading: isLoadingGuide, + retryAccessibilityLabel: "자세 가이드 다시 시도", + retryAccessibilityIdentifier: "workout.guide.retry", + onRetry: onRetryGuide + ) + } + muscleChips keySteps commonMistakes @@ -74,7 +113,7 @@ struct WorkoutExerciseGuideScreen: View { private var muscleChips: some View { HStack(spacing: 8) { - ForEach(Array(exercise.guideMuscleGroups.enumerated()), id: \.offset) { index, muscle in + ForEach(Array(displayedMuscles.enumerated()), id: \.offset) { index, muscle in Text(muscle) .font(GymupTypography.uiMedium) .foregroundStyle(index == 0 ? .white : GymupColors.primaryText) @@ -96,7 +135,7 @@ struct WorkoutExerciseGuideScreen: View { private var keySteps: some View { VStack(spacing: 0) { - ForEach(Array(exercise.formSteps.prefix(3).enumerated()), id: \.offset) { index, step in + ForEach(Array(displayedKeyPoints.prefix(3).enumerated()), id: \.offset) { index, step in HStack(alignment: .center, spacing: 14) { Text(String(format: "%02d", index + 1)) .font(.custom("BarlowCondensed-ExtraBold", size: 18)) @@ -122,7 +161,7 @@ struct WorkoutExerciseGuideScreen: View { .tracking(0.16) .foregroundStyle(GymupColors.critical) - ForEach(Array(exercise.commonMistakes.prefix(2).enumerated()), id: \.offset) { _, mistake in + ForEach(Array(displayedCommonMistakes.prefix(2).enumerated()), id: \.offset) { _, mistake in HStack(spacing: 10) { Image("R3IconClose") .resizable() @@ -149,4 +188,49 @@ struct WorkoutExerciseGuideScreen: View { .accessibilityIdentifier("workout.guide.confirm") .background(GymupColors.canvas) } + + private var validMediaURL: URL? { + guard let value = guide?.mediaUrl, + let url = URL(string: value), + url.scheme?.lowercased() == "https", + url.host != nil else { return nil } + return url + } + + private var displayedMuscles: [String] { + guide?.targetMuscles.isEmpty == false ? guide?.targetMuscles ?? [] : exercise.guideMuscleGroups + } + + private var displayedKeyPoints: [String] { + guide?.keyPoints.isEmpty == false ? guide?.keyPoints ?? [] : exercise.formSteps + } + + private var displayedCommonMistakes: [String] { + guide?.commonMistakes.isEmpty == false ? guide?.commonMistakes ?? [] : exercise.commonMistakes + } +} + +private struct WorkoutGuideVideoPlayer: View { + let url: URL + @State private var player: AVPlayer? + + var body: some View { + Group { + if let player { + VideoPlayer(player: player) + } else { + GymupColors.canvas + .overlay { ProgressView().tint(GymupColors.primaryText) } + } + } + .onAppear { + guard player == nil else { return } + player = AVPlayer(url: url) + } + .onChange(of: url) { _, newURL in + player?.pause() + player = AVPlayer(url: newURL) + } + .onDisappear { player?.pause() } + } } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift index fdb798c..4a83f2c 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift @@ -3,7 +3,12 @@ import UIKit extension WorkoutSessionView { func loadPreviousRecord(for exerciseID: String) async { - await viewModel.loadPreviousRecord(for: exerciseID) + async let previousRecord: Void = viewModel.loadPreviousRecord(for: exerciseID) + async let firstTimeStatus: Void = viewModel.exerciseGuidance.loadFirstTimeStatus( + for: exerciseID, + in: viewModel.plan + ) + _ = await (previousRecord, firstTimeStatus) guard viewModel.currentExercise?.id == exerciseID else { return } showFirstExercisePromptIfNeeded() } @@ -227,7 +232,7 @@ extension WorkoutSessionView { func showFirstExercisePromptIfNeeded() { guard let exercise = viewModel.currentExercise, - !exercise.hasPreviousPerformance, + viewModel.exerciseGuidance.shouldShowFirstExercisePrompt(for: exercise), !promptedExerciseIDs.contains(exercise.id) else { return } promptedExerciseIDs.insert(exercise.id) isShowingFirstExercisePrompt = true diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift index 657c1ad..dfb6841 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift @@ -28,11 +28,13 @@ struct WorkoutSessionView: View { @MainActor init(routineID: String, historyStore: HistoryStore) { + let exerciseReadService = Self.makePreviousRecordService() _viewModel = State( initialValue: WorkoutSessionViewModel( routineID: routineID, historyStore: historyStore, - previousRecordService: Self.makePreviousRecordService() + previousRecordService: exerciseReadService, + exerciseGuidanceService: exerciseReadService ) ) self.historyStore = historyStore @@ -45,12 +47,14 @@ struct WorkoutSessionView: View { serverWorkoutID: String? = nil, startedAt: Date = .now ) { + let exerciseReadService = Self.makePreviousRecordService() _viewModel = State( initialValue: WorkoutSessionViewModel( plan: plan, startedAt: startedAt, historyStore: historyStore, - previousRecordService: Self.makePreviousRecordService(), + previousRecordService: exerciseReadService, + exerciseGuidanceService: exerciseReadService, serverWorkoutID: serverWorkoutID, progressService: serverWorkoutID == nil ? nil : Self.makeProgressService(), nextSetService: serverWorkoutID == nil ? nil : Self.makeWorkoutReadService() @@ -142,6 +146,14 @@ struct WorkoutSessionView: View { if let exercise = viewModel.currentExercise { WorkoutExerciseGuideScreen( exercise: exercise, + guide: viewModel.exerciseGuidance.exerciseGuide(for: exercise.id), + guideError: viewModel.exerciseGuidance.exerciseGuideError(for: exercise.id), + isLoadingGuide: viewModel.exerciseGuidance.isLoadingExerciseGuide(for: exercise.id), + onRetryGuide: { + Task { + await viewModel.exerciseGuidance.loadExerciseGuide(for: exercise.id, in: viewModel.plan) + } + }, onClose: { isShowingCurrentGuide = false } ) } @@ -198,14 +210,16 @@ struct WorkoutSessionView: View { } .onChange(of: viewModel.currentExerciseIndex) { _, _ in isShowingCurrentGuide = false - showFirstExercisePromptIfNeeded() + } + .onChange(of: isShowingCurrentGuide) { _, isShowing in + guard isShowing, let exerciseID = viewModel.currentExercise?.id else { return } + Task { await viewModel.exerciseGuidance.loadExerciseGuide(for: exerciseID, in: viewModel.plan) } } .onChange(of: viewModel.finishedAt) { _, finishedAt in guard finishedAt != nil else { return } settings.cancelRestNotification(sessionID: liveActivitySessionID) Task { await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) } } - .task { showFirstExercisePromptIfNeeded() } .task(id: "\(viewModel.hasStarted)-\(viewModel.currentExercise?.id ?? "")") { guard viewModel.hasStarted, let exerciseID = viewModel.currentExercise?.id else { return } await loadPreviousRecord(for: exerciseID) diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift index eaa1359..d220a3a 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift @@ -13,6 +13,7 @@ final class WorkoutSessionViewModel { private let historyStore: HistoryStore private let goalSettings: OnboardingStoredSettings? private let previousRecordService: ExerciseReadAPIService? + let exerciseGuidance: WorkoutExerciseGuidanceViewModel let nextSetService: WorkoutNextSetReading? let progressService: WorkoutSessionProgressing? private(set) var previousRecordErrors: [String: String] = [:] @@ -44,6 +45,7 @@ final class WorkoutSessionViewModel { historyStore: HistoryStore? = nil, goalSettings: OnboardingStoredSettings? = .load(), previousRecordService: ExerciseReadAPIService? = nil, + exerciseGuidanceService: ExerciseGuidanceReading? = nil, progressService: WorkoutSessionProgressing? = nil, nextSetService: WorkoutNextSetReading? = nil ) { @@ -53,6 +55,7 @@ final class WorkoutSessionViewModel { self.historyStore = historyStore ?? HistoryStore() self.goalSettings = goalSettings self.previousRecordService = previousRecordService + exerciseGuidance = WorkoutExerciseGuidanceViewModel(service: exerciseGuidanceService) self.progressService = progressService self.nextSetService = nextSetService serverWorkoutID = nil @@ -65,6 +68,7 @@ final class WorkoutSessionViewModel { historyStore: HistoryStore? = nil, goalSettings: OnboardingStoredSettings? = .load(), previousRecordService: ExerciseReadAPIService? = nil, + exerciseGuidanceService: ExerciseGuidanceReading? = nil, serverWorkoutID: String? = nil, progressService: WorkoutSessionProgressing? = nil, nextSetService: WorkoutNextSetReading? = nil @@ -75,6 +79,7 @@ final class WorkoutSessionViewModel { self.historyStore = historyStore ?? HistoryStore() self.goalSettings = goalSettings self.previousRecordService = previousRecordService + exerciseGuidance = WorkoutExerciseGuidanceViewModel(service: exerciseGuidanceService) self.serverWorkoutID = serverWorkoutID self.progressService = progressService self.nextSetService = nextSetService diff --git a/GYMUP/Sources/Networking/ExerciseReadAPIService.swift b/GYMUP/Sources/Networking/ExerciseReadAPIService.swift index 7765350..565deac 100644 --- a/GYMUP/Sources/Networking/ExerciseReadAPIService.swift +++ b/GYMUP/Sources/Networking/ExerciseReadAPIService.swift @@ -31,13 +31,22 @@ struct PreviousExerciseRecordResponse: Codable, Equatable { let performedAt: String } -struct ExerciseReadAPIService { +protocol ExerciseGuidanceReading { + @MainActor + func firstTimeStatus(exerciseId: String) async throws -> ExerciseFirstTimeStatusResponse + + @MainActor + func guide(exerciseId: String) async throws -> ExerciseGuideResponse +} + +struct ExerciseReadAPIService: ExerciseGuidanceReading { private let client: APIClient init(client: APIClient) { self.client = client } + @MainActor func firstTimeStatus(exerciseId: String) async throws -> ExerciseFirstTimeStatusResponse { let endpoint = APIEndpoint( method: .get, @@ -47,6 +56,7 @@ struct ExerciseReadAPIService { return try await client.send(endpoint, as: ExerciseFirstTimeStatusResponse.self) } + @MainActor func guide(exerciseId: String) async throws -> ExerciseGuideResponse { let endpoint = APIEndpoint( method: .get, diff --git a/GYMUP/Tests/WorkoutSessionGuidanceAPITests.swift b/GYMUP/Tests/WorkoutSessionGuidanceAPITests.swift new file mode 100644 index 0000000..9588ff5 --- /dev/null +++ b/GYMUP/Tests/WorkoutSessionGuidanceAPITests.swift @@ -0,0 +1,171 @@ +import XCTest +@testable import GYMUP + +final class WorkoutSessionGuidanceAPITests: XCTestCase { + @MainActor + func testFirstTimeStatusShowsPromptForFirstTimeExercise() async throws { + let service = MockExerciseGuidanceService() + service.firstTimeStatusResponse = firstTimeStatus(exerciseID: "bench-press", isFirstTime: true) + let viewModel = makeViewModel(service: service) + let exercise = try XCTUnwrap(viewModel.plan.exercises.first) + + XCTAssertTrue(exercise.hasPreviousPerformance) + await viewModel.exerciseGuidance.loadFirstTimeStatus(for: exercise.id, in: viewModel.plan) + + XCTAssertEqual(service.firstTimeStatusRequests, [exercise.id]) + XCTAssertTrue(viewModel.exerciseGuidance.shouldShowFirstExercisePrompt(for: exercise)) + } + + @MainActor + func testNonFirstTimeStatusHidesPromptDespiteMissingLocalRecord() async throws { + let service = MockExerciseGuidanceService() + service.firstTimeStatusResponse = firstTimeStatus(exerciseID: "incline-press", isFirstTime: false) + let viewModel = makeViewModel(service: service) + let exercise = try XCTUnwrap(viewModel.plan.exercises.dropFirst().first) + + XCTAssertFalse(exercise.hasPreviousPerformance) + await viewModel.exerciseGuidance.loadFirstTimeStatus(for: exercise.id, in: viewModel.plan) + + XCTAssertFalse(viewModel.exerciseGuidance.shouldShowFirstExercisePrompt(for: exercise)) + XCTAssertNil(viewModel.exerciseGuidance.firstTimeStatusErrors[exercise.id]) + } + + @MainActor + func testPromptIsHiddenWhenGuideIsUnavailable() async throws { + let service = MockExerciseGuidanceService() + service.firstTimeStatusResponse = firstTimeStatus( + exerciseID: "incline-press", + isFirstTime: true, + guideAvailable: false + ) + let viewModel = makeViewModel(service: service) + let exercise = try XCTUnwrap(viewModel.plan.exercises.dropFirst().first) + + await viewModel.exerciseGuidance.loadFirstTimeStatus(for: exercise.id, in: viewModel.plan) + + XCTAssertFalse(viewModel.exerciseGuidance.shouldShowFirstExercisePrompt(for: exercise)) + } + + @MainActor + func testFirstTimeStatusErrorFallsBackToLocalHistory() async throws { + let service = MockExerciseGuidanceService() + service.firstTimeStatusError = serverError(message: "상태 조회 실패") + let viewModel = makeViewModel(service: service) + let exercise = try XCTUnwrap(viewModel.plan.exercises.dropFirst().first) + + await viewModel.exerciseGuidance.loadFirstTimeStatus(for: exercise.id, in: viewModel.plan) + + XCTAssertNil(viewModel.exerciseGuidance.firstTimeStatuses[exercise.id]) + XCTAssertEqual(viewModel.exerciseGuidance.firstTimeStatusErrors[exercise.id], "상태 조회 실패") + XCTAssertTrue(viewModel.exerciseGuidance.shouldShowFirstExercisePrompt(for: exercise)) + } + + @MainActor + func testGuideResponseProvidesServerContent() async throws { + let service = MockExerciseGuidanceService() + service.guideResponse = guide(exerciseID: "bench-press") + let viewModel = makeViewModel(service: service) + + await viewModel.exerciseGuidance.loadExerciseGuide(for: "bench-press", in: viewModel.plan) + + XCTAssertEqual(service.guideRequests, ["bench-press"]) + XCTAssertEqual(viewModel.exerciseGuidance.exerciseGuide(for: "bench-press")?.targetMuscles, ["chest"]) + XCTAssertEqual(viewModel.exerciseGuidance.exerciseGuide(for: "bench-press")?.keyPoints, ["brace"]) + XCTAssertEqual(viewModel.exerciseGuidance.exerciseGuide(for: "bench-press")?.commonMistakes, ["bounce"]) + XCTAssertNil(viewModel.exerciseGuidance.exerciseGuideError(for: "bench-press")) + } + + @MainActor + func testGuideErrorKeepsLocalGuideAvailableAndRetryLoadsServerGuide() async throws { + let service = MockExerciseGuidanceService() + service.nextGuideError = serverError(message: "가이드 조회 실패") + let viewModel = makeViewModel(service: service) + + await viewModel.exerciseGuidance.loadExerciseGuide(for: "bench-press", in: viewModel.plan) + + XCTAssertNil(viewModel.exerciseGuidance.exerciseGuide(for: "bench-press")) + XCTAssertEqual(viewModel.exerciseGuidance.exerciseGuideError(for: "bench-press"), "가이드 조회 실패") + XCTAssertFalse(try XCTUnwrap(viewModel.plan.exercises.first).formSteps.isEmpty) + + service.guideResponse = guide(exerciseID: "bench-press") + await viewModel.exerciseGuidance.loadExerciseGuide(for: "bench-press", in: viewModel.plan) + + XCTAssertEqual(viewModel.exerciseGuidance.exerciseGuide(for: "bench-press")?.keyPoints, ["brace"]) + XCTAssertNil(viewModel.exerciseGuidance.exerciseGuideError(for: "bench-press")) + XCTAssertEqual(service.guideRequests, ["bench-press", "bench-press"]) + } + + @MainActor + func testGuideResponseWithDifferentExerciseIDIsRejected() async { + let service = MockExerciseGuidanceService() + service.guideResponse = guide(exerciseID: "incline-press") + let viewModel = makeViewModel(service: service) + + await viewModel.exerciseGuidance.loadExerciseGuide(for: "bench-press", in: viewModel.plan) + + XCTAssertNil(viewModel.exerciseGuidance.exerciseGuide(for: "bench-press")) + XCTAssertEqual(viewModel.exerciseGuidance.exerciseGuideError(for: "bench-press"), "자세 가이드를 불러오지 못했어요.") + } + + @MainActor + private func makeViewModel(service: MockExerciseGuidanceService) -> WorkoutSessionViewModel { + WorkoutSessionViewModel( + routineID: "upper-a", + goalSettings: nil, + exerciseGuidanceService: service + ) + } + + private func firstTimeStatus( + exerciseID: String, + isFirstTime: Bool, + guideAvailable: Bool = true + ) -> ExerciseFirstTimeStatusResponse { + ExerciseFirstTimeStatusResponse( + exerciseId: exerciseID, + isFirstTime: isFirstTime, + guideAvailable: guideAvailable + ) + } + + private func guide(exerciseID: String) -> ExerciseGuideResponse { + ExerciseGuideResponse( + exerciseId: exerciseID, + mediaUrl: "https://cdn.example.test/guide.mp4", + targetMuscles: ["chest"], + keyPoints: ["brace"], + commonMistakes: ["bounce"] + ) + } + + private func serverError(message: String) -> APIClientError { + .server(statusCode: 503, detail: ServerErrorDetail(code: "TEMPORARY_FAILURE", message: message)) + } +} + +@MainActor +private final class MockExerciseGuidanceService: ExerciseGuidanceReading { + var firstTimeStatusResponse: ExerciseFirstTimeStatusResponse? + var guideResponse: ExerciseGuideResponse? + var firstTimeStatusError: Error? + var nextGuideError: Error? + private(set) var firstTimeStatusRequests: [String] = [] + private(set) var guideRequests: [String] = [] + + func firstTimeStatus(exerciseId: String) async throws -> ExerciseFirstTimeStatusResponse { + firstTimeStatusRequests.append(exerciseId) + if let firstTimeStatusError { throw firstTimeStatusError } + guard let firstTimeStatusResponse else { throw APIClientError.invalidResponse } + return firstTimeStatusResponse + } + + func guide(exerciseId: String) async throws -> ExerciseGuideResponse { + guideRequests.append(exerciseId) + if let nextGuideError { + self.nextGuideError = nil + throw nextGuideError + } + guard let guideResponse else { throw APIClientError.invalidResponse } + return guideResponse + } +}