From fdf70dbd3f54d11329cb79e343f92865bc7d5731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Fri, 6 Nov 2026 19:32:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[#190]=20=EC=9A=B4=EB=8F=99=20=ED=9C=B4?= =?UTF-8?q?=EC=8B=9D=20API=20=EC=84=9C=EB=B2=84=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkoutSessionView+Actions.swift | 67 ++++- .../WorkoutSessionView+Content.swift | 22 +- .../WorkoutSession/WorkoutSessionView.swift | 11 +- .../WorkoutSessionViewModel+Progress.swift | 33 +- .../WorkoutSessionViewModel+RestAPI.swift | 191 ++++++++++++ .../WorkoutSessionViewModel.swift | 20 +- .../WorkoutTrainModeScreen.swift | 2 + .../Networking/WorkoutReadAPIService.swift | 40 --- .../Networking/WorkoutSessionAPIService.swift | 53 +++- .../Networking/WorkoutAPIServiceTests.swift | 2 +- .../WorkoutSessionAPIServiceTests.swift | 11 + .../WorkoutSessionServerProgressTests.swift | 29 ++ .../WorkoutSessionServerRestTestSupport.swift | 136 +++++++++ .../Tests/WorkoutSessionServerRestTests.swift | 283 ++++++++++++++++++ 14 files changed, 836 insertions(+), 64 deletions(-) create mode 100644 GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+RestAPI.swift create mode 100644 GYMUP/Tests/WorkoutSessionServerRestTestSupport.swift create mode 100644 GYMUP/Tests/WorkoutSessionServerRestTests.swift diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift index 1be3386..50950c0 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Actions.swift @@ -11,9 +11,14 @@ extension WorkoutSessionView { func handleRestTick(at date: Date) async { if let activityState = WorkoutRestActivityController.shared.currentState(sessionID: liveActivitySessionID) { if activityState.isSkipped { - viewModel.skipRest() - settings.cancelRestNotification(sessionID: liveActivitySessionID) - await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) + let didSkip = await viewModel.requestServerRestSkip() + if didSkip { + settings.cancelRestNotification(sessionID: liveActivitySessionID) + await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) + } else { + isShowingServerActionError = viewModel.serverActionErrorMessage != nil + syncRestActivity() + } return } viewModel.synchronizeRestTimer( @@ -71,10 +76,66 @@ extension WorkoutSessionView { } if let failedAction, case .recordSet = failedAction { completeSetSideEffects() + } else if let failedAction, case .startRest = failedAction { + syncRestActivity() + } else if let failedAction, case .skipRest = failedAction { + settings.cancelRestNotification(sessionID: liveActivitySessionID) + await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) + } else if let failedAction, case .extendRest = failedAction { + syncRestActivity() + } else if let failedAction, case .refreshRest = failedAction { + syncRestActivity() + } + } + } + + func skipCurrentRest() { + Task { + let didSkip = await viewModel.requestServerRestSkip() + guard didSkip else { + isShowingServerActionError = viewModel.serverActionErrorMessage != nil + syncRestActivity() + return } + settings.cancelRestNotification(sessionID: liveActivitySessionID) + await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) } } + func extendCurrentRest(by seconds: Int) { + Task { + let didExtend = await viewModel.requestServerRestExtension(by: seconds) + guard didExtend else { + isShowingServerActionError = viewModel.serverActionErrorMessage != nil + syncRestActivity() + return + } + syncRestActivity() + } + } + + func refreshRestOnForeground() { + guard viewModel.isServerManagedRest else { return } + if viewModel.refreshClock(at: Date()) { + syncRestActivity() + return + } + guard viewModel.isResting else { return } + Task { + let didRefresh = await viewModel.refreshActiveServerRest(at: Date()) + guard didRefresh else { + isShowingServerActionError = viewModel.serverActionErrorMessage != nil + return + } + syncRestActivity() + } + } + + func skipPendingRestStart() { + guard viewModel.skipPendingRestStart() else { return } + syncRestActivity() + } + func advanceToNextExercise() { Task { let didAdvance = await viewModel.advanceToNextExercise() diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift index 3f5416a..604550c 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView+Content.swift @@ -32,15 +32,8 @@ extension WorkoutSessionView { nextSetIndex: nextSetIndex, remainingSeconds: viewModel.restSecondsRemaining, totalSeconds: viewModel.activeRestDurationSeconds, - onExtend: { - viewModel.extendRest(by: 15) - syncRestActivity() - }, - onSkip: { - viewModel.skipRest() - settings.cancelRestNotification(sessionID: liveActivitySessionID) - Task { await WorkoutRestActivityController.shared.end(sessionID: liveActivitySessionID) } - }, + onExtend: { extendCurrentRest(by: 15) }, + onSkip: skipCurrentRest, previousRecordError: viewModel.previousRecordError(for: exercise.id), isLoadingPreviousRecord: viewModel.isLoadingPreviousRecord(for: exercise.id), onRetryPreviousRecord: { Task { await loadPreviousRecord(for: exercise.id) } } @@ -53,6 +46,8 @@ extension WorkoutSessionView { exerciseIndex: viewModel.currentExerciseIndex, totalExerciseCount: viewModel.plan.exercises.count, currentSetIndex: viewModel.currentSetIndex, + canPerformPrimaryAction: viewModel.pendingRestStart == nil && + !viewModel.isSubmittingWorkoutAction, isResting: viewModel.isResting || viewModel.isPaused, isPaused: viewModel.isPaused, restSecondsRemaining: viewModel.restSecondsRemaining, @@ -83,7 +78,9 @@ extension WorkoutSessionView { }, canToggleSet: { setIndex in viewModel.serverWorkoutID == nil || - (setIndex == viewModel.currentSetIndex && !viewModel.isSubmittingWorkoutAction) + (viewModel.pendingRestStart == nil && + setIndex == viewModel.currentSetIndex && + !viewModel.isSubmittingWorkoutAction) }, onAdjustWeight: viewModel.adjustCurrentWeight, onInputWeight: { @@ -92,10 +89,7 @@ extension WorkoutSessionView { }, onAdjustRepetitions: viewModel.adjustCurrentRepetitions, onSetDone: { completeCurrentSet(at: Date()) }, - onSkipRest: { - viewModel.skipRest() - syncRestActivity() - }, + onSkipRest: skipCurrentRest, onNextExercise: advanceToNextExercise, onFinish: { isConfirmingFinish = true }, previousRecordError: viewModel.previousRecordError(for: exercise.id), diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift index 38c29f1..5befc8e 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionView.swift @@ -4,6 +4,7 @@ import UIKit struct WorkoutSessionView: View { @Environment(\.dismiss) var dismiss + @Environment(\.scenePhase) var scenePhase @State var viewModel: WorkoutSessionViewModel @State var settings = WorkoutSettings.shared @State var didSaveHistoryRecord = false @@ -161,6 +162,10 @@ struct WorkoutSessionView: View { .onReceive(clock) { date in Task { @MainActor in await handleRestTick(at: date) } } + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active else { return } + refreshRestOnForeground() + } .alert( isShowingServerActionError ? "서버 요청을 처리하지 못했어요" : "휴식 시간이 끝났어요", isPresented: Binding( @@ -175,7 +180,11 @@ struct WorkoutSessionView: View { ) { if isShowingServerActionError { Button("재시도") { retryFailedServerAction() } - Button("취소", role: .cancel) { viewModel.dismissServerActionError() } + if viewModel.failedServerAction == .startRest { + Button("휴식 건너뛰기", role: .cancel) { skipPendingRestStart() } + } else { + Button("취소", role: .cancel) { viewModel.dismissServerActionError() } + } } else { Button("다음 세트 진행", role: .cancel) {} } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+Progress.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+Progress.swift index 38c1e16..62c3660 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+Progress.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+Progress.swift @@ -50,6 +50,7 @@ extension WorkoutSessionViewModel { @discardableResult func advanceToNextExercise() async -> Bool { guard let serverWorkoutID else { return moveToNextExerciseLocally() } + guard pendingRestStart == nil else { return false } guard hasStarted, !isPaused, !isFinished, let currentExercise, plan.exercises.indices.contains(currentExerciseIndex + 1) else { return false } @@ -94,6 +95,14 @@ extension WorkoutSessionViewModel { return await completeSet(exerciseIndex: exerciseIndex, setIndex: setIndex) case .advanceExercise: return await advanceToNextExercise() + case .startRest: + return await reconcilePendingRestStart() + case .skipRest: + return await requestServerRestSkip() + case let .extendRest(seconds): + return await requestServerRestExtension(by: seconds) + case .refreshRest: + return await refreshActiveServerRest(at: Date()) } } @@ -103,6 +112,7 @@ extension WorkoutSessionViewModel { private func canCompleteSet(exerciseIndex: Int, setIndex: Int) -> Bool { guard hasStarted, !isPaused, !isFinished, + pendingRestStart == nil, plan.exercises.indices.contains(exerciseIndex), plan.exercises[exerciseIndex].sets.indices.contains(setIndex) else { return false } return !plan.exercises[exerciseIndex].sets[setIndex].isCompleted @@ -139,9 +149,16 @@ extension WorkoutSessionViewModel { idempotencyKey: pendingSetSubmission.idempotencyKey ) try validateCreatedSet(response, workoutID: workoutID, exercise: exercise, setIndex: setIndex) - markSetCompleted(exerciseIndex: exerciseIndex, setIndex: setIndex, at: date) + markSetCompleted(exerciseIndex: exerciseIndex, setIndex: setIndex, at: date, startsLocalRest: false) self.pendingSetSubmission = nil - return true + let hasMoreSets = exercise.sets.dropFirst(setIndex + 1).contains { !$0.isCompleted } + let restDuration = min(max(exercise.restDurationSeconds, 0), 600) + guard hasMoreSets, restDuration > 0 else { return true } + pendingRestStart = PendingWorkoutRestStart( + workoutID: workoutID, + request: StartRestRequest(setId: response.setId, durationSeconds: restDuration) + ) + return await startPendingRest() } catch { failServerAction(.recordSet(exerciseIndex: exerciseIndex, setIndex: setIndex), error: error) return false @@ -184,7 +201,8 @@ extension WorkoutSessionViewModel { ) throws { guard response.workoutId == workoutID, response.exerciseId == exercise.id, - response.setIndex == setIndex + 1 else { + response.setIndex == setIndex + 1, + !response.setId.isEmpty else { throw APIClientError.invalidResponse } } @@ -236,6 +254,10 @@ extension WorkoutSessionViewModel { enum FailedWorkoutServerAction: Equatable { case recordSet(exerciseIndex: Int, setIndex: Int) case advanceExercise + case startRest + case skipRest + case extendRest(seconds: Int) + case refreshRest } struct PendingWorkoutSetSubmission { @@ -244,3 +266,8 @@ struct PendingWorkoutSetSubmission { let request: CreateWorkoutSetRequest let idempotencyKey: String } + +struct PendingWorkoutRestStart: Equatable { + let workoutID: String + let request: StartRestRequest +} diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+RestAPI.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+RestAPI.swift new file mode 100644 index 0000000..75f8e3a --- /dev/null +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel+RestAPI.swift @@ -0,0 +1,191 @@ +import Foundation + +extension WorkoutSessionViewModel { + func startPendingRest() async -> Bool { + guard let pendingRestStart else { return false } + guard let progressService else { + failServerAction(.startRest, error: APIClientError.invalidBaseURL) + return false + } + + do { + let response = try await progressService.startRest( + workoutId: pendingRestStart.workoutID, + request: pendingRestStart.request + ) + try applyStartedRest(response, at: Date()) + self.pendingRestStart = nil + clearServerActionError() + return true + } catch { + failServerAction(.startRest, error: error) + return false + } + } + + func reconcilePendingRestStart() async -> Bool { + guard let pendingRestStart else { return false } + guard let progressService else { + failServerAction(.startRest, error: APIClientError.invalidBaseURL) + return false + } + + isSubmittingWorkoutAction = true + clearServerActionError() + defer { isSubmittingWorkoutAction = false } + + if let currentRest = try? await progressService.currentRest(workoutId: pendingRestStart.workoutID), + let isActive = try? applyCurrentRest(currentRest, at: Date()) { + self.pendingRestStart = nil + clearServerActionError() + return isActive || currentRest.remainingSeconds == 0 || currentRest.status.uppercased() == "SKIPPED" + } + + // A failed lookup cannot establish whether the first POST took effect. + // The user explicitly chose retry, so retry this exact rest request without resubmitting its set. + return await startPendingRest() + } + + func skipPendingRestStart() -> Bool { + guard pendingRestStart != nil else { return false } + pendingRestStart = nil + skipRest() + clearServerActionError() + return true + } + + func requestServerRestSkip() async -> Bool { + guard isServerManagedRest else { + skipRest() + return true + } + guard let serverWorkoutID, let progressService else { + failServerAction(.skipRest, error: APIClientError.invalidBaseURL) + return false + } + guard !isSubmittingWorkoutAction else { return false } + + isSubmittingWorkoutAction = true + clearServerActionError() + defer { isSubmittingWorkoutAction = false } + + do { + let response = try await progressService.skipCurrentRest(workoutId: serverWorkoutID) + guard response.status == .skipped else { throw APIClientError.invalidResponse } + skipRest() + clearServerActionError() + return true + } catch { + failServerAction(.skipRest, error: error) + return false + } + } + + func requestServerRestExtension(by seconds: Int) async -> Bool { + guard isResting, seconds > 0 else { return false } + guard isServerManagedRest else { + extendRest(by: seconds) + return true + } + guard let serverWorkoutID, let progressService else { + failServerAction(.extendRest(seconds: seconds), error: APIClientError.invalidBaseURL) + return false + } + guard !isSubmittingWorkoutAction else { return false } + + isSubmittingWorkoutAction = true + clearServerActionError() + defer { isSubmittingWorkoutAction = false } + + do { + let response = try await progressService.extendCurrentRest(workoutId: serverWorkoutID, by: seconds) + guard !response.restId.isEmpty, + response.remainingSeconds >= 0, + let endDate = Self.parseServerDate(response.endsAt) else { + throw APIClientError.invalidResponse + } + + let elapsedSeconds = max(0, activeRestDurationSeconds - restSecondsRemaining) + activeRestDurationSeconds = min(max(elapsedSeconds + response.remainingSeconds, 0), 600) + restEndsAt = endDate + _ = refreshClock(at: Date()) + clearServerActionError() + return true + } catch { + failServerAction(.extendRest(seconds: seconds), error: error) + return false + } + } + + func refreshActiveServerRest(at date: Date) async -> Bool { + guard isServerManagedRest else { + clearServerActionError() + return true + } + guard !isPaused else { return false } + guard let restEndsAt, restEndsAt > date else { + _ = refreshClock(at: date) + clearServerActionError() + return true + } + guard let serverWorkoutID, let progressService else { return false } + guard !isSubmittingWorkoutAction else { return false } + + isSubmittingWorkoutAction = true + clearServerActionError() + defer { isSubmittingWorkoutAction = false } + + do { + let response = try await progressService.currentRest(workoutId: serverWorkoutID) + _ = try applyCurrentRest(response, at: date) + clearServerActionError() + return true + } catch { + failServerAction(.refreshRest, error: error) + return false + } + } + + private func applyStartedRest(_ response: StartRestResponse, at date: Date) throws { + guard !response.restId.isEmpty, + response.durationSeconds >= 0, + let startedAt = Self.parseServerDate(response.startedAt), + let endsAt = Self.parseServerDate(response.endsAt), + endsAt >= startedAt else { + throw APIClientError.invalidResponse + } + + _ = refreshClock(at: date) + activeRestDurationSeconds = min(max(response.durationSeconds, 0), 600) + restEndsAt = endsAt + isServerManagedRest = true + } + + @discardableResult + private func applyCurrentRest(_ response: CurrentRestResponse, at date: Date) throws -> Bool { + guard !response.restId.isEmpty, + response.remainingSeconds >= 0, + let startedAt = Self.parseServerDate(response.startedAt), + let endsAt = Self.parseServerDate(response.endsAt), + endsAt >= startedAt else { + throw APIClientError.invalidResponse + } + + guard response.remainingSeconds > 0, response.status.uppercased() != "SKIPPED" else { + skipRest() + return false + } + + _ = refreshClock(at: date) + activeRestDurationSeconds = min(max(Int(endsAt.timeIntervalSince(startedAt)), 0), 600) + restEndsAt = endsAt + isServerManagedRest = true + return true + } + + private static func parseServerDate(_ string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: string) ?? ISO8601DateFormatter().date(from: string) + } +} diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift index ae419e8..0d00302 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutSessionViewModel.swift @@ -22,8 +22,10 @@ final class WorkoutSessionViewModel { var pendingSetSubmission: PendingWorkoutSetSubmission? var pendingExerciseAdvance: AdvanceWorkoutExerciseRequest? + var pendingRestStart: PendingWorkoutRestStart? var hasPendingServerSetSubmission: Bool { pendingSetSubmission != nil } + var isServerManagedRest = false private(set) var startedAt: Date? private var pausedAt: Date? @@ -120,6 +122,7 @@ final class WorkoutSessionViewModel { if didRestEnd { self.restEndsAt = nil activeRestDurationSeconds = 0 + isServerManagedRest = false } return didRestEnd } @@ -182,13 +185,25 @@ final class WorkoutSessionViewModel { markSetCompleted(exerciseIndex: exerciseIndex, setIndex: setIndex, at: date) } - func markSetCompleted(exerciseIndex: Int, setIndex: Int, at date: Date?) { + func markSetCompleted( + exerciseIndex: Int, + setIndex: Int, + at date: Date?, + startsLocalRest: Bool = true + ) { guard plan.exercises.indices.contains(exerciseIndex), plan.exercises[exerciseIndex].sets.indices.contains(setIndex), !plan.exercises[exerciseIndex].sets[setIndex].isCompleted else { return } plan.exercises[exerciseIndex].sets[setIndex].isCompleted = true notice = nil + guard startsLocalRest else { + restEndsAt = nil + activeRestDurationSeconds = 0 + isServerManagedRest = false + return + } + let hasMoreSets = plan.exercises[exerciseIndex].sets.contains { !$0.isCompleted } if hasMoreSets, let date { now = date @@ -201,11 +216,13 @@ final class WorkoutSessionViewModel { restEndsAt = nil activeRestDurationSeconds = 0 } + isServerManagedRest = false } func skipRest() { restEndsAt = nil activeRestDurationSeconds = 0 + isServerManagedRest = false } func extendRest(by seconds: Int) { @@ -243,6 +260,7 @@ final class WorkoutSessionViewModel { finishedAt = date restEndsAt = nil activeRestDurationSeconds = 0 + isServerManagedRest = false notice = nil } } diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift index 70634e7..423f112 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutTrainModeScreen.swift @@ -7,6 +7,7 @@ struct WorkoutTrainModeScreen: View { let exerciseIndex: Int let totalExerciseCount: Int let currentSetIndex: Int? + let canPerformPrimaryAction: Bool let isResting: Bool let isPaused: Bool let restSecondsRemaining: Int @@ -250,6 +251,7 @@ struct WorkoutTrainModeScreen: View { .background(GymupColors.accent, in: Capsule()) } .buttonStyle(.plain) + .disabled(!canPerformPrimaryAction) .accessibilityIdentifier("workout.train.primary-action") } } diff --git a/GYMUP/Sources/Networking/WorkoutReadAPIService.swift b/GYMUP/Sources/Networking/WorkoutReadAPIService.swift index ed4feb4..a6c2c84 100644 --- a/GYMUP/Sources/Networking/WorkoutReadAPIService.swift +++ b/GYMUP/Sources/Networking/WorkoutReadAPIService.swift @@ -41,25 +41,6 @@ struct NextWorkoutSetResponse: Codable, Equatable { let restEndsAt: String } -struct CurrentRestResponse: Codable, Equatable { - let restId: String - let status: String - let startedAt: String - let endsAt: String - let remainingSeconds: Int - let notificationAt: String -} - -struct ExtendCurrentRestRequest: Codable, Equatable { - let seconds: Int -} - -struct ExtendCurrentRestResponse: Codable, Equatable { - let restId: String - let endsAt: String - let remainingSeconds: Int -} - struct WorkoutReadAPIService { private let client: APIClient @@ -86,25 +67,4 @@ struct WorkoutReadAPIService { return try await client.send(endpoint, as: NextWorkoutSetResponse.self) } - func currentRest(workoutId: String) async throws -> CurrentRestResponse { - let endpoint = APIEndpoint( - method: .get, - path: "/api/v1/workouts/\(workoutId)/rests/current", - expectedStatusCode: 200 - ) - return try await client.send(endpoint, as: CurrentRestResponse.self) - } - - func extendCurrentRest(workoutId: String, by seconds: Int) async throws -> ExtendCurrentRestResponse { - let endpoint = APIEndpoint( - method: .patch, - path: "/api/v1/workouts/\(workoutId)/rests/current/extend", - expectedStatusCode: 200 - ) - return try await client.send( - endpoint, - body: ExtendCurrentRestRequest(seconds: seconds), - as: ExtendCurrentRestResponse.self - ) - } } diff --git a/GYMUP/Sources/Networking/WorkoutSessionAPIService.swift b/GYMUP/Sources/Networking/WorkoutSessionAPIService.swift index 482691c..cb5d236 100644 --- a/GYMUP/Sources/Networking/WorkoutSessionAPIService.swift +++ b/GYMUP/Sources/Networking/WorkoutSessionAPIService.swift @@ -23,6 +23,25 @@ struct StartRestResponse: Codable, Equatable { let nextSet: JSONValue } +struct CurrentRestResponse: Codable, Equatable { + let restId: String + let status: String + let startedAt: String + let endsAt: String + let remainingSeconds: Int + let notificationAt: String +} + +struct ExtendCurrentRestRequest: Codable, Equatable { + let seconds: Int +} + +struct ExtendCurrentRestResponse: Codable, Equatable { + let restId: String + let endsAt: String + let remainingSeconds: Int +} + struct AdvanceWorkoutExerciseRequest: Codable, Equatable { let completedExerciseId: String } @@ -84,7 +103,17 @@ protocol WorkoutStarting { func startWorkout(request: StartWorkoutRequest) async throws -> StartWorkoutResponse } -protocol WorkoutSessionProgressing { +protocol WorkoutSessionResting { + func startRest(workoutId: String, request: StartRestRequest) async throws -> StartRestResponse + + func currentRest(workoutId: String) async throws -> CurrentRestResponse + + func skipCurrentRest(workoutId: String) async throws -> SkipCurrentRestResponse + + func extendCurrentRest(workoutId: String, by seconds: Int) async throws -> ExtendCurrentRestResponse +} + +protocol WorkoutSessionProgressing: WorkoutSessionResting { func createSet( workoutId: String, request: CreateWorkoutSetRequest, @@ -122,6 +151,28 @@ struct WorkoutSessionAPIService { return try await client.send(endpoint, body: request, as: StartRestResponse.self) } + func currentRest(workoutId: String) async throws -> CurrentRestResponse { + let endpoint = APIEndpoint( + method: .get, + path: "/api/v1/workouts/\(workoutId)/rests/current", + expectedStatusCode: 200 + ) + return try await client.send(endpoint, as: CurrentRestResponse.self) + } + + func extendCurrentRest(workoutId: String, by seconds: Int) async throws -> ExtendCurrentRestResponse { + let endpoint = APIEndpoint( + method: .patch, + path: "/api/v1/workouts/\(workoutId)/rests/current/extend", + expectedStatusCode: 200 + ) + return try await client.send( + endpoint, + body: ExtendCurrentRestRequest(seconds: seconds), + as: ExtendCurrentRestResponse.self + ) + } + func advanceExercise( workoutId: String, request: AdvanceWorkoutExerciseRequest diff --git a/GYMUP/Tests/Networking/WorkoutAPIServiceTests.swift b/GYMUP/Tests/Networking/WorkoutAPIServiceTests.swift index 22e24df..e3dc534 100644 --- a/GYMUP/Tests/Networking/WorkoutAPIServiceTests.swift +++ b/GYMUP/Tests/Networking/WorkoutAPIServiceTests.swift @@ -157,7 +157,7 @@ final class WorkoutAPIServiceTests: NetworkingTestCase { ] var requests: [URLRequest] = [] installJSONResponses(paths, recordRequest: { requests.append($0) }) - let service = try WorkoutReadAPIService(client: makeClient(token: "test-token")) + let service = try WorkoutSessionAPIService(client: makeClient(token: "test-token")) let current = try await service.currentRest(workoutId: "workout-1") let extended = try await service.extendCurrentRest(workoutId: "workout-1", by: 15) diff --git a/GYMUP/Tests/Networking/WorkoutSessionAPIServiceTests.swift b/GYMUP/Tests/Networking/WorkoutSessionAPIServiceTests.swift index b143653..6ad9d9b 100644 --- a/GYMUP/Tests/Networking/WorkoutSessionAPIServiceTests.swift +++ b/GYMUP/Tests/Networking/WorkoutSessionAPIServiceTests.swift @@ -31,9 +31,20 @@ final class WorkoutSessionAPIServiceTests: NetworkingTestCase { ) XCTAssertEqual(requests.map(\.httpMethod), ["POST", "POST"]) + XCTAssertEqual( + requests.map { $0.url?.path }, + [ + "/api/v1/workouts/workout-1/rests/current/skip", + "/api/v1/workouts/workout-1/rests" + ] + ) XCTAssertEqual(skipped.status, .skipped) XCTAssertEqual(rest.durationSeconds, 60) XCTAssertEqual(rest.nextSet, .object(["exerciseId": .string("exercise-2")])) + XCTAssertEqual( + try JSONDecoder().decode(StartRestRequest.self, from: try XCTUnwrap(requests.last?.httpBody)), + StartRestRequest(setId: "set-1", durationSeconds: 60) + ) } func testExerciseAdvanceAndSetCreationPreserveOpaqueResponses() async throws { let paths = [ diff --git a/GYMUP/Tests/WorkoutSessionServerProgressTests.swift b/GYMUP/Tests/WorkoutSessionServerProgressTests.swift index 9dfe2a6..beb1a29 100644 --- a/GYMUP/Tests/WorkoutSessionServerProgressTests.swift +++ b/GYMUP/Tests/WorkoutSessionServerProgressTests.swift @@ -196,4 +196,33 @@ private final class MockWorkoutSessionProgressService: WorkoutSessionProgressing status: "IN_PROGRESS" ) } + + func startRest(workoutId: String, request: StartRestRequest) async throws -> StartRestResponse { + StartRestResponse( + restId: "rest-1", + startedAt: "2026-10-31T09:00:00Z", + endsAt: "2026-10-31T09:01:00Z", + durationSeconds: request.durationSeconds, + nextSet: .object([:]) + ) + } + + func currentRest(workoutId: String) async throws -> CurrentRestResponse { + CurrentRestResponse( + restId: "rest-1", + status: "ACTIVE", + startedAt: "2026-10-31T09:00:00Z", + endsAt: "2026-10-31T09:01:00Z", + remainingSeconds: 60, + notificationAt: "2026-10-31T09:00:50Z" + ) + } + + func skipCurrentRest(workoutId: String) async throws -> SkipCurrentRestResponse { + SkipCurrentRestResponse(restId: "rest-1", status: .skipped, nextSet: .object([:])) + } + + func extendCurrentRest(workoutId: String, by seconds: Int) async throws -> ExtendCurrentRestResponse { + ExtendCurrentRestResponse(restId: "rest-1", endsAt: "2026-10-31T09:01:15Z", remainingSeconds: 75) + } } diff --git a/GYMUP/Tests/WorkoutSessionServerRestTestSupport.swift b/GYMUP/Tests/WorkoutSessionServerRestTestSupport.swift new file mode 100644 index 0000000..0b88881 --- /dev/null +++ b/GYMUP/Tests/WorkoutSessionServerRestTestSupport.swift @@ -0,0 +1,136 @@ +import Foundation +@testable import GYMUP + +@MainActor +final class MockWorkoutSessionRestService: WorkoutSessionProgressing { + struct SetRequest { + let workoutId: String + let request: CreateWorkoutSetRequest + let idempotencyKey: String? + } + + struct RestRequest { + let workoutID: String + let request: StartRestRequest + } + + var setRequests: [SetRequest] = [] + var restRequests: [RestRequest] = [] + var currentRestRequests: [String] = [] + var skipRequests: [String] = [] + var extendRequests: [(String, Int)] = [] + var nextRestStartError: Error? + var nextCurrentRestError: Error? + var nextSkipError: Error? + var nextExtendError: Error? + var restStartCommitsBeforeThrow = false + var currentRestResponse: CurrentRestResponse? + var nextExtendResponse: ExtendCurrentRestResponse? + private let startDate: Date + + init(startDate: Date) { + self.startDate = startDate + currentRestResponse = makeCurrentRest(startDate: startDate, duration: 75) + } + + func createSet( + workoutId: String, + request: CreateWorkoutSetRequest, + idempotencyKey: String? + ) async throws -> CreateWorkoutSetResponse { + setRequests.append(SetRequest(workoutId: workoutId, request: request, idempotencyKey: idempotencyKey)) + return CreateWorkoutSetResponse( + setId: "set-\(setRequests.count)", + workoutId: workoutId, + exerciseId: request.exerciseId, + setIndex: request.setIndex, + isPersonalRecord: false, + nextAction: .object([:]), + rest: .object([:]) + ) + } + + func advanceExercise( + workoutId: String, + request: AdvanceWorkoutExerciseRequest + ) async throws -> AdvanceWorkoutExerciseResponse { + AdvanceWorkoutExerciseResponse( + workoutId: workoutId, + currentExercise: .object([:]), + nextExercise: .object([:]), + status: "IN_PROGRESS" + ) + } + + func startRest(workoutId: String, request: StartRestRequest) async throws -> StartRestResponse { + restRequests.append(RestRequest(workoutID: workoutId, request: request)) + let response = makeStartRest(request: request, startDate: startDate) + if let error = nextRestStartError { + nextRestStartError = nil + if restStartCommitsBeforeThrow { + currentRestResponse = makeCurrentRest(startDate: startDate, duration: request.durationSeconds) + } + throw error + } + currentRestResponse = makeCurrentRest(startDate: startDate, duration: request.durationSeconds) + return response + } + + func currentRest(workoutId: String) async throws -> CurrentRestResponse { + currentRestRequests.append(workoutId) + if let error = nextCurrentRestError { + nextCurrentRestError = nil + throw error + } + guard let currentRestResponse else { throw APIClientError.invalidResponse } + return currentRestResponse + } + + func skipCurrentRest(workoutId: String) async throws -> SkipCurrentRestResponse { + skipRequests.append(workoutId) + if let error = nextSkipError { + nextSkipError = nil + throw error + } + return SkipCurrentRestResponse(restId: "rest-1", status: .skipped, nextSet: .object([:])) + } + + func extendCurrentRest(workoutId: String, by seconds: Int) async throws -> ExtendCurrentRestResponse { + extendRequests.append((workoutId, seconds)) + if let error = nextExtendError { + nextExtendError = nil + throw error + } + if let nextExtendResponse { return nextExtendResponse } + return ExtendCurrentRestResponse( + restId: "rest-1", + endsAt: serverDate(startDate.addingTimeInterval(75 + TimeInterval(seconds))), + remainingSeconds: 75 + seconds + ) + } + + private func makeStartRest(request: StartRestRequest, startDate: Date) -> StartRestResponse { + StartRestResponse( + restId: "rest-1", + startedAt: serverDate(startDate), + endsAt: serverDate(startDate.addingTimeInterval(TimeInterval(request.durationSeconds))), + durationSeconds: request.durationSeconds, + nextSet: .object([:]) + ) + } + + private func makeCurrentRest(startDate: Date, duration: Int) -> CurrentRestResponse { + CurrentRestResponse( + restId: "rest-1", + status: "ACTIVE", + startedAt: serverDate(startDate), + endsAt: serverDate(startDate.addingTimeInterval(TimeInterval(duration))), + remainingSeconds: duration, + notificationAt: serverDate(startDate.addingTimeInterval(TimeInterval(max(duration - 10, 0)))) + ) + } + + private func serverDate(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } +} diff --git a/GYMUP/Tests/WorkoutSessionServerRestTests.swift b/GYMUP/Tests/WorkoutSessionServerRestTests.swift new file mode 100644 index 0000000..749d033 --- /dev/null +++ b/GYMUP/Tests/WorkoutSessionServerRestTests.swift @@ -0,0 +1,283 @@ +import XCTest +@testable import GYMUP + +final class WorkoutSessionServerRestTests: XCTestCase { + @MainActor + func testRecordedSetStartsServerRestAndUsesServerDeadline() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + let viewModel = makeViewModel(service: service, start: start) + + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + + XCTAssertTrue(didComplete) + XCTAssertTrue(try XCTUnwrap(viewModel.currentExercise?.sets.first).isCompleted) + XCTAssertEqual(service.setRequests.count, 1) + XCTAssertEqual(service.restRequests.count, 1) + XCTAssertEqual(service.restRequests[0].request, StartRestRequest(setId: "set-1", durationSeconds: 75)) + XCTAssertEqual(viewModel.activeRestEndsAt, start.addingTimeInterval(75)) + XCTAssertTrue(viewModel.isServerManagedRest) + XCTAssertNil(viewModel.pendingRestStart) + } + + @MainActor + func testFailedRestStartKeepsSavedSetAndRetryAdoptsCurrentServerRest() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + service.nextRestStartError = serverError(message: "휴식 생성 오류") + service.restStartCommitsBeforeThrow = true + let viewModel = makeViewModel(service: service, start: start) + + let firstAttempt = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + + XCTAssertFalse(firstAttempt) + XCTAssertTrue(try XCTUnwrap(viewModel.currentExercise?.sets.first).isCompleted) + XCTAssertFalse(viewModel.hasPendingServerSetSubmission) + XCTAssertEqual(viewModel.serverActionErrorMessage, "휴식 생성 오류") + XCTAssertFalse(viewModel.isResting) + XCTAssertNotNil(viewModel.pendingRestStart) + XCTAssertEqual(service.setRequests.count, 1) + XCTAssertEqual(service.restRequests.count, 1) + + let didRetry = await viewModel.retryFailedServerAction() + + XCTAssertTrue(didRetry) + XCTAssertEqual(service.currentRestRequests, ["workout-1"]) + XCTAssertEqual(service.restRequests.count, 1) + XCTAssertEqual(service.setRequests.count, 1) + XCTAssertTrue(viewModel.isResting) + XCTAssertTrue(viewModel.isServerManagedRest) + XCTAssertNil(viewModel.pendingRestStart) + XCTAssertNil(viewModel.serverActionErrorMessage) + } + + @MainActor + func testRetryAfterCurrentRestAlreadyEndedDoesNotCreateAnotherRest() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + service.nextRestStartError = serverError(message: "응답을 받지 못했어요.") + service.currentRestResponse = CurrentRestResponse( + restId: "rest-1", + status: "COMPLETED", + startedAt: serverDate(start), + endsAt: serverDate(start.addingTimeInterval(75)), + remainingSeconds: 0, + notificationAt: serverDate(start.addingTimeInterval(65)) + ) + let viewModel = makeViewModel(service: service, start: start) + + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertFalse(didComplete) + + let didRetry = await viewModel.retryFailedServerAction() + + XCTAssertTrue(didRetry) + XCTAssertEqual(service.setRequests.count, 1) + XCTAssertEqual(service.currentRestRequests.count, 1) + XCTAssertEqual(service.restRequests.count, 1) + XCTAssertNil(viewModel.pendingRestStart) + XCTAssertFalse(viewModel.isResting) + } + + @MainActor + func testRetryAfterUnresolvedLookupRetriesRestOnlyWithSameRequest() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + let requestError = serverError(message: "첫 번째 휴식 요청 실패") + service.nextRestStartError = requestError + service.nextCurrentRestError = serverError(statusCode: 404, message: "휴식 상태 조회 실패") + let viewModel = makeViewModel(service: service, start: start) + + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertFalse(didComplete) + let originalRequest = try XCTUnwrap(viewModel.pendingRestStart?.request) + + let didRetry = await viewModel.retryFailedServerAction() + + XCTAssertTrue(didRetry) + XCTAssertEqual(service.setRequests.count, 1) + XCTAssertEqual(service.currentRestRequests.count, 1) + XCTAssertEqual(service.restRequests.count, 2) + XCTAssertEqual(service.restRequests[0].request, originalRequest) + XCTAssertEqual(service.restRequests[1].request, originalRequest) + XCTAssertTrue(viewModel.isResting) + } + + @MainActor + func testSkippingFailedRestStartKeepsSavedSetAndAllowsNextSet() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + service.nextRestStartError = serverError(message: "휴식 생성 오류") + let viewModel = makeViewModel(service: service, start: start) + + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertFalse(didComplete) + XCTAssertTrue(viewModel.skipPendingRestStart()) + XCTAssertTrue(viewModel.plan.exercises[0].sets[0].isCompleted) + XCTAssertNil(viewModel.pendingRestStart) + XCTAssertFalse(viewModel.isResting) + + let didCompleteNextSet = await viewModel.completeSet(exerciseIndex: 0, setIndex: 1, at: start.addingTimeInterval(90)) + + XCTAssertTrue(didCompleteNextSet) + XCTAssertEqual(service.setRequests.count, 2) + XCTAssertEqual(service.restRequests.count, 1) + } + + @MainActor + func testFailedServerRestSkipLeavesTimerUntilRetrySucceeds() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + let viewModel = makeViewModel(service: service, start: start) + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didComplete) + let originalEnd = try XCTUnwrap(viewModel.activeRestEndsAt) + service.nextSkipError = serverError(message: "건너뛰기 실패") + + let didSkipFirstTry = await viewModel.requestServerRestSkip() + + XCTAssertFalse(didSkipFirstTry) + XCTAssertEqual(viewModel.activeRestEndsAt, originalEnd) + XCTAssertTrue(viewModel.isResting) + XCTAssertEqual(viewModel.serverActionErrorMessage, "건너뛰기 실패") + + let didRetry = await viewModel.retryFailedServerAction() + + XCTAssertTrue(didRetry) + XCTAssertEqual(service.skipRequests, ["workout-1", "workout-1"]) + XCTAssertFalse(viewModel.isResting) + XCTAssertFalse(viewModel.isServerManagedRest) + } + + @MainActor + func testFailedServerRestExtensionLeavesTimerUntilRetrySucceeds() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + let viewModel = makeViewModel(service: service, start: start) + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didComplete) + let originalEnd = try XCTUnwrap(viewModel.activeRestEndsAt) + service.nextExtendError = serverError(message: "연장 실패") + + let didExtendFirstTry = await viewModel.requestServerRestExtension(by: 15) + + XCTAssertFalse(didExtendFirstTry) + XCTAssertEqual(viewModel.activeRestEndsAt, originalEnd) + XCTAssertEqual(viewModel.activeRestDurationSeconds, 75) + XCTAssertEqual(viewModel.serverActionErrorMessage, "연장 실패") + + service.nextExtendResponse = ExtendCurrentRestResponse( + restId: "rest-1", + endsAt: serverDate(start.addingTimeInterval(90)), + remainingSeconds: 90 + ) + let didRetry = await viewModel.retryFailedServerAction() + + XCTAssertTrue(didRetry) + XCTAssertEqual(service.extendRequests.count, 2) + XCTAssertEqual(service.extendRequests[0].0, "workout-1") + XCTAssertEqual(service.extendRequests[0].1, 15) + XCTAssertEqual(service.extendRequests[1].0, "workout-1") + XCTAssertEqual(service.extendRequests[1].1, 15) + XCTAssertEqual(viewModel.activeRestEndsAt, start.addingTimeInterval(90)) + XCTAssertEqual(viewModel.activeRestDurationSeconds, 90) + } + + @MainActor + func testForegroundRefreshUsesCurrentServerRestAndSkipsLookupWhenNotResting() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + let viewModel = makeViewModel(service: service, start: start) + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + XCTAssertTrue(didComplete) + service.currentRestResponse = CurrentRestResponse( + restId: "rest-1", + status: "ACTIVE", + startedAt: serverDate(start), + endsAt: serverDate(start.addingTimeInterval(50)), + remainingSeconds: 50, + notificationAt: serverDate(start.addingTimeInterval(45)) + ) + + let didRefresh = await viewModel.refreshActiveServerRest(at: start) + + XCTAssertTrue(didRefresh) + XCTAssertEqual(viewModel.activeRestEndsAt, start.addingTimeInterval(50)) + XCTAssertEqual(viewModel.activeRestDurationSeconds, 50) + XCTAssertEqual(service.currentRestRequests.count, 1) + + viewModel.skipRest() + let didRefreshWhenIdle = await viewModel.refreshActiveServerRest(at: start) + XCTAssertTrue(didRefreshWhenIdle) + XCTAssertEqual(service.currentRestRequests.count, 1) + } + + @MainActor + func testTimedSetUsesLocalRestWithoutCallingRestAPI() async throws { + let start = Date(timeIntervalSince1970: 1_800_000_000) + let service = MockWorkoutSessionRestService(startDate: start) + let viewModel = makeViewModel(service: service, start: start, measure: .seconds) + + let didComplete = await viewModel.completeSet(exerciseIndex: 0, setIndex: 0, at: start) + + XCTAssertTrue(didComplete) + XCTAssertTrue(viewModel.isResting) + XCTAssertFalse(viewModel.isServerManagedRest) + XCTAssertTrue(service.setRequests.isEmpty) + XCTAssertTrue(service.restRequests.isEmpty) + } + + @MainActor + private func makeViewModel( + service: MockWorkoutSessionRestService, + start: Date, + measure: RoutineExerciseMeasure = .repetitions + ) -> WorkoutSessionViewModel { + let sets = [ + WorkoutSessionSet(weightKilograms: 40, repetitions: 8), + WorkoutSessionSet(weightKilograms: 42.5, repetitions: 8) + ] + let exercises = [ + WorkoutSessionExercise( + id: "exercise-1", + name: "벤치 프레스", + target: "가슴", + restDurationSeconds: 75, + formSteps: [], + beginnerTip: "", + sets: sets, + measure: measure + ), + 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: exercises + ) + return WorkoutSessionViewModel( + plan: plan, + startedAt: start, + serverWorkoutID: "workout-1", + progressService: service + ) + } + + private func serverError(statusCode: Int = 503, message: String) -> APIClientError { + .server(statusCode: statusCode, detail: ServerErrorDetail(code: "TEMPORARY_FAILURE", message: message)) + } + + private func serverDate(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } +}