From 12307ce6aaccd7d685ec23bcdde6395706786f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Fri, 6 Nov 2026 20:55:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20[#192]=20=EB=8B=A4=EC=9D=8C=20?= =?UTF-8?q?=EC=84=B8=ED=8A=B8=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20API?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkoutSessionComponents.swift | 20 +++- .../WorkoutSessionRestTimerScreen.swift | 22 +++- .../WorkoutSessionView+Actions.swift | 17 +++ .../WorkoutSessionView+Content.swift | 6 +- .../WorkoutSession/WorkoutSessionView.swift | 3 +- .../WorkoutSessionViewModel+NextSetAPI.swift | 61 ++++++++++ .../WorkoutSessionViewModel.swift | 12 +- .../DebugOnboardingNetworking.swift | 12 ++ .../Networking/WorkoutReadAPIService.swift | 6 +- .../WorkoutSessionServerRestTestSupport.swift | 15 ++- .../Tests/WorkoutSessionServerRestTests.swift | 105 +++++++++++++++++- 11 files changed, 266 insertions(+), 13 deletions(-) create mode 100644 GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+NextSetAPI.swift diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionComponents.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionComponents.swift index 4e804b8..2ea3bf8 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionComponents.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionComponents.swift @@ -3,8 +3,24 @@ import SwiftUI struct WorkoutSessionRecordRetryBanner: View { let message: String let isLoading: Bool + let retryAccessibilityLabel: String + let retryAccessibilityIdentifier: String let onRetry: () -> Void + init( + message: String, + isLoading: Bool, + retryAccessibilityLabel: String = "이전 운동 기록 다시 시도", + retryAccessibilityIdentifier: String = "workout.previous-record.retry", + onRetry: @escaping () -> Void + ) { + self.message = message + self.isLoading = isLoading + self.retryAccessibilityLabel = retryAccessibilityLabel + self.retryAccessibilityIdentifier = retryAccessibilityIdentifier + self.onRetry = onRetry + } + var body: some View { HStack(spacing: GymupSpacing.small) { Image(systemName: "exclamationmark.circle") @@ -34,8 +50,8 @@ struct WorkoutSessionRecordRetryBanner: View { } .buttonStyle(.plain) .disabled(isLoading) - .accessibilityLabel("이전 운동 기록 다시 시도") - .accessibilityIdentifier("workout.previous-record.retry") + .accessibilityLabel(retryAccessibilityLabel) + .accessibilityIdentifier(retryAccessibilityIdentifier) } .padding(.horizontal, GymupSpacing.medium) .padding(.vertical, GymupSpacing.xSmall) diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionRestTimerScreen.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionRestTimerScreen.swift index 2fbed53..176aecd 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionRestTimerScreen.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionRestTimerScreen.swift @@ -11,6 +11,10 @@ struct WorkoutSessionRestTimerScreen: View { let previousRecordError: String? let isLoadingPreviousRecord: Bool let onRetryPreviousRecord: () -> Void + let nextSetPreview: NextWorkoutSetResponse? + let nextSetPreviewError: String? + let isLoadingNextSetPreview: Bool + let onRetryNextSetPreview: () -> Void @State private var isPlateCalculatorPresented = false private var nextSet: WorkoutSessionSet { exercise.sets[nextSetIndex] } @@ -38,6 +42,16 @@ struct WorkoutSessionRestTimerScreen: View { ) .padding(.top, 12) } + if let nextSetPreviewError { + WorkoutSessionRecordRetryBanner( + message: nextSetPreviewError, + isLoading: isLoadingNextSetPreview, + retryAccessibilityLabel: "다음 세트 정보 다시 시도", + retryAccessibilityIdentifier: "workout.next-set.retry", + onRetry: onRetryNextSetPreview + ) + .padding(.top, 12) + } Spacer(minLength: 64) plateCalculatorHint } @@ -163,17 +177,17 @@ struct WorkoutSessionRestTimerScreen: View { .clipShape(RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 0) { - Text("NEXT · SET \(nextSetIndex + 1) / \(exercise.sets.count)") + Text("NEXT · SET \(nextSetPreview?.setIndex ?? nextSetIndex + 1) / \(exercise.sets.count)") .font(GymupTypography.label) .tracking(0.16) .foregroundStyle(GymupColors.secondaryText) - Text("\(nextSet.weightKilograms.formatted(.number.precision(.fractionLength(0...1)))) KG × " - + "\(nextSet.repetitions)\(exercise.measure == .seconds ? "초" : "")") + Text("\((nextSetPreview?.weight ?? nextSet.weightKilograms).formatted(.number.precision(.fractionLength(0...1)))) KG × " + + "\(nextSetPreview?.reps ?? nextSet.repetitions)\(exercise.measure == .seconds ? "초" : "")") .font(GymupTypography.display) .foregroundStyle(GymupColors.primaryText) .lineLimit(1) .minimumScaleFactor(0.75) - Text("\(exercise.name) · 이전 \(exercise.previousPerformance(for: nextSetIndex) ?? "—")") + Text("\(nextSetPreview?.exerciseName ?? exercise.name) · 이전 \(exercise.previousPerformance(for: nextSetIndex) ?? "—")") .font(GymupTypography.uiCaption) .foregroundStyle(GymupColors.secondaryText) .lineLimit(1) diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift index 50950c0..fdb798c 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift @@ -60,6 +60,7 @@ extension WorkoutSessionView { UIImpactFeedbackGenerator(style: .light).impactOccurred() } syncRestActivity() + refreshNextSetPreviewIfServerResting() if viewModel.currentExerciseIndex == viewModel.plan.exercises.count - 1, viewModel.currentExercise?.isComplete == true { showWorkoutProcessing() @@ -78,6 +79,7 @@ extension WorkoutSessionView { completeSetSideEffects() } else if let failedAction, case .startRest = failedAction { syncRestActivity() + refreshNextSetPreviewIfServerResting() } else if let failedAction, case .skipRest = failedAction { settings.cancelRestNotification(sessionID: liveActivitySessionID) await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) @@ -85,6 +87,7 @@ extension WorkoutSessionView { syncRestActivity() } else if let failedAction, case .refreshRest = failedAction { syncRestActivity() + refreshNextSetPreviewIfServerResting() } } } @@ -114,6 +117,11 @@ extension WorkoutSessionView { } } + private func refreshNextSetPreviewIfServerResting() { + guard viewModel.isServerManagedRest else { return } + Task { await viewModel.loadNextSetPreview() } + } + func refreshRestOnForeground() { guard viewModel.isServerManagedRest else { return } if viewModel.refreshClock(at: Date()) { @@ -128,6 +136,7 @@ extension WorkoutSessionView { return } syncRestActivity() + await viewModel.loadNextSetPreview() } } @@ -237,6 +246,14 @@ extension WorkoutSessionView { DebugOnboardingNetworking.makeWorkoutSessionService() #else nil +#endif + } + + static func makeWorkoutReadService() -> WorkoutNextSetReading? { +#if DEBUG + DebugOnboardingNetworking.makeWorkoutReadService() +#else + nil #endif } } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift index 604550c..ed5ebca 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift @@ -36,7 +36,11 @@ extension WorkoutSessionView { onSkip: skipCurrentRest, previousRecordError: viewModel.previousRecordError(for: exercise.id), isLoadingPreviousRecord: viewModel.isLoadingPreviousRecord(for: exercise.id), - onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } } + onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } }, + nextSetPreview: viewModel.nextSetPreview, + nextSetPreviewError: viewModel.nextSetPreviewError, + isLoadingNextSetPreview: viewModel.isLoadingNextSetPreview, + onRetryNextSetPreview: { Task { await viewModel.loadNextSetPreview() } } ) } else if let exercise = viewModel.currentExercise { WorkoutTrainModeScreen( diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift index 5befc8e..657c1ad 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift @@ -52,7 +52,8 @@ struct WorkoutSessionView: View { historyStore: historyStore, previousRecordService: Self.makePreviousRecordService(), serverWorkoutID: serverWorkoutID, - progressService: serverWorkoutID == nil ? nil : Self.makeProgressService() + progressService: serverWorkoutID == nil ? nil : Self.makeProgressService(), + nextSetService: serverWorkoutID == nil ? nil : Self.makeWorkoutReadService() ) ) self.historyStore = historyStore diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+NextSetAPI.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+NextSetAPI.swift new file mode 100644 index 0000000..caebc7b --- /dev/null +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+NextSetAPI.swift @@ -0,0 +1,61 @@ +import Foundation + +extension WorkoutSessionViewModel { + func loadNextSetPreview() async { + guard !isLoadingNextSetPreview, + isServerManagedRest, + let workoutID = serverWorkoutID, + let nextSetService, + let exercise = currentExercise, + exercise.measure == .repetitions, + let localSetIndex = currentSetIndex else { return } + + let expectedSetIndex = localSetIndex + 1 + isLoadingNextSetPreview = true + nextSetPreview = nil + nextSetPreviewError = nil + defer { isLoadingNextSetPreview = false } + + do { + let response = try await nextSetService.nextSet(workoutId: workoutID) + guard isCurrentNextSetRequest( + workoutID: workoutID, + exerciseID: exercise.id, + setIndex: localSetIndex + ) else { return } + guard response.exerciseId == exercise.id, + response.setIndex == expectedSetIndex, + !response.exerciseName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + response.weight.isFinite, + response.weight >= 0, + response.reps >= 0 else { + throw APIClientError.invalidResponse + } + + nextSetPreview = response + } catch { + guard !Task.isCancelled, + isCurrentNextSetRequest( + workoutID: workoutID, + exerciseID: exercise.id, + setIndex: localSetIndex + ) else { return } + nextSetPreviewError = Self.nextSetPreviewMessage(for: error) + } + } + + private func isCurrentNextSetRequest(workoutID: String, exerciseID: String, setIndex: Int) -> Bool { + serverWorkoutID == workoutID && + isServerManagedRest && + currentExercise?.id == exerciseID && + currentSetIndex == setIndex + } + + private static func nextSetPreviewMessage(for error: Error) -> String { + if let apiError = error as? APIClientError, + case let .server(_, detail) = apiError { + return detail.message + } + return "다음 세트 정보를 불러오지 못했어요." + } +} diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift index 0d00302..19f4504 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift @@ -13,9 +13,13 @@ final class WorkoutSessionViewModel { private let historyStore: HistoryStore private let goalSettings: OnboardingStoredSettings? private let previousRecordService: ExerciseReadAPIService? + let nextSetService: WorkoutNextSetReading? let progressService: WorkoutSessionProgressing? private(set) var previousRecordErrors: [String: String] = [:] private(set) var loadingPreviousRecordExerciseIDs: Set = [] + private(set) var nextSetPreview: NextWorkoutSetResponse? + private(set) var nextSetPreviewError: String? + private(set) var isLoadingNextSetPreview = false var isSubmittingWorkoutAction = false var serverActionErrorMessage: String? var failedServerAction: FailedWorkoutServerAction? @@ -40,7 +44,8 @@ final class WorkoutSessionViewModel { historyStore: HistoryStore? = nil, goalSettings: OnboardingStoredSettings? = .load(), previousRecordService: ExerciseReadAPIService? = nil, - progressService: WorkoutSessionProgressing? = nil + progressService: WorkoutSessionProgressing? = nil, + nextSetService: WorkoutNextSetReading? = nil ) { plan = .sample(for: routineID) self.startedAt = startsImmediately ? startedAt : nil @@ -49,6 +54,7 @@ final class WorkoutSessionViewModel { self.goalSettings = goalSettings self.previousRecordService = previousRecordService self.progressService = progressService + self.nextSetService = nextSetService serverWorkoutID = nil } @@ -60,7 +66,8 @@ final class WorkoutSessionViewModel { goalSettings: OnboardingStoredSettings? = .load(), previousRecordService: ExerciseReadAPIService? = nil, serverWorkoutID: String? = nil, - progressService: WorkoutSessionProgressing? = nil + progressService: WorkoutSessionProgressing? = nil, + nextSetService: WorkoutNextSetReading? = nil ) { self.plan = plan self.startedAt = startsImmediately ? startedAt : nil @@ -70,6 +77,7 @@ final class WorkoutSessionViewModel { self.previousRecordService = previousRecordService self.serverWorkoutID = serverWorkoutID self.progressService = progressService + self.nextSetService = nextSetService } var isPaused: Bool { pausedAt != nil } diff --git a/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift b/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift index d4136ac..634ac83 100644 --- a/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift +++ b/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift @@ -179,5 +179,17 @@ enum DebugOnboardingNetworking { let client = APIClient(environment: environment, tokenProvider: tokenProvider) return WorkoutSessionAPIService(client: client) } + + static func makeWorkoutReadService() -> WorkoutReadAPIService? { + guard let baseURL = URL(string: "http://localhost:8000"), + let environment = try? APIEnvironment(baseURL: baseURL) + else { + return nil + } + + let tokenProvider = DevelopmentBearerTokenProvider(environment: environment) + let client = APIClient(environment: environment, tokenProvider: tokenProvider) + return WorkoutReadAPIService(client: client) + } } #endif diff --git a/GYMUP/Sources/Networking/WorkoutReadAPIService.swift b/GYMUP/Sources/Networking/WorkoutReadAPIService.swift index a6c2c84..75c23d1 100644 --- a/GYMUP/Sources/Networking/WorkoutReadAPIService.swift +++ b/GYMUP/Sources/Networking/WorkoutReadAPIService.swift @@ -41,7 +41,11 @@ struct NextWorkoutSetResponse: Codable, Equatable { let restEndsAt: String } -struct WorkoutReadAPIService { +protocol WorkoutNextSetReading { + func nextSet(workoutId: String) async throws -> NextWorkoutSetResponse +} + +struct WorkoutReadAPIService: WorkoutNextSetReading { private let client: APIClient init(client: APIClient) { diff --git a/GYMUP/Tests/WorkoutSessionServerRestTestSupport.swift b/GYMUP/Tests/WorkoutSessionServerRestTestSupport.swift index 0b88881..98583fd 100644 --- a/GYMUP/Tests/WorkoutSessionServerRestTestSupport.swift +++ b/GYMUP/Tests/WorkoutSessionServerRestTestSupport.swift @@ -2,7 +2,7 @@ import Foundation @testable import GYMUP @MainActor -final class MockWorkoutSessionRestService: WorkoutSessionProgressing { +final class MockWorkoutSessionRestService: WorkoutSessionProgressing, WorkoutNextSetReading { struct SetRequest { let workoutId: String let request: CreateWorkoutSetRequest @@ -17,6 +17,7 @@ final class MockWorkoutSessionRestService: WorkoutSessionProgressing { var setRequests: [SetRequest] = [] var restRequests: [RestRequest] = [] var currentRestRequests: [String] = [] + var nextSetRequests: [String] = [] var skipRequests: [String] = [] var extendRequests: [(String, Int)] = [] var nextRestStartError: Error? @@ -26,6 +27,8 @@ final class MockWorkoutSessionRestService: WorkoutSessionProgressing { var restStartCommitsBeforeThrow = false var currentRestResponse: CurrentRestResponse? var nextExtendResponse: ExtendCurrentRestResponse? + var nextSetResponse: NextWorkoutSetResponse? + var nextSetError: Error? private let startDate: Date init(startDate: Date) { @@ -86,6 +89,16 @@ final class MockWorkoutSessionRestService: WorkoutSessionProgressing { return currentRestResponse } + func nextSet(workoutId: String) async throws -> NextWorkoutSetResponse { + nextSetRequests.append(workoutId) + if let error = nextSetError { + nextSetError = nil + throw error + } + guard let nextSetResponse else { throw APIClientError.invalidResponse } + return nextSetResponse + } + func skipCurrentRest(workoutId: String) async throws -> SkipCurrentRestResponse { skipRequests.append(workoutId) if let error = nextSkipError { diff --git a/GYMUP/Tests/WorkoutSessionServerRestTests.swift b/GYMUP/Tests/WorkoutSessionServerRestTests.swift index 749d033..510a523 100644 --- a/GYMUP/Tests/WorkoutSessionServerRestTests.swift +++ b/GYMUP/Tests/WorkoutSessionServerRestTests.swift @@ -20,6 +20,103 @@ final class WorkoutSessionServerRestTests: XCTestCase { XCTAssertNil(viewModel.pendingRestStart) } + @MainActor + func testNextSetPreviewUsesValidatedServerValuesAndKeepsLocalPlanUnchanged() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + service.nextSetResponse = NextWorkoutSetResponse( + exerciseId: "exercise-1", + exerciseName: "Bench Press", + setIndex: 2, + weight: 47.5, + reps: 6, + restEndsAt: "not-used-by-preview" + ) + let viewModel = makeViewModel(service: service, start: start) + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didComplete) + + await viewModel.loadNextSetPreview() + + XCTAssertEqual(service.nextSetRequests, ["workout-1"]) + XCTAssertEqual(viewModel.nextSetPreview?.exerciseName, "Bench Press") + XCTAssertEqual(viewModel.nextSetPreview?.setIndex, 2) + XCTAssertEqual(viewModel.nextSetPreview?.weight, 47.5) + XCTAssertEqual(viewModel.nextSetPreview?.reps, 6) + XCTAssertNil(viewModel.nextSetPreviewError) + XCTAssertEqual(viewModel.currentExercise?.sets[1].weightKilograms, 42.5) + XCTAssertEqual(viewModel.currentExercise?.sets[1].repetitions, 8) + XCTAssertEqual(viewModel.currentSetIndex, 1) + } + + @MainActor + func testNextSetPreviewFailureKeepsLocalFallbackAndRetryLoadsServerValues() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + service.nextSetError = serverError(message: "서버 미리보기 오류") + let viewModel = makeViewModel(service: service, start: start) + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didComplete) + + await viewModel.loadNextSetPreview() + + XCTAssertNil(viewModel.nextSetPreview) + XCTAssertEqual(viewModel.nextSetPreviewError, "서버 미리보기 오류") + XCTAssertEqual(viewModel.currentExercise?.sets[1].weightKilograms, 42.5) + XCTAssertEqual(viewModel.currentExercise?.sets[1].repetitions, 8) + + service.nextSetResponse = NextWorkoutSetResponse( + exerciseId: "exercise-1", + exerciseName: "Bench Press", + setIndex: 2, + weight: 47.5, + reps: 6, + restEndsAt: "" + ) + await viewModel.loadNextSetPreview() + + XCTAssertEqual(service.nextSetRequests, ["workout-1", "workout-1"]) + XCTAssertEqual(viewModel.nextSetPreview?.weight, 47.5) + XCTAssertNil(viewModel.nextSetPreviewError) + } + + @MainActor + func testNextSetPreviewRejectsExerciseOrSetIndexMismatch() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + service.nextSetResponse = NextWorkoutSetResponse( + exerciseId: "exercise-1", + exerciseName: "Bench Press", + setIndex: 3, + weight: 47.5, + reps: 6, + restEndsAt: "" + ) + let viewModel = makeViewModel(service: service, start: start) + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didComplete) + + await viewModel.loadNextSetPreview() + + XCTAssertNil(viewModel.nextSetPreview) + XCTAssertEqual(viewModel.nextSetPreviewError, "다음 세트 정보를 불러오지 못했어요.") + XCTAssertEqual(viewModel.currentSetIndex, 1) + + service.nextSetResponse = NextWorkoutSetResponse( + exerciseId: "exercise-2", + exerciseName: "Row", + setIndex: 2, + weight: 30, + reps: 10, + restEndsAt: "" + ) + await viewModel.loadNextSetPreview() + + XCTAssertNil(viewModel.nextSetPreview) + XCTAssertEqual(viewModel.nextSetPreviewError, "다음 세트 정보를 불러오지 못했어요.") + XCTAssertEqual(service.nextSetRequests.count, 2) + } + @MainActor func testFailedRestStartKeepsSavedSetAndRetryAdoptsCurrentServerRest() async throws { let start = Date(timeIntervalSince1970: 1_800_000_000) @@ -225,6 +322,11 @@ final class WorkoutSessionServerRestTests: XCTestCase { XCTAssertFalse(viewModel.isServerManagedRest) XCTAssertTrue(service.setRequests.isEmpty) XCTAssertTrue(service.restRequests.isEmpty) + + await viewModel.loadNextSetPreview() + + XCTAssertTrue(service.nextSetRequests.isEmpty) + XCTAssertNil(viewModel.nextSetPreviewError) } @MainActor @@ -269,7 +371,8 @@ final class WorkoutSessionServerRestTests: XCTestCase { plan: plan, startedAt: start, serverWorkoutID: "workout-1", - progressService: service + progressService: service, + nextSetService: service ) } From ad7bc03ab6114802fcc8484de277cd22ea8a947e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Fri, 6 Nov 2026 21:05:04 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20[#192]=20next-set=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkoutSessionNextSetPreviewTests.swift | 143 ++++++++++++++++++ .../Tests/WorkoutSessionServerRestTests.swift | 97 ------------ 2 files changed, 143 insertions(+), 97 deletions(-) create mode 100644 GYMUP/Tests/WorkoutSessionNextSetPreviewTests.swift diff --git a/GYMUP/Tests/WorkoutSessionNextSetPreviewTests.swift b/GYMUP/Tests/WorkoutSessionNextSetPreviewTests.swift new file mode 100644 index 0000000..953a462 --- /dev/null +++ b/GYMUP/Tests/WorkoutSessionNextSetPreviewTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import GYMUP + +final class WorkoutSessionNextSetPreviewTests: XCTestCase { + @MainActor + func testPreviewUsesValidatedServerValuesAndKeepsLocalPlanUnchanged() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + service.nextSetResponse = NextWorkoutSetResponse( + exerciseId: "exercise-1", + exerciseName: "Bench Press", + setIndex: 2, + weight: 47.5, + reps: 6, + restEndsAt: "not-used-by-preview" + ) + let viewModel = makeViewModel(service: service, start: start) + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didComplete) + + await viewModel.loadNextSetPreview() + + XCTAssertEqual(service.nextSetRequests, ["workout-1"]) + XCTAssertEqual(viewModel.nextSetPreview?.exerciseName, "Bench Press") + XCTAssertEqual(viewModel.nextSetPreview?.setIndex, 2) + XCTAssertEqual(viewModel.nextSetPreview?.weight, 47.5) + XCTAssertEqual(viewModel.nextSetPreview?.reps, 6) + XCTAssertNil(viewModel.nextSetPreviewError) + XCTAssertEqual(viewModel.currentExercise?.sets[1].weightKilograms, 42.5) + XCTAssertEqual(viewModel.currentExercise?.sets[1].repetitions, 8) + XCTAssertEqual(viewModel.currentSetIndex, 1) + } + + @MainActor + func testPreviewFailureKeepsLocalFallbackAndRetryLoadsServerValues() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + service.nextSetError = serverError(message: "서버 미리보기 오류") + let viewModel = makeViewModel(service: service, start: start) + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didComplete) + + await viewModel.loadNextSetPreview() + + XCTAssertNil(viewModel.nextSetPreview) + XCTAssertEqual(viewModel.nextSetPreviewError, "서버 미리보기 오류") + XCTAssertEqual(viewModel.currentExercise?.sets[1].weightKilograms, 42.5) + XCTAssertEqual(viewModel.currentExercise?.sets[1].repetitions, 8) + + service.nextSetResponse = nextSetResponse() + await viewModel.loadNextSetPreview() + + XCTAssertEqual(service.nextSetRequests, ["workout-1", "workout-1"]) + XCTAssertEqual(viewModel.nextSetPreview?.weight, 47.5) + XCTAssertNil(viewModel.nextSetPreviewError) + } + + @MainActor + func testPreviewRejectsExerciseOrSetIndexMismatch() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + service.nextSetResponse = nextSetResponse(setIndex: 3) + let viewModel = makeViewModel(service: service, start: start) + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didComplete) + + await viewModel.loadNextSetPreview() + + XCTAssertNil(viewModel.nextSetPreview) + XCTAssertEqual(viewModel.nextSetPreviewError, "다음 세트 정보를 불러오지 못했어요.") + XCTAssertEqual(viewModel.currentSetIndex, 1) + + service.nextSetResponse = nextSetResponse(exerciseID: "exercise-2") + await viewModel.loadNextSetPreview() + + XCTAssertNil(viewModel.nextSetPreview) + XCTAssertEqual(viewModel.nextSetPreviewError, "다음 세트 정보를 불러오지 못했어요.") + XCTAssertEqual(service.nextSetRequests.count, 2) + } + + @MainActor + private func makeViewModel( + service: MockWorkoutSessionRestService, + start: Date + ) -> WorkoutSessionViewModel { + let currentExercise = WorkoutSessionExercise( + id: "exercise-1", + name: "벤치 프레스", + target: "가슴", + restDurationSeconds: 75, + formSteps: [], + beginnerTip: "", + sets: [ + WorkoutSessionSet(weightKilograms: 40, repetitions: 8), + WorkoutSessionSet(weightKilograms: 42.5, repetitions: 8) + ] + ) + let nextExercise = WorkoutSessionExercise( + id: "exercise-2", + name: "로우", + target: "등", + restDurationSeconds: 60, + formSteps: [], + beginnerTip: "", + sets: [WorkoutSessionSet(weightKilograms: 30, repetitions: 10)] + ) + let plan = WorkoutSessionPlan( + id: "routine-1", + name: "루틴", + title: "루틴", + estimatedDurationMinutes: 20, + exercises: [currentExercise, nextExercise] + ) + return WorkoutSessionViewModel( + plan: plan, + startedAt: start, + serverWorkoutID: "workout-1", + progressService: service, + nextSetService: service + ) + } + + private func nextSetResponse( + exerciseID: String = "exercise-1", + setIndex: Int = 2 + ) -> NextWorkoutSetResponse { + NextWorkoutSetResponse( + exerciseId: exerciseID, + exerciseName: "Bench Press", + setIndex: setIndex, + weight: 47.5, + reps: 6, + restEndsAt: "" + ) + } + + private func serverError(message: String) -> APIClientError { + .server( + statusCode: 503, + detail: ServerErrorDetail(code: "TEMPORARY_FAILURE", message: message) + ) + } +} diff --git a/GYMUP/Tests/WorkoutSessionServerRestTests.swift b/GYMUP/Tests/WorkoutSessionServerRestTests.swift index 510a523..4f6589d 100644 --- a/GYMUP/Tests/WorkoutSessionServerRestTests.swift +++ b/GYMUP/Tests/WorkoutSessionServerRestTests.swift @@ -20,103 +20,6 @@ final class WorkoutSessionServerRestTests: XCTestCase { XCTAssertNil(viewModel.pendingRestStart) } - @MainActor - func testNextSetPreviewUsesValidatedServerValuesAndKeepsLocalPlanUnchanged() async throws { - let start = Date(timeIntervalSince1970: 1_800_000_000) - let service = MockWorkoutSessionRestService(startDate: start) - service.nextSetResponse = NextWorkoutSetResponse( - exerciseId: "exercise-1", - exerciseName: "Bench Press", - setIndex: 2, - weight: 47.5, - reps: 6, - restEndsAt: "not-used-by-preview" - ) - let viewModel = makeViewModel(service: service, start: start) - let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) - XCTAssertTrue(didComplete) - - await viewModel.loadNextSetPreview() - - XCTAssertEqual(service.nextSetRequests, ["workout-1"]) - XCTAssertEqual(viewModel.nextSetPreview?.exerciseName, "Bench Press") - XCTAssertEqual(viewModel.nextSetPreview?.setIndex, 2) - XCTAssertEqual(viewModel.nextSetPreview?.weight, 47.5) - XCTAssertEqual(viewModel.nextSetPreview?.reps, 6) - XCTAssertNil(viewModel.nextSetPreviewError) - XCTAssertEqual(viewModel.currentExercise?.sets[1].weightKilograms, 42.5) - XCTAssertEqual(viewModel.currentExercise?.sets[1].repetitions, 8) - XCTAssertEqual(viewModel.currentSetIndex, 1) - } - - @MainActor - func testNextSetPreviewFailureKeepsLocalFallbackAndRetryLoadsServerValues() async throws { - let start = Date(timeIntervalSince1970: 1_800_000_000) - let service = MockWorkoutSessionRestService(startDate: start) - service.nextSetError = serverError(message: "서버 미리보기 오류") - let viewModel = makeViewModel(service: service, start: start) - let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) - XCTAssertTrue(didComplete) - - await viewModel.loadNextSetPreview() - - XCTAssertNil(viewModel.nextSetPreview) - XCTAssertEqual(viewModel.nextSetPreviewError, "서버 미리보기 오류") - XCTAssertEqual(viewModel.currentExercise?.sets[1].weightKilograms, 42.5) - XCTAssertEqual(viewModel.currentExercise?.sets[1].repetitions, 8) - - service.nextSetResponse = NextWorkoutSetResponse( - exerciseId: "exercise-1", - exerciseName: "Bench Press", - setIndex: 2, - weight: 47.5, - reps: 6, - restEndsAt: "" - ) - await viewModel.loadNextSetPreview() - - XCTAssertEqual(service.nextSetRequests, ["workout-1", "workout-1"]) - XCTAssertEqual(viewModel.nextSetPreview?.weight, 47.5) - XCTAssertNil(viewModel.nextSetPreviewError) - } - - @MainActor - func testNextSetPreviewRejectsExerciseOrSetIndexMismatch() async throws { - let start = Date(timeIntervalSince1970: 1_800_000_000) - let service = MockWorkoutSessionRestService(startDate: start) - service.nextSetResponse = NextWorkoutSetResponse( - exerciseId: "exercise-1", - exerciseName: "Bench Press", - setIndex: 3, - weight: 47.5, - reps: 6, - restEndsAt: "" - ) - let viewModel = makeViewModel(service: service, start: start) - let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) - XCTAssertTrue(didComplete) - - await viewModel.loadNextSetPreview() - - XCTAssertNil(viewModel.nextSetPreview) - XCTAssertEqual(viewModel.nextSetPreviewError, "다음 세트 정보를 불러오지 못했어요.") - XCTAssertEqual(viewModel.currentSetIndex, 1) - - service.nextSetResponse = NextWorkoutSetResponse( - exerciseId: "exercise-2", - exerciseName: "Row", - setIndex: 2, - weight: 30, - reps: 10, - restEndsAt: "" - ) - await viewModel.loadNextSetPreview() - - XCTAssertNil(viewModel.nextSetPreview) - XCTAssertEqual(viewModel.nextSetPreviewError, "다음 세트 정보를 불러오지 못했어요.") - XCTAssertEqual(service.nextSetRequests.count, 2) - } - @MainActor func testFailedRestStartKeepsSavedSetAndRetryAdoptsCurrentServerRest() async throws { let start = Date(timeIntervalSince1970: 1_800_000_000) From 78353b320dc1ebc4a5dd021de72900a1a80cbc21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Fri, 6 Nov 2026 21:13:16 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20[#192]=20next-set=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=91=EA=B7=BC=20=EB=B2=94=EC=9C=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkoutSessionViewModel+NextSetAPI.swift | 61 ------------------- .../WorkoutSessionViewModel.swift | 60 ++++++++++++++++++ 2 files changed, 60 insertions(+), 61 deletions(-) delete mode 100644 GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+NextSetAPI.swift diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+NextSetAPI.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+NextSetAPI.swift deleted file mode 100644 index caebc7b..0000000 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+NextSetAPI.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation - -extension WorkoutSessionViewModel { - func loadNextSetPreview() async { - guard !isLoadingNextSetPreview, - isServerManagedRest, - let workoutID = serverWorkoutID, - let nextSetService, - let exercise = currentExercise, - exercise.measure == .repetitions, - let localSetIndex = currentSetIndex else { return } - - let expectedSetIndex = localSetIndex + 1 - isLoadingNextSetPreview = true - nextSetPreview = nil - nextSetPreviewError = nil - defer { isLoadingNextSetPreview = false } - - do { - let response = try await nextSetService.nextSet(workoutId: workoutID) - guard isCurrentNextSetRequest( - workoutID: workoutID, - exerciseID: exercise.id, - setIndex: localSetIndex - ) else { return } - guard response.exerciseId == exercise.id, - response.setIndex == expectedSetIndex, - !response.exerciseName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - response.weight.isFinite, - response.weight >= 0, - response.reps >= 0 else { - throw APIClientError.invalidResponse - } - - nextSetPreview = response - } catch { - guard !Task.isCancelled, - isCurrentNextSetRequest( - workoutID: workoutID, - exerciseID: exercise.id, - setIndex: localSetIndex - ) else { return } - nextSetPreviewError = Self.nextSetPreviewMessage(for: error) - } - } - - private func isCurrentNextSetRequest(workoutID: String, exerciseID: String, setIndex: Int) -> Bool { - serverWorkoutID == workoutID && - isServerManagedRest && - currentExercise?.id == exerciseID && - currentSetIndex == setIndex - } - - private static func nextSetPreviewMessage(for error: Error) -> String { - if let apiError = error as? APIClientError, - case let .server(_, detail) = apiError { - return detail.message - } - return "다음 세트 정보를 불러오지 못했어요." - } -} diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift index 19f4504..eaa1359 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift @@ -313,3 +313,63 @@ extension WorkoutSessionViewModel { } } } + +extension WorkoutSessionViewModel { + func loadNextSetPreview() async { + guard !isLoadingNextSetPreview, + isServerManagedRest, + let workoutID = serverWorkoutID, + let nextSetService, + let exercise = currentExercise, + exercise.measure == .repetitions, + let localSetIndex = currentSetIndex else { return } + + let expectedSetIndex = localSetIndex + 1 + isLoadingNextSetPreview = true + nextSetPreview = nil + nextSetPreviewError = nil + defer { isLoadingNextSetPreview = false } + + do { + let response = try await nextSetService.nextSet(workoutId: workoutID) + guard isCurrentNextSetRequest( + workoutID: workoutID, + exerciseID: exercise.id, + setIndex: localSetIndex + ) else { return } + guard response.exerciseId == exercise.id, + response.setIndex == expectedSetIndex, + !response.exerciseName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + response.weight.isFinite, + response.weight >= 0, + response.reps >= 0 else { + throw APIClientError.invalidResponse + } + + nextSetPreview = response + } catch { + guard !Task.isCancelled, + isCurrentNextSetRequest( + workoutID: workoutID, + exerciseID: exercise.id, + setIndex: localSetIndex + ) else { return } + nextSetPreviewError = Self.nextSetPreviewMessage(for: error) + } + } + + private func isCurrentNextSetRequest(workoutID: String, exerciseID: String, setIndex: Int) -> Bool { + serverWorkoutID == workoutID && + isServerManagedRest && + currentExercise?.id == exerciseID && + currentSetIndex == setIndex + } + + private static func nextSetPreviewMessage(for error: Error) -> String { + if let apiError = error as? APIClientError, + case let .server(_, detail) = apiError { + return detail.message + } + return "다음 세트 정보를 불러오지 못했어요." + } +}