feat(gift): 운송장 등록을 추가한다

This commit is contained in:
Yu Sung
2026-10-08 12:32:58 +09:00
parent 0751971bf3
commit 74bc1679b7
4 changed files with 967 additions and 0 deletions
@@ -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)")
}
}