From 13d42bbd9751174b18fa904c6dc5624658fc4ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Tue, 3 Nov 2026 23:54:32 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20[#182]=20=EC=9B=90=ED=8C=90=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=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 --- .../WorkoutPlateCalculatorSheet.swift | 89 +++++++++++-- .../WorkoutPlateCalculatorViewModel.swift | 117 ++++++++++++++++++ .../DebugOnboardingNetworking.swift | 12 ++ 3 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 GYMUP/Sources/Features/WorkoutSession/WorkoutPlateCalculatorViewModel.swift diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutPlateCalculatorSheet.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutPlateCalculatorSheet.swift index d37252f..7d78a36 100644 --- a/GYMUP/Sources/Features/WorkoutSession/WorkoutPlateCalculatorSheet.swift +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutPlateCalculatorSheet.swift @@ -3,8 +3,17 @@ import SwiftUI struct WorkoutPlateCalculatorSheet: View { let target: Double @Environment(\.dismiss) private var dismiss + @State private var viewModel: WorkoutPlateCalculatorViewModel - private var plates: [Double]? { WorkoutPlateCalculator.platesPerSide(for: target) } + init(target: Double) { + self.target = target +#if DEBUG + let service: WorkoutPlateCalculating? = DebugOnboardingNetworking.makePlateCalculatorService() +#else + let service: WorkoutPlateCalculating? = nil +#endif + _viewModel = State(initialValue: WorkoutPlateCalculatorViewModel(target: target, service: service)) + } var body: some View { ScrollView { @@ -26,11 +35,14 @@ struct WorkoutPlateCalculatorSheet: View { .frame(maxWidth: .infinity).accessibilityHidden(true) row("바", "20 kg") row("양쪽 각각", plateDescription) - row("사용 가능 원판", "25 · 20 · 15 · 10 · 5 · 2.5 · 1.25") - if plates == nil { - Text("20kg 이상, 2.5kg 단위의 중량을 입력해 주세요.") - .font(GymupTypography.uiMedium).foregroundStyle(GymupColors.secondaryText) + if let response = viewModel.response { + row("실제 총중량", "\(weight(response.actualWeight)) kg") + if !response.achievable { + row("목표보다 부족", "\(weight(response.remainder)) kg") + } } + row("사용 가능 원판", "25 · 20 · 15 · 10 · 5 · 2.5 · 1.25") + calculationFeedback Button("닫기") { dismiss() } .font(GymupTypography.sectionTitle) .foregroundStyle(GymupColors.primaryText) @@ -45,11 +57,74 @@ struct WorkoutPlateCalculatorSheet: View { .presentationDetents([.height(555)]) .presentationCornerRadius(28) .presentationDragIndicator(.visible) + .task { await viewModel.calculateIfAvailable() } } private var plateDescription: String { - guard let plates else { return "계산 불가" } - return plates.isEmpty ? "원판 없음" : plates.map { "\(weight($0)) kg" }.joined(separator: " + ") + guard let response = viewModel.response else { + return viewModel.isCalculating ? "계산 중..." : "계산 불가" + } + guard !response.eachSide.isEmpty else { return "원판 없음" } + return response.eachSide + .sorted { $0.weight > $1.weight } + .map { plate in + let count = plate.count > 1 ? " × \(plate.count)" : "" + return "\(weight(plate.weight)) kg\(count)" + } + .joined(separator: " + ") + } + + @ViewBuilder + private var calculationFeedback: some View { + if viewModel.isCalculating { + HStack(spacing: 8) { + ProgressView() + .tint(GymupColors.brandText) + .accessibilityHidden(true) + Text("가장 가까운 조합을 확인하고 있어요.") + .font(GymupTypography.uiMedium) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundStyle(GymupColors.secondaryText) + .accessibilityElement(children: .combine) + } else if let errorMessage = viewModel.serverErrorMessage { + VStack(alignment: .leading, spacing: 4) { + Label { + Text(errorMessage) + .font(GymupTypography.uiMedium) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "exclamationmark.circle.fill") + .accessibilityHidden(true) + } + .foregroundStyle(GymupColors.accent) + .accessibilityIdentifier("workout.plate.serverError") + + if viewModel.canRetry { + Button { + Task { await viewModel.retry() } + } label: { + Text("다시 시도") + .font(GymupTypography.uiBold) + .foregroundStyle(GymupColors.brandText) + .frame(minHeight: 44, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("원판 계산 다시 시도") + .accessibilityHint("같은 목표 중량으로 서버에 다시 요청합니다") + .accessibilityIdentifier("workout.plate.retry") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else if viewModel.response == nil { + Text(viewModel.isValidTarget + ? "정확한 조합을 만들 수 없어요. 2.5kg 단위로 조정해 주세요." + : "20kg 이상, 999kg 이하 중량을 입력해 주세요.") + .font(GymupTypography.uiMedium) + .foregroundStyle(GymupColors.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } } private func weight(_ value: Double) -> String { diff --git a/GYMUP/Sources/Features/WorkoutSession/WorkoutPlateCalculatorViewModel.swift b/GYMUP/Sources/Features/WorkoutSession/WorkoutPlateCalculatorViewModel.swift new file mode 100644 index 0000000..8e98702 --- /dev/null +++ b/GYMUP/Sources/Features/WorkoutSession/WorkoutPlateCalculatorViewModel.swift @@ -0,0 +1,117 @@ +import Foundation +import Observation + +protocol WorkoutPlateCalculating { + func calculate(_ request: PlateCalculatorRequest) async throws -> PlateCalculatorResponse +} + +extension PlateCalculatorAPIService: WorkoutPlateCalculating {} + +@Observable +@MainActor +final class WorkoutPlateCalculatorViewModel { + let target: Double + + private let service: WorkoutPlateCalculating? + private let request: PlateCalculatorRequest? + private var didAttemptCalculation = false + + private(set) var response: PlateCalculatorResponse? + private(set) var isCalculating = false + private(set) var serverErrorMessage: String? + + init(target: Double, service: WorkoutPlateCalculating?) { + self.target = target + self.service = service + request = Self.makeRequest(target: target) + response = Self.localResponse(target: target) + } + + var isValidTarget: Bool { request != nil } + var canRetry: Bool { serverErrorMessage != nil && service != nil && request != nil } + var canRequestServerResult: Bool { service != nil && request != nil } + + func calculateIfAvailable() async { + guard !didAttemptCalculation else { return } + await calculate() + } + + func retry() async { + guard canRetry else { return } + await calculate() + } + + private func calculate() async { + guard !isCalculating, + let service, + let request + else { + return + } + + didAttemptCalculation = true + isCalculating = true + serverErrorMessage = nil + defer { isCalculating = false } + + do { + response = try await service.calculate(request) + } catch { + serverErrorMessage = Self.errorMessage(for: error, hasLocalResult: response != nil) + } + } + + private static func makeRequest(target: Double) -> PlateCalculatorRequest? { + guard target.isFinite, + target >= WorkoutPlateCalculator.barWeight, + target <= 999 + else { + return nil + } + + return PlateCalculatorRequest( + targetWeight: target, + barWeight: WorkoutPlateCalculator.barWeight, + availablePlates: WorkoutPlateCalculator.plates.map(JSONValue.number) + ) + } + + private static func localResponse(target: Double) -> PlateCalculatorResponse? { + guard target.isFinite, + target >= WorkoutPlateCalculator.barWeight, + target <= 999 + else { + return nil + } + + let perSideUnits = (target - WorkoutPlateCalculator.barWeight) / 2 / 1.25 + let exactUnits = abs(perSideUnits - perSideUnits.rounded()) < 0.000_001 + let localTarget = exactUnits + ? target + : WorkoutPlateCalculator.barWeight + 2 * floor(perSideUnits) * 1.25 + guard let plates = WorkoutPlateCalculator.platesPerSide(for: localTarget) else { return nil } + + let groupedPlates = Dictionary(grouping: plates, by: \.self) + .map { weight, plates in PlateWeightCount(weight: weight, count: plates.count) } + .sorted { $0.weight > $1.weight } + let actualWeight = WorkoutPlateCalculator.barWeight + 2 * plates.reduce(0, +) + let remainder = max(target - actualWeight, 0) + + return PlateCalculatorResponse( + achievable: remainder < 0.000_001, + eachSide: groupedPlates, + actualWeight: actualWeight, + remainder: remainder + ) + } + + private static func errorMessage(for error: Error, hasLocalResult: Bool) -> String { + if case let APIClientError.server(_, detail) = error { + return detail.message + } + + return hasLocalResult + ? "서버 연결에 실패해 기존 계산 결과를 보여드려요." + : "계산 결과를 불러오지 못했어요. 다시 시도해 주세요." + } +} diff --git a/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift b/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift index 6ed4d5c..0256b95 100644 --- a/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift +++ b/GYMUP/Sources/Networking/DebugOnboardingNetworking.swift @@ -131,5 +131,17 @@ enum DebugOnboardingNetworking { let client = APIClient(environment: environment, tokenProvider: tokenProvider) return UserSettingsAPIService(client: client) } + + static func makePlateCalculatorService() -> PlateCalculatorAPIService? { + 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 PlateCalculatorAPIService(client: client) + } } #endif From bf168dbba782e53ef2f37f36c513201083c23b57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Wed, 4 Nov 2026 12:11:19 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20[#182]=20=EC=9B=90=ED=8C=90=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20API=20=ED=9A=8C=EA=B7=80=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlateCalculatorAPIServiceTests.swift | 39 +++++++ .../Networking/WorkoutAPIServiceTests.swift | 19 --- ...WorkoutPlateCalculatorViewModelTests.swift | 109 ++++++++++++++++++ 3 files changed, 148 insertions(+), 19 deletions(-) create mode 100644 GYMUP/Tests/Networking/PlateCalculatorAPIServiceTests.swift create mode 100644 GYMUP/Tests/WorkoutPlateCalculatorViewModelTests.swift diff --git a/GYMUP/Tests/Networking/PlateCalculatorAPIServiceTests.swift b/GYMUP/Tests/Networking/PlateCalculatorAPIServiceTests.swift new file mode 100644 index 0000000..0e1bb08 --- /dev/null +++ b/GYMUP/Tests/Networking/PlateCalculatorAPIServiceTests.swift @@ -0,0 +1,39 @@ +import Foundation +import XCTest +@testable import GYMUP + +final class PlateCalculatorAPIServiceTests: NetworkingTestCase { + func testCalculatePostsDocumentedRequestAndDecodesSuccessResponse() async throws { + var capturedRequest: URLRequest? + installJSONResponse( + [ + "achievable": true, + "eachSide": [["weight": 20.0, "count": 2]], + "actualWeight": 100.0, + "remainder": 0.0 + ], + recordRequest: { capturedRequest = $0 } + ) + + let request = PlateCalculatorRequest( + targetWeight: 100, + barWeight: 20, + availablePlates: [25, 20, 15, 10, 5, 2.5, 1.25].map(JSONValue.number) + ) + let service = try PlateCalculatorAPIService(client: makeClient(token: "test-token")) + let response = try await service.calculate(request) + + XCTAssertEqual(capturedRequest?.httpMethod, "POST") + XCTAssertEqual(capturedRequest?.url?.path, "/api/v1/tools/plate-calculator") + XCTAssertTrue(response.achievable) + XCTAssertEqual(response.eachSide, [PlateWeightCount(weight: 20, count: 2)]) + XCTAssertEqual(response.actualWeight, 100) + XCTAssertEqual(response.remainder, 0) + + let body = try XCTUnwrap(capturedRequest?.httpBody) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(object["targetWeight"] as? Double, 100) + XCTAssertEqual(object["barWeight"] as? Double, 20) + XCTAssertEqual(object["availablePlates"] as? [Double], [25, 20, 15, 10, 5, 2.5, 1.25]) + } +} diff --git a/GYMUP/Tests/Networking/WorkoutAPIServiceTests.swift b/GYMUP/Tests/Networking/WorkoutAPIServiceTests.swift index cfd68ac..22e24df 100644 --- a/GYMUP/Tests/Networking/WorkoutAPIServiceTests.swift +++ b/GYMUP/Tests/Networking/WorkoutAPIServiceTests.swift @@ -92,25 +92,6 @@ final class WorkoutAPIServiceTests: NetworkingTestCase { let body = try XCTUnwrap(capturedRequest?.httpBody) XCTAssertEqual(try JSONDecoder().decode(CompleteWorkoutRequest.self, from: body), request) } - func testPlateCalculatorUsesTypedWeightsAndOpaquePlateItems() async throws { - var capturedRequest: URLRequest? - installJSONResponse( - ["achievable": true, "eachSide": [["weight": 20.0, "count": 1]], "actualWeight": 60.0, "remainder": 0.0], - recordRequest: { capturedRequest = $0 } - ) - - let availablePlate = JSONValue.object(["weight": .number(20), "count": .integer(2)]) - let request = PlateCalculatorRequest(targetWeight: 60, barWeight: 20, availablePlates: [availablePlate]) - let service = try PlateCalculatorAPIService(client: makeClient(token: "test-token")) - let response = try await service.calculate(request) - - XCTAssertEqual(capturedRequest?.url?.path, "/api/v1/tools/plate-calculator") - XCTAssertTrue(response.achievable) - XCTAssertEqual(response.eachSide, [PlateWeightCount(weight: 20, count: 1)]) - let body = try XCTUnwrap(capturedRequest?.httpBody) - let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) - XCTAssertEqual((object["availablePlates"] as? [[String: Any]])?.first?["weight"] as? Double, 20) - } func testWorkoutListDecodesPagedItemsAndNextSet() async throws { let paths = [ "/api/v1/workouts": ( diff --git a/GYMUP/Tests/WorkoutPlateCalculatorViewModelTests.swift b/GYMUP/Tests/WorkoutPlateCalculatorViewModelTests.swift new file mode 100644 index 0000000..177cc61 --- /dev/null +++ b/GYMUP/Tests/WorkoutPlateCalculatorViewModelTests.swift @@ -0,0 +1,109 @@ +import XCTest +@testable import GYMUP + +final class WorkoutPlateCalculatorViewModelTests: XCTestCase { + @MainActor + func testServerResponseReplacesLocalPlateComposition() async throws { + let serverResponse = PlateCalculatorResponse( + achievable: true, + eachSide: [PlateWeightCount(weight: 20, count: 2)], + actualWeight: 100, + remainder: 0 + ) + let service = PlateCalculatorServiceStub(outcomes: [.success(serverResponse)]) + let viewModel = WorkoutPlateCalculatorViewModel(target: 100, service: service) + + XCTAssertEqual( + viewModel.response?.eachSide, + [PlateWeightCount(weight: 25, count: 1), PlateWeightCount(weight: 15, count: 1)] + ) + + await viewModel.calculateIfAvailable() + + XCTAssertEqual(viewModel.response, serverResponse) + XCTAssertNil(viewModel.serverErrorMessage) + XCTAssertFalse(viewModel.canRetry) + XCTAssertEqual(service.requests.count, 1) + } + + @MainActor + func testRequestFailureKeepsLocalResultAndAllowsRetry() async throws { + let service = PlateCalculatorServiceStub(outcomes: [.failure(.offline)]) + let viewModel = WorkoutPlateCalculatorViewModel(target: 100.5, service: service) + let localResult = try XCTUnwrap(viewModel.response) + + await viewModel.calculateIfAvailable() + + XCTAssertEqual(viewModel.response, localResult) + XCTAssertEqual(viewModel.serverErrorMessage, "서버 연결에 실패해 기존 계산 결과를 보여드려요.") + XCTAssertTrue(viewModel.canRetry) + XCTAssertFalse(viewModel.isCalculating) + } + + @MainActor + func testRetrySendsTheOriginalRequestAndAppliesSuccessfulResponse() async { + let serverResponse = PlateCalculatorResponse( + achievable: true, + eachSide: [PlateWeightCount(weight: 20, count: 2)], + actualWeight: 100, + remainder: 0 + ) + let service = PlateCalculatorServiceStub(outcomes: [.failure(.offline), .success(serverResponse)]) + let viewModel = WorkoutPlateCalculatorViewModel(target: 100, service: service) + + await viewModel.calculateIfAvailable() + await viewModel.retry() + + let expectedRequest = PlateCalculatorRequest( + targetWeight: 100, + barWeight: 20, + availablePlates: [25, 20, 15, 10, 5, 2.5, 1.25].map(JSONValue.number) + ) + XCTAssertEqual(service.requests, [expectedRequest, expectedRequest]) + XCTAssertEqual(viewModel.response, serverResponse) + XCTAssertNil(viewModel.serverErrorMessage) + XCTAssertFalse(viewModel.canRetry) + } + + @MainActor + func testInvalidTargetDoesNotCreateOrSendServerRequest() async { + let service = PlateCalculatorServiceStub(outcomes: []) + let viewModel = WorkoutPlateCalculatorViewModel(target: 19, service: service) + + await viewModel.calculateIfAvailable() + + XCTAssertFalse(viewModel.isValidTarget) + XCTAssertNil(viewModel.response) + XCTAssertTrue(service.requests.isEmpty) + XCTAssertNil(viewModel.serverErrorMessage) + } +} + +@MainActor +private final class PlateCalculatorServiceStub: WorkoutPlateCalculating { + enum Outcome { + case success(PlateCalculatorResponse) + case failure(StubError) + } + + enum StubError: Error { + case offline + } + + private var outcomes: [Outcome] + private(set) var requests: [PlateCalculatorRequest] = [] + + init(outcomes: [Outcome]) { + self.outcomes = outcomes + } + + func calculate(_ request: PlateCalculatorRequest) async throws -> PlateCalculatorResponse { + requests.append(request) + switch outcomes.removeFirst() { + case let .success(response): + return response + case let .failure(error): + throw error + } + } +} From 46470138cd43553045e529771e59fa7979e9fe25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=9C=A8?= Date: Wed, 4 Nov 2026 16:35:39 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20[#182]=20=EC=84=B1=EC=9E=A5=20?= =?UTF-8?q?=EB=AA=A9=ED=91=9C=20UI=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GYMUP/UITests/GrowthFlowUITests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/GYMUP/UITests/GrowthFlowUITests.swift b/GYMUP/UITests/GrowthFlowUITests.swift index 0580bfe..9e5d89c 100644 --- a/GYMUP/UITests/GrowthFlowUITests.swift +++ b/GYMUP/UITests/GrowthFlowUITests.swift @@ -3,6 +3,8 @@ import XCTest final class GrowthFlowUITests: XCTestCase { func testGrowthScreenUsesConfiguredGoalAndStartsWithoutSampleProgress() { let app = XCUIApplication() + let goalStartDate = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date() + let goalStartDateArgument = ISO8601DateFormatter().string(from: goalStartDate) app.launchArguments = [ "-onboarding.completed", "YES", "-onboarding.experience", "1", @@ -10,7 +12,7 @@ final class GrowthFlowUITests: XCTestCase { "-onboarding.frequency", "2", "-onboarding.goal", "1", "-onboarding.weeks", "12", - "-onboarding.goal.startedAt", "2026-10-28T00:00:00Z" + "-onboarding.goal.startedAt", goalStartDateArgument ] app.launchArguments += ["--uitest-ephemeral"] app.launch()