feat(gift): 운송장 등록을 추가한다
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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선물 보내기 규정에 따라 선물 취소 및 사용한 캔이 모두 환불됩니다."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GiftDetailResponse>.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Response {
|
||||||
|
let data: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MoyaError: Error {
|
||||||
|
let response: Response?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ApiResponse<T: Decodable>: 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<Response, MoyaError>?
|
||||||
|
static var pendingCancel: PassthroughSubject<Response, MoyaError>?
|
||||||
|
|
||||||
|
static func reset() {
|
||||||
|
getGiftEvents = []
|
||||||
|
registerEvents = []
|
||||||
|
cancelEvents = []
|
||||||
|
getGiftCallCount = 0
|
||||||
|
registerCallCount = 0
|
||||||
|
cancelCallCount = 0
|
||||||
|
registeredRequests = []
|
||||||
|
pendingRegister = nil
|
||||||
|
pendingCancel = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGift(applicationNo: String) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
Self.getGiftCallCount += 1
|
||||||
|
return Self.publisher(for: Self.next(from: &Self.getGiftEvents), pending: nil).publisher
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerTracking(
|
||||||
|
applicationNo: String,
|
||||||
|
request: RegisterGiftTrackingRequest
|
||||||
|
) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
Self.registerCallCount += 1
|
||||||
|
Self.registeredRequests.append(request)
|
||||||
|
let pending = PassthroughSubject<Response, MoyaError>()
|
||||||
|
let publisher = Self.publisher(for: Self.next(from: &Self.registerEvents), pending: pending)
|
||||||
|
Self.pendingRegister = publisher.pending
|
||||||
|
return publisher.publisher
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelGift(applicationNo: String) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
Self.cancelCallCount += 1
|
||||||
|
let pending = PassthroughSubject<Response, MoyaError>()
|
||||||
|
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<Response, MoyaError>?
|
||||||
|
) -> (publisher: AnyPublisher<Response, MoyaError>, pending: PassthroughSubject<Response, MoyaError>?) {
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user