From 74bc1679b7aca79b13cdefb98ba6926e11aa24f9 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 8 Oct 2026 12:32:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(gift):=20=EC=9A=B4=EC=86=A1=EC=9E=A5=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/GiftCourierPickerView.swift | 37 ++ .../GiftTrackingRegistrationView.swift | 293 ++++++++++++++ .../GiftTrackingRegistrationViewModel.swift | 254 ++++++++++++ .../tests/GiftTrackingRegistrationCheck.swift | 383 ++++++++++++++++++ 4 files changed, 967 insertions(+) create mode 100644 SodaLive/Sources/V2/Gift/Tracking/Components/GiftCourierPickerView.swift create mode 100644 SodaLive/Sources/V2/Gift/Tracking/GiftTrackingRegistrationView.swift create mode 100644 SodaLive/Sources/V2/Gift/Tracking/GiftTrackingRegistrationViewModel.swift create mode 100644 work/tests/GiftTrackingRegistrationCheck.swift diff --git a/SodaLive/Sources/V2/Gift/Tracking/Components/GiftCourierPickerView.swift b/SodaLive/Sources/V2/Gift/Tracking/Components/GiftCourierPickerView.swift new file mode 100644 index 00000000..fdb7d707 --- /dev/null +++ b/SodaLive/Sources/V2/Gift/Tracking/Components/GiftCourierPickerView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct GiftCourierPickerView: View { + @Binding var selectedCourier: String? + + var body: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + Text("택배사") + .appFont(.body5) + .foregroundColor(.white) + Menu { + ForEach(GiftConstants.courierCompanyNames, id: \.self) { courier in + Button(courier) { + selectedCourier = courier + } + } + } label: { + HStack(spacing: SodaSpacing.s12) { + Text(selectedCourier ?? "택배사 선택") + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "chevron.down") + .accessibilityHidden(true) + } + .appFont(.body3) + .foregroundColor(selectedCourier == nil ? .gray500 : .white) + .padding(SodaSpacing.s14) + .frame(maxWidth: .infinity) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14)) + } + .accessibilityLabel("택배사") + .accessibilityValue(selectedCourier ?? "택배사 선택") + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/SodaLive/Sources/V2/Gift/Tracking/GiftTrackingRegistrationView.swift b/SodaLive/Sources/V2/Gift/Tracking/GiftTrackingRegistrationView.swift new file mode 100644 index 00000000..8eb7a13f --- /dev/null +++ b/SodaLive/Sources/V2/Gift/Tracking/GiftTrackingRegistrationView.swift @@ -0,0 +1,293 @@ +import SwiftUI + +struct GiftTrackingRegistrationView: View { + @StateObject private var viewModel: GiftTrackingRegistrationViewModel + @State private var hasAppeared = false + @State private var isActive = false + @State private var isShowingCancelDialog = false + @FocusState private var isTrackingNumberFocused: Bool + + init(applicationNo: String) { + _viewModel = StateObject(wrappedValue: GiftTrackingRegistrationViewModel(applicationNo: applicationNo, onUnavailable: { unavailableApplicationNo in + AppState.shared.replaceCurrentAppStep( + step: .giftDetail(applicationNo: unavailableApplicationNo), + ifCurrentStep: { step in + guard case .giftTrackingRegistration(let currentApplicationNo) = step else { return false } + return currentApplicationNo == unavailableApplicationNo + } + ) + }, onMutationCompleted: { completedApplicationNo, completion in + AppState.shared.replaceCurrentAppStep( + step: .giftDetail(applicationNo: completedApplicationNo), + ifCurrentStep: { step in + guard case .giftTrackingRegistration(let currentApplicationNo) = step else { return false } + return currentApplicationNo == completedApplicationNo + }, + completion: completion + ) + })) + } + + var body: some View { + VStack(spacing: 0) { + DetailNavigationBar(title: "운송장 등록") + .disabled(viewModel.isSubmitting) + BaseView(isLoading: .constant(viewModel.isLoading || viewModel.isSubmitting)) { + if let summary = viewModel.summary, let mailbox = viewModel.mailbox { + ScrollView { + LazyVStack(alignment: .leading, spacing: SodaSpacing.s24) { + summaryCard(summary) + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + sectionHeader("보내실 주소", helper: "아래 주소로 발송한 뒤 운송장 번호를 등록해 주세요") + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + GiftInfoRow(title: "사서함", value: mailbox.name) + GiftInfoRow(title: "주소", value: mailbox.address) + GiftInfoRow(title: "연락처", value: mailbox.phoneNumber) + } + .padding(SodaSpacing.s14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14)) + } + sectionDivider + trackingSection + sectionDivider + guidanceSection + } + .padding(.horizontal, SodaSpacing.s14) + .padding(.top, SodaSpacing.s24) + .padding(.bottom, SodaSpacing.s32) + } + .scrollDismissesKeyboard(.interactively) + .safeAreaInset(edge: .bottom, spacing: 0) { + actionBar + } + } else if !viewModel.isLoading && !viewModel.errorMessage.isEmpty { + ScrollView { + VStack(spacing: SodaSpacing.s16) { + Text(viewModel.errorMessage) + .appFont(.body3) + .foregroundColor(.gray500) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + if !viewModel.shouldReturnToDetail { + Button(action: viewModel.fetch) { + Text("다시 시도") + .appFont(.body2) + .foregroundColor(.white) + .padding(.horizontal, SodaSpacing.s16) + .frame(minHeight: SodaSpacing.s48) + .background(Color.gray800) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(SodaSpacing.s24) + .frame(maxWidth: .infinity) + } + } + } + .disabled(viewModel.isSubmitting) + } + .background(Color.black.ignoresSafeArea()) + .alert("신청 취소", isPresented: $isShowingCancelDialog) { + Button("취소하기", role: .destructive) { + viewModel.confirmCancel() + } + .disabled(viewModel.isSubmitting) + Button("나가기", role: .cancel) { + isShowingCancelDialog = false + } + } message: { + Text(cancelMessage) + } + .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage) + .sodaToast(isPresented: $viewModel.isShowSuccessToast, message: viewModel.successMessage) + .onChange(of: viewModel.isShowPopup) { isPresented in + if !isPresented && isActive { + viewModel.errorToastDidDismiss() + } + } + .onChange(of: viewModel.isShowSuccessToast) { isPresented in + if !isPresented && isActive { + viewModel.successToastDidDismiss() + } + } + .onAppear { + isActive = true + viewModel.errorToastDidDismiss() + viewModel.successToastDidDismiss() + guard !hasAppeared else { return } + hasAppeared = true + viewModel.fetch() + } + .onDisappear { + isActive = false + } + } + + private func summaryCard(_ summary: SentGiftDetailDisplayModel) -> some View { + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + Text(summary.applicationNo) + .appFont(.caption3) + .foregroundColor(.gray500) + HStack(alignment: .top, spacing: SodaSpacing.s12) { + Circle() + .fill(Color.gray800) + .frame(width: 42, height: 42) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: SodaSpacing.s6) { + if let nickname = summary.recipientCreatorNickname { + Text(nickname) + .appFont(size: 14, weight: .semibold) + .foregroundColor(.white) + } + Text([summary.sizeName, summary.categoryName, summary.applicationDateText.map { "\($0) 신청" }] + .compactMap { $0 }.joined(separator: " · ")) + .appFont(.caption3) + .foregroundColor(.gray500) + } + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(SodaSpacing.s14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14)) + } + + private func sectionHeader(_ title: String, helper: String) -> some View { + VStack(alignment: .leading, spacing: SodaSpacing.s8) { + Text(title) + .appFont(.body1) + .foregroundColor(.white) + .accessibilityAddTraits(.isHeader) + Text(helper) + .appFont(.caption2) + .foregroundColor(.gray500) + } + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var trackingSection: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + sectionHeader("운송장 정보", helper: "등록 완료 후 수정이 불가능하니 신중히 적어주세요") + GiftCourierPickerView(selectedCourier: $viewModel.selectedCourier) + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + Text("운송장 번호") + .appFont(.body5) + .foregroundColor(.white) + TextField("운송장 번호를 입력해 주세요", text: Binding( + get: { viewModel.trackingNumber }, + set: { value in + guard GiftTrackingRegistrationViewModel.acceptsTrackingEditing(value) else { return } + viewModel.trackingNumber = value + } + )) + .appFont(.body3) + .foregroundColor(.white) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.numberPad) + .focused($isTrackingNumberFocused) + .submitLabel(.done) + .onSubmit { isTrackingNumberFocused = false } + .padding(SodaSpacing.s14) + .frame(maxWidth: .infinity) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14)) + .accessibilityLabel("운송장 번호") + } + } + } + + private var sectionDivider: some View { + Color.gray900 + .frame(height: 7) + .padding(.horizontal, -SodaSpacing.s14) + .accessibilityHidden(true) + } + + private var guidanceSection: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + Text("등록 안내") + .appFont(.body1) + .foregroundColor(.white) + .accessibilityAddTraits(.isHeader) + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + ForEach(registrationGuidance, id: \.self) { guidance in + HStack(alignment: .top, spacing: SodaSpacing.s6) { + Text("•").accessibilityHidden(true) + Text(guidance) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .appFont(.caption2) + .foregroundColor(.gray500) + } + } + .padding(SodaSpacing.s14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaRadius.r14)) + } + } + + private var registrationGuidance: [String] { + [ + "신청일로부터 3일 이내에 운송장 번호를 등록해 주세요.", + "3일 이내 운송장 번호를 등록하지 않은 경우, 선물 보내기가 자동으로 취소되며 사용한 캔이 모두 환불됩니다.", + "선물은 반드시 배송비 선불로 발송해 주세요. 착불로 발송된 선물은 수취가 거부됩니다.", + "진행 상태는 신청 상세에서 확인할 수 있습니다.", + "검수를 통과한 선물만 크리에이터에게 재배송됩니다." + ] + } + + private var actionBar: some View { + HStack(spacing: SodaSpacing.s14) { + Button { + guard viewModel.canCancel else { return } + isTrackingNumberFocused = false + isShowingCancelDialog = true + } label: { + Text("신청 취소") + .appFont(.body2) + .foregroundColor(viewModel.canCancel ? .white : .gray500) + .padding(.vertical, SodaSpacing.s16) + .frame(maxWidth: .infinity, minHeight: SodaSpacing.s48 + SodaSpacing.s4) + .overlay(Capsule().stroke(Color.white.opacity(0.3), lineWidth: 1)) + } + .disabled(!viewModel.canCancel) + Button { + isTrackingNumberFocused = false + viewModel.registerTracking() + } label: { + Text("등록 완료") + .appFont(.body2) + .foregroundColor(viewModel.canSubmit ? .white : .gray500) + .padding(.vertical, SodaSpacing.s16) + .frame(maxWidth: .infinity, minHeight: SodaSpacing.s48 + SodaSpacing.s4) + .background(viewModel.canSubmit ? Color.button : Color.gray900) + .clipShape(Capsule()) + } + .disabled(!viewModel.canSubmit) + } + .buttonStyle(.plain) + .padding(SodaSpacing.s14) + .background(Color.black) + } + + private var cancelMessage: String { + let firstLine: String + if let nickname = viewModel.summary?.recipientCreatorNickname?.trimmingCharacters(in: .whitespacesAndNewlines), + !nickname.isEmpty { + firstLine = "\(nickname)에게 보내는 선물을 취소할까요?" + } else { + firstLine = "보내는 선물을 취소할까요?" + } + return firstLine + "\n선물 보내기 규정에 따라 선물 취소 및 사용한 캔이 모두 환불됩니다." + } +} diff --git a/SodaLive/Sources/V2/Gift/Tracking/GiftTrackingRegistrationViewModel.swift b/SodaLive/Sources/V2/Gift/Tracking/GiftTrackingRegistrationViewModel.swift new file mode 100644 index 00000000..9b51056e --- /dev/null +++ b/SodaLive/Sources/V2/Gift/Tracking/GiftTrackingRegistrationViewModel.swift @@ -0,0 +1,254 @@ +import Foundation +import Combine + +final class GiftTrackingRegistrationViewModel: ObservableObject { + private let applicationNo: String + private let onUnavailable: (String) -> Void + private let onMutationCompleted: (String, @escaping (Bool) -> Void) -> Void + private let repository = GiftRepository() + private var subscription: AnyCancellable? + private var mutationSubscription: AnyCancellable? + private var hasCompletedMutation = false + private var hasStartedMutationNavigation = false + + @Published private(set) var summary: SentGiftDetailDisplayModel? + @Published private(set) var mailbox: GiftAddressDisplayModel? + @Published private(set) var isLoading = false + @Published private(set) var isSubmitting = false + @Published private(set) var errorMessage = "" + @Published private(set) var successMessage = "운송장이 등록되었습니다." + @Published private(set) var shouldReturnToDetail = false + @Published var isShowPopup = false + @Published var isShowSuccessToast = false + @Published var selectedCourier: String? + @Published var trackingNumber = "" + + static func acceptsTrackingEditing(_ value: String) -> Bool { + value.utf8.count <= 14 && value.utf8.allSatisfy { (48...57).contains($0) } + } + + static func acceptsTrackingSubmission(_ value: String) -> Bool { + value.utf8.count >= 10 && acceptsTrackingEditing(value) + } + + var canRegister: Bool { + mailbox != nil && !isLoading && !shouldReturnToDetail + } + + var canSubmit: Bool { + guard let selectedCourier else { return false } + return canRegister + && !isSubmitting + && !hasCompletedMutation + && !selectedCourier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && Self.acceptsTrackingSubmission(trackingNumber) + } + + var canCancel: Bool { + canRegister && summary?.status == .received && !isSubmitting && !hasCompletedMutation + } + + init( + applicationNo: String, + onUnavailable: @escaping (String) -> Void = { _ in } + ) { + self.applicationNo = applicationNo + self.onUnavailable = onUnavailable + self.onMutationCompleted = { _, completion in + completion(true) + } + } + + init( + applicationNo: String, + onUnavailable: @escaping (String) -> Void, + onMutationCompleted: @escaping (String, @escaping (Bool) -> Void) -> Void + ) { + self.applicationNo = applicationNo + self.onUnavailable = onUnavailable + self.onMutationCompleted = onMutationCompleted + } + + func fetch() { + guard !isLoading, !isSubmitting, !shouldReturnToDetail else { return } + + summary = nil + mailbox = nil + errorMessage = "" + isShowPopup = false + isLoading = true + + subscription = repository.getGift(applicationNo: applicationNo) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self else { return } + + if case .failure(let error) = result { + self.showError(data: error.response?.data) + } + self.isLoading = false + } receiveValue: { [weak self] response in + guard let self else { return } + + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + guard decoded.success, let detail = decoded.data else { + self.showError(data: response.data) + return + } + guard let sentDetail = GiftDisplayPolicy.sentDetail(from: detail) else { + self.showUnavailable(data: response.data) + return + } + guard sentDetail.status != .canceled else { + self.showUnavailable(data: response.data) + return + } + guard detail.trackingRequired else { + self.showUnavailable(data: response.data) + return + } + guard let mailbox = detail.mailbox else { + self.showMissingMailboxError() + return + } + + self.summary = sentDetail + self.mailbox = GiftAddressDisplayModel( + name: mailbox.name, + phoneNumber: mailbox.phoneNumber, + address: mailbox.address + ) + } catch { + self.showError(data: response.data) + } + } + } + + func registerTracking() { + guard canSubmit, let selectedCourier else { return } + + let request = RegisterGiftTrackingRequest( + courierCompanyName: selectedCourier, + trackingNumber: trackingNumber.trimmingCharacters(in: .whitespacesAndNewlines) + ) + beginMutation() + + mutationSubscription = repository.registerTracking(applicationNo: applicationNo, request: request) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self, !self.hasCompletedMutation else { return } + if case .failure(let error) = result { + self.finishMutationFailure(data: error.response?.data) + } + } receiveValue: { [weak self] response in + guard let self, !self.hasCompletedMutation else { return } + + do { + let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) + guard decoded.success else { + self.finishMutationFailure(data: response.data) + return + } + self.finishRegistrationSuccess() + } catch { + self.finishMutationFailure(data: response.data) + } + } + } + + func confirmCancel() { + guard canCancel else { return } + + beginMutation() + + mutationSubscription = repository.cancelGift(applicationNo: applicationNo) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self, !self.hasCompletedMutation else { return } + if case .failure(let error) = result { + self.finishMutationFailure(data: error.response?.data) + } + } receiveValue: { [weak self] response in + guard let self, !self.hasCompletedMutation else { return } + + do { + let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) + guard decoded.success else { + self.finishMutationFailure(data: response.data) + return + } + self.finishCancellationSuccess() + } catch { + self.finishMutationFailure(data: response.data) + } + } + } + + func successToastDidDismiss() { + guard hasCompletedMutation, !isShowSuccessToast else { return } + + navigateAfterMutation() + } + + func errorToastDidDismiss() { + guard !isShowPopup, shouldReturnToDetail else { return } + + shouldReturnToDetail = false + onUnavailable(applicationNo) + } + + private func beginMutation() { + isSubmitting = true + errorMessage = "" + isShowPopup = false + } + + private func finishRegistrationSuccess() { + hasCompletedMutation = true + isShowSuccessToast = true + } + + private func finishCancellationSuccess() { + hasCompletedMutation = true + navigateAfterMutation() + } + + private func finishMutationFailure(data: Data?) { + isSubmitting = false + showError(data: data) + } + + private func navigateAfterMutation() { + guard hasCompletedMutation, !hasStartedMutationNavigation else { return } + + hasStartedMutationNavigation = true + onMutationCompleted(applicationNo) { [weak self] didReplace in + guard let self, !didReplace, self.isSubmitting else { return } + + self.isSubmitting = false + self.fetch() + } + } + + private func showUnavailable(data: Data?) { + showError(data: data) + shouldReturnToDetail = true + } + + private func showMissingMailboxError() { + errorMessage = "받을 주소가 등록되어 있지 않습니다." + isShowPopup = true + } + + private func showError(data: Data?) { + struct ErrorResponse: Decodable { + let message: String? + } + + let decoded = data.flatMap { try? JSONDecoder().decode(ErrorResponse.self, from: $0) } + let message = decoded?.message?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + errorMessage = message.isEmpty ? I18n.Common.commonError : message + isShowPopup = true + } +} diff --git a/work/tests/GiftTrackingRegistrationCheck.swift b/work/tests/GiftTrackingRegistrationCheck.swift new file mode 100644 index 00000000..9e193100 --- /dev/null +++ b/work/tests/GiftTrackingRegistrationCheck.swift @@ -0,0 +1,383 @@ +import Combine +import Foundation + +struct Response { + let data: Data +} + +struct MoyaError: Error { + let response: Response? +} + +struct ApiResponse: Decodable { + let success: Bool + let data: T? +} + +struct ApiResponseWithoutData: Decodable { + let success: Bool +} + +enum GiftStatus { + case received + case trackingRegistered + case canceled +} + +struct SentGiftDetailDisplayModel { + let status: GiftStatus +} + +struct GiftAddressDisplayModel { + let name: String + let phoneNumber: String + let address: String +} + +struct GiftMailboxResponse: Decodable { + let name: String + let phoneNumber: String + let address: String +} + +struct GiftDetailResponse: Decodable { + let direction: String + let status: String + let trackingRequired: Bool + let mailbox: GiftMailboxResponse? +} + +enum GiftDisplayPolicy { + static func sentDetail(from detail: GiftDetailResponse) -> SentGiftDetailDisplayModel? { + guard detail.direction == "SENT" else { return nil } + switch detail.status { + case "CANCELED": + return SentGiftDetailDisplayModel(status: .canceled) + case "TRACKING_REGISTERED": + return SentGiftDetailDisplayModel(status: .trackingRegistered) + default: + return SentGiftDetailDisplayModel(status: .received) + } + } +} + +enum I18n { + enum Common { + static let commonError = "common error" + } +} + +struct RegisterGiftTrackingRequest { + let courierCompanyName: String + let trackingNumber: String +} + +enum RepositoryEvent { + case success(Data) + case failure(Data?) + case pending +} + +final class GiftRepository { + static var getGiftEvents: [RepositoryEvent] = [] + static var registerEvents: [RepositoryEvent] = [] + static var cancelEvents: [RepositoryEvent] = [] + static var getGiftCallCount = 0 + static var registerCallCount = 0 + static var cancelCallCount = 0 + static var registeredRequests: [RegisterGiftTrackingRequest] = [] + static var pendingRegister: PassthroughSubject? + static var pendingCancel: PassthroughSubject? + + static func reset() { + getGiftEvents = [] + registerEvents = [] + cancelEvents = [] + getGiftCallCount = 0 + registerCallCount = 0 + cancelCallCount = 0 + registeredRequests = [] + pendingRegister = nil + pendingCancel = nil + } + + func getGift(applicationNo: String) -> AnyPublisher { + Self.getGiftCallCount += 1 + return Self.publisher(for: Self.next(from: &Self.getGiftEvents), pending: nil).publisher + } + + func registerTracking( + applicationNo: String, + request: RegisterGiftTrackingRequest + ) -> AnyPublisher { + Self.registerCallCount += 1 + Self.registeredRequests.append(request) + let pending = PassthroughSubject() + let publisher = Self.publisher(for: Self.next(from: &Self.registerEvents), pending: pending) + Self.pendingRegister = publisher.pending + return publisher.publisher + } + + func cancelGift(applicationNo: String) -> AnyPublisher { + Self.cancelCallCount += 1 + let pending = PassthroughSubject() + let publisher = Self.publisher(for: Self.next(from: &Self.cancelEvents), pending: pending) + Self.pendingCancel = publisher.pending + return publisher.publisher + } + + private static func next(from events: inout [RepositoryEvent]) -> RepositoryEvent { + guard !events.isEmpty else { return .pending } + return events.removeFirst() + } + + private static func publisher( + for event: RepositoryEvent, + pending: PassthroughSubject? + ) -> (publisher: AnyPublisher, pending: PassthroughSubject?) { + switch event { + case .success(let data): + return (Just(Response(data: data)).setFailureType(to: MoyaError.self).eraseToAnyPublisher(), nil) + case .failure(let data): + return (Fail(error: MoyaError(response: data.map(Response.init(data:)))).eraseToAnyPublisher(), nil) + case .pending: + guard let pending else { fatalError("Missing pending publisher") } + return (pending.eraseToAnyPublisher(), pending) + } + } +} + +private struct Check { + private(set) var assertions = 0 + + mutating func expect( + _ condition: @autoclosure () -> Bool, + _ message: String + ) { + assertions += 1 + guard condition() else { fatalError("Assertion \(assertions) failed: \(message)") } + } +} + +private func runMainLoop() { + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) +} + +private func response(_ json: String) -> Data { + Data(json.utf8) +} + +private let validDetail = response(""" +{"success":true,"data":{"direction":"SENT","status":"RECEIVED","trackingRequired":true,"mailbox":{"name":"Soda mailbox","phoneNumber":"010-0000-0000","address":"Seoul"}}} +""") + +private let unavailableDetail = response(""" +{"success":true,"data":{"direction":"SENT","status":"RECEIVED","trackingRequired":false,"mailbox":{"name":"Soda mailbox","phoneNumber":"010-0000-0000","address":"Seoul"}}} +""") + +private let missingMailboxDetail = response(""" +{"success":true,"data":{"direction":"SENT","status":"RECEIVED","trackingRequired":true,"mailbox":null}} +""") + +private let trackingRegisteredDetail = response(""" +{"success":true,"data":{"direction":"SENT","status":"TRACKING_REGISTERED","trackingRequired":true,"mailbox":{"name":"Soda mailbox","phoneNumber":"010-0000-0000","address":"Seoul"}}} +""") + +private let mutationSuccess = response("{\"success\":true}") +private let mutationFailure = response("{\"success\":false,\"message\":\"request failed\"}") + +private func loadValid( + onUnavailable: @escaping (String) -> Void = { _ in }, + onMutationCompleted: @escaping (String, @escaping (Bool) -> Void) -> Void = { _, completion in completion(true) } +) -> GiftTrackingRegistrationViewModel { + GiftRepository.getGiftEvents = [.success(validDetail)] + let viewModel = GiftTrackingRegistrationViewModel( + applicationNo: "GIFT-001", + onUnavailable: onUnavailable, + onMutationCompleted: onMutationCompleted + ) + viewModel.fetch() + runMainLoop() + return viewModel +} + +@main +enum GiftTrackingRegistrationCheck { + static func main() { + var check = Check() + + for length in 0...15 { + let digits = String(repeating: "0", count: length) + check.expect(GiftTrackingRegistrationViewModel.acceptsTrackingEditing(digits) == (length <= 14), "editing boundary at \(length) ASCII digits") + check.expect(GiftTrackingRegistrationViewModel.acceptsTrackingSubmission(digits) == (10...14).contains(length), "submission boundary at \(length) ASCII digits") + } + for invalidNumber in ["00-12 34", " 0012345678", "0012345678 ", "0012345678\n", "00123\t5678", "00123a5678", "0012345678", "٠١٢٣٤٥٦٧٨٩", "00123😀5678", "0012345678\u{200B}"] { + check.expect(!GiftTrackingRegistrationViewModel.acceptsTrackingEditing(invalidNumber), "editing rejects the entire invalid replacement: \(invalidNumber.debugDescription)") + check.expect(!GiftTrackingRegistrationViewModel.acceptsTrackingSubmission(invalidNumber), "submission rejects non-ASCII-digit input: \(invalidNumber.debugDescription)") + } + check.expect(GiftTrackingRegistrationViewModel.acceptsTrackingEditing("0123456789"), "editing accepts every ASCII digit") + check.expect(GiftTrackingRegistrationViewModel.acceptsTrackingSubmission("0123456789"), "submission accepts every ASCII digit") + + GiftRepository.reset() + var unavailableRoutes: [String] = [] + GiftRepository.getGiftEvents = [.success(unavailableDetail)] + let unavailableViewModel = GiftTrackingRegistrationViewModel(applicationNo: "GIFT-UNAVAILABLE") { + unavailableRoutes.append($0) + } + unavailableViewModel.fetch() + runMainLoop() + check.expect(unavailableViewModel.summary == nil, "unavailable detail clears the summary") + check.expect(unavailableViewModel.mailbox == nil, "unavailable detail clears the mailbox") + check.expect(!unavailableViewModel.canRegister, "unavailable detail is not registrable") + check.expect(unavailableViewModel.shouldReturnToDetail, "unavailable detail schedules a return") + check.expect(unavailableViewModel.isShowPopup, "unavailable detail shows the existing error toast") + unavailableViewModel.errorToastDidDismiss() + check.expect(unavailableRoutes.isEmpty, "visible error toast does not navigate early") + unavailableViewModel.isShowPopup = false + unavailableViewModel.errorToastDidDismiss() + check.expect(unavailableRoutes == ["GIFT-UNAVAILABLE"], "dismissed unavailable toast returns once") + unavailableViewModel.errorToastDidDismiss() + check.expect(unavailableRoutes.count == 1, "unavailable return remains exactly once") + + GiftRepository.reset() + GiftRepository.getGiftEvents = [.success(missingMailboxDetail)] + let missingMailboxViewModel = GiftTrackingRegistrationViewModel(applicationNo: "GIFT-MAILBOX") + missingMailboxViewModel.fetch() + runMainLoop() + check.expect(missingMailboxViewModel.summary == nil, "missing mailbox does not expose a summary") + check.expect(missingMailboxViewModel.mailbox == nil, "missing mailbox is not registrable") + check.expect(missingMailboxViewModel.errorMessage == "받을 주소가 등록되어 있지 않습니다.", "missing mailbox uses the existing message") + check.expect(missingMailboxViewModel.isShowPopup, "missing mailbox shows the existing toast") + check.expect(!missingMailboxViewModel.shouldReturnToDetail, "missing mailbox remains retryable") + + GiftRepository.reset() + GiftRepository.getGiftEvents = [.success(trackingRegisteredDetail)] + let trackingRegisteredViewModel = GiftTrackingRegistrationViewModel(applicationNo: "GIFT-TRACKING") + trackingRegisteredViewModel.fetch() + runMainLoop() + check.expect(!trackingRegisteredViewModel.canCancel, "tracking-registered detail cannot be canceled") + + GiftRepository.reset() + var registrationRoutes: [String] = [] + var registrationCompletions: [(Bool) -> Void] = [] + let registrationViewModel = loadValid(onMutationCompleted: { applicationNo, completion in + registrationRoutes.append(applicationNo) + registrationCompletions.append(completion) + }) + check.expect(registrationViewModel.summary != nil, "valid detail loads its summary") + check.expect(registrationViewModel.mailbox != nil, "valid detail loads its mailbox") + check.expect(registrationViewModel.canRegister, "valid detail remains entry-eligible") + check.expect(!registrationViewModel.canSubmit, "missing courier cannot submit") + check.expect(registrationViewModel.canCancel, "loaded registration can be canceled without form input") + registrationViewModel.selectedCourier = " \n " + registrationViewModel.trackingNumber = "0012345678" + check.expect(!registrationViewModel.canSubmit, "whitespace courier cannot submit") + registrationViewModel.selectedCourier = " CJ대한통운 " + registrationViewModel.trackingNumber = " \n " + check.expect(!registrationViewModel.canSubmit, "whitespace tracking number cannot submit") + for invalidNumber in ["", "123456789", "123456789012345", "00-12 34", " 0012345678", "0012345678\n", "00123a5678", "0012345678", "٠١٢٣٤٥٦٧٨٩", "00123\t5678", "00123😀5678"] { + registrationViewModel.trackingNumber = invalidNumber + check.expect(!registrationViewModel.canSubmit, "invalid tracking number cannot submit: \(invalidNumber.debugDescription)") + registrationViewModel.registerTracking() + check.expect(GiftRepository.registerCallCount == 0, "invalid tracking number never reaches the repository") + } + for length in 10...14 { + registrationViewModel.trackingNumber = String(repeating: "0", count: length) + check.expect(registrationViewModel.canSubmit, "\(length) ASCII digits can submit") + } + registrationViewModel.trackingNumber = "0012345678" + check.expect(registrationViewModel.canCancel, "loaded registration can be canceled") + + GiftRepository.registerEvents = [.pending] + registrationViewModel.registerTracking() + check.expect(GiftRepository.registerCallCount == 1, "registration calls the repository once") + check.expect(GiftRepository.registeredRequests.first?.courierCompanyName == " CJ대한통운 ", "courier display string is preserved") + check.expect(GiftRepository.registeredRequests.first?.trackingNumber == "0012345678", "tracking number preserves leading zeros exactly") + check.expect(registrationViewModel.isSubmitting, "registration retains the shared mutation lock") + check.expect(registrationViewModel.canRegister, "mutation lock does not change entry eligibility") + check.expect(!registrationViewModel.canSubmit, "shared mutation lock blocks duplicate registration") + check.expect(!registrationViewModel.canCancel, "shared mutation lock blocks cancellation overlap") + let fetchesBeforeOverlap = GiftRepository.getGiftCallCount + registrationViewModel.fetch() + registrationViewModel.registerTracking() + registrationViewModel.confirmCancel() + check.expect(GiftRepository.getGiftCallCount == fetchesBeforeOverlap, "shared mutation lock blocks fetch overlap") + check.expect(GiftRepository.registerCallCount == 1, "shared mutation lock blocks duplicate POST") + check.expect(GiftRepository.cancelCallCount == 0, "shared mutation lock blocks cross-mutation POST") + + GiftRepository.pendingRegister?.send(Response(data: mutationSuccess)) + GiftRepository.pendingRegister?.send(completion: .finished) + runMainLoop() + check.expect(registrationViewModel.isShowSuccessToast, "registration success uses a separate success toast") + check.expect(registrationViewModel.successMessage == "운송장이 등록되었습니다.", "registration success toast copy is stable") + check.expect(registrationViewModel.isSubmitting, "registration keeps the lock until toast dismissal") + check.expect(registrationRoutes.isEmpty, "registration does not navigate before toast dismissal") + registrationViewModel.successToastDidDismiss() + check.expect(registrationRoutes.isEmpty, "visible success toast cannot trigger navigation") + registrationViewModel.isShowSuccessToast = false + registrationViewModel.successToastDidDismiss() + check.expect(registrationRoutes == ["GIFT-001"], "dismissed success toast navigates once") + registrationViewModel.successToastDidDismiss() + check.expect(registrationRoutes.count == 1, "success navigation remains exactly once") + GiftRepository.getGiftEvents = [.success(validDetail)] + registrationCompletions[0](false) + runMainLoop() + check.expect(!registrationViewModel.isSubmitting, "replacement refusal releases the mutation lock") + check.expect(GiftRepository.getGiftCallCount == fetchesBeforeOverlap + 1, "replacement refusal authoritatively refetches") + registrationViewModel.registerTracking() + registrationViewModel.confirmCancel() + check.expect(GiftRepository.registerCallCount == 1, "completed registration cannot repeat its POST") + check.expect(GiftRepository.cancelCallCount == 0, "completed registration cannot start cancellation") + + GiftRepository.reset() + GiftRepository.registerEvents = [.success(mutationFailure)] + let registrationFailureViewModel = loadValid() + registrationFailureViewModel.selectedCourier = "CJ대한통운" + registrationFailureViewModel.trackingNumber = "0012345678" + registrationFailureViewModel.registerTracking() + runMainLoop() + check.expect(!registrationFailureViewModel.isSubmitting, "registration failure releases the shared lock") + check.expect(registrationFailureViewModel.isShowPopup, "registration failure keeps the existing error toast") + check.expect(registrationFailureViewModel.selectedCourier == "CJ대한통운", "registration failure preserves courier input") + check.expect(registrationFailureViewModel.trackingNumber == "0012345678", "registration failure preserves tracking input") + check.expect(registrationFailureViewModel.summary != nil && registrationFailureViewModel.mailbox != nil, "registration failure preserves loaded display") + check.expect(registrationFailureViewModel.canSubmit, "registration failure restores submit eligibility") + + GiftRepository.reset() + var cancellationRoutes: [String] = [] + var cancellationCompletions: [(Bool) -> Void] = [] + GiftRepository.cancelEvents = [.success(mutationSuccess)] + let cancellationViewModel = loadValid(onMutationCompleted: { applicationNo, completion in + cancellationRoutes.append(applicationNo) + cancellationCompletions.append(completion) + }) + cancellationViewModel.confirmCancel() + runMainLoop() + check.expect(GiftRepository.cancelCallCount == 1, "cancellation calls the repository once") + check.expect(cancellationRoutes == ["GIFT-001"], "cancellation navigates immediately on success") + check.expect(!cancellationViewModel.isShowSuccessToast, "cancellation does not show registration toast") + check.expect(cancellationViewModel.isSubmitting, "cancellation retains the shared lock until navigation resolves") + cancellationViewModel.confirmCancel() + check.expect(GiftRepository.cancelCallCount == 1, "completed cancellation cannot repeat its POST") + GiftRepository.getGiftEvents = [.success(validDetail)] + cancellationCompletions[0](false) + runMainLoop() + check.expect(!cancellationViewModel.isSubmitting, "cancellation replacement refusal releases the lock") + check.expect(GiftRepository.getGiftCallCount == 2, "cancellation replacement refusal refetches exactly once") + + GiftRepository.reset() + GiftRepository.cancelEvents = [.failure(response("{\"message\":\"cancel failed\"}"))] + let cancellationFailureViewModel = loadValid() + cancellationFailureViewModel.selectedCourier = "CJ대한통운" + cancellationFailureViewModel.trackingNumber = "0009" + cancellationFailureViewModel.confirmCancel() + runMainLoop() + check.expect(!cancellationFailureViewModel.isSubmitting, "cancellation transport failure releases the lock") + check.expect(cancellationFailureViewModel.isShowPopup, "cancellation transport failure keeps the error toast") + check.expect(cancellationFailureViewModel.selectedCourier == "CJ대한통운", "cancellation failure preserves courier input") + check.expect(cancellationFailureViewModel.trackingNumber == "0009", "cancellation failure preserves tracking input") + check.expect(cancellationFailureViewModel.summary != nil && cancellationFailureViewModel.mailbox != nil, "cancellation failure preserves loaded display") + + print("PASS GiftTrackingRegistrationCheck assertions=\(check.assertions)") + } +}