import Combine import Foundation struct Response { let data: Data } struct MoyaError: Error { let response: Response? } enum I18n { enum Common { static let commonError = "common error" } } final class GiftRepository { static var requests: [(String, SubmitGiftRecipientAddressRequest)] = [] static var pending: [PassthroughSubject] = [] func submitRecipientAddress( applicationNo: String, request: SubmitGiftRecipientAddressRequest ) -> AnyPublisher { Self.requests.append((applicationNo, request)) let subject = PassthroughSubject() Self.pending.append(subject) return subject.eraseToAnyPublisher() } } @main enum GiftRecipientAddressCheck { static var assertions = 0 static let fields: [ReferenceWritableKeyPath] = [ \.recipientName, \.recipientPhoneNumber, \.recipientZipCode, \.recipientAddress, \.recipientAddressDetail ] static let raw = ["Name", "01012345678", " zip \n", " Address \t", " Detail \n"] static func check(_ condition: Bool, _ message: String) { guard condition else { print("FAIL: \(message)") exit(1) } assertions += 1 } static func valid(_ callback: @escaping () -> Void = {}) -> GiftRecipientAddressViewModel { GiftRepository.requests = [] GiftRepository.pending = [] let model = GiftRecipientAddressViewModel(applicationNo: " application/007 ", onSubmitted: callback) for (field, value) in zip(fields, raw) { model[keyPath: field] = value } model.recipientTermsAgreed = true model.recipientPrivacyAgreed = true return model } static func retained(_ model: GiftRecipientAddressViewModel) { for (field, value) in zip(fields, raw) { check(model[keyPath: field] == value, "raw field preserved: \(field)") } check(model.recipientTermsAgreed, "terms retained") check(model.recipientPrivacyAgreed, "privacy retained") } static func drain() { RunLoop.main.run(until: Date().addingTimeInterval(0.02)) } static func response(_ json: String) -> Response { Response(data: Data(json.utf8)) } static func succeed() { GiftRepository.pending.last!.send(response("{\"success\":true}")) GiftRepository.pending.last!.send(completion: .finished) drain() } static func main() { let initial = GiftRecipientAddressViewModel(applicationNo: "initial", onSubmitted: {}) check(!initial.canSubmit, "empty form invalid") check(!initial.isSubmitting && !initial.isShowPopup && !initial.isShowSuccessToast, "initial flags") check(initial.errorMessage.isEmpty, "initial error empty") check(!initial.hasSubmitted, "initial submission state false") initial.successToastDidDismiss() for name in ["Name1", "", "ABCDEFGHIJKLM"] { let model = valid() model.recipientName = name check(!model.canSubmit, "invalid name rejected: \(name)") } for phone in ["phone", "", "0", "0101234567", "010123456789", "01112345678", "010-1234-5678", "01012345678"] { let model = valid() model.recipientPhoneNumber = phone check(!model.canSubmit, "invalid phone rejected: \(phone)") } for name in ["가", "홍길동", "ABCDEFGHIJKL"] { for phone in ["01012345678"] { let model = valid() model.recipientName = name model.recipientPhoneNumber = phone check(model.canSubmit, "valid contact boundaries accepted: \(name), \(phone)") } } for field in fields.prefix(4) { for blank in ["", " \t\n"] { let model = valid() model[keyPath: field] = blank check(!model.canSubmit, "blank required field invalid") model.submit() check(GiftRepository.requests.isEmpty, "invalid field sends no POST") } } for agreement in [\GiftRecipientAddressViewModel.recipientTermsAgreed, \.recipientPrivacyAgreed] { let model = valid() model[keyPath: agreement] = false check(!model.canSubmit, "agreement required") model.submit() check(GiftRepository.requests.isEmpty, "missing agreement sends no POST") } for detail in ["", " \t\n"] { let model = valid() model.recipientAddressDetail = detail check(model.canSubmit, "optional detail accepts blank") model.submit() check(GiftRepository.requests.last?.1.recipientAddressDetail == "", "blank detail mapped empty") check(model.recipientAddressDetail == detail, "blank detail raw preserved") } var callbacks = 0 var successModel: GiftRecipientAddressViewModel! successModel = valid { callbacks += 1 if callbacks == 1 { successModel.successToastDidDismiss() } } check(successModel.canSubmit, "valid contact and arbitrary zip accepted") var submittedStates: [Bool] = [] let submissionObservation = successModel.$hasSubmitted.sink { submittedStates.append($0) } defer { submissionObservation.cancel() } check(submittedStates == [false], "observable initial submission state") successModel.successToastDidDismiss() check(callbacks == 0, "dismiss before success ignored") successModel.submit() check(successModel.isSubmitting && !successModel.canSubmit, "synchronous submit lock") successModel.submit() check(GiftRepository.requests.count == 1, "double tap one POST") let (applicationNo, request) = GiftRepository.requests[0] check(applicationNo == " application/007 ", "exact route applicationNo") check(request.recipientName == "Name", "map valid name") check(request.recipientPhoneNumber == "01012345678", "map valid phone") check(request.recipientZipCode == "zip", "trim zip without format validation") check(request.recipientAddress == "Address", "trim address") check(request.recipientAddressDetail == "Detail", "trim detail") check(request.recipientTermsAgreed && request.recipientPrivacyAgreed, "map agreements") retained(successModel) succeed() check(successModel.hasSubmitted, "successful POST marks submitted immediately") check(submittedStates == [false, true], "successful submission published") check(successModel.isShowSuccessToast, "success shows toast") check(successModel.successMessage == "배송지 입력 완료", "success copy") check(callbacks == 0, "success does not callback before dismissal") successModel.successToastDidDismiss() check(callbacks == 0, "visible toast blocks dismissal callback") successModel.submit() check(GiftRepository.requests.count == 1 && !successModel.canSubmit, "success blocks POST") successModel.isShowSuccessToast = false successModel.successToastDidDismiss() successModel.successToastDidDismiss() check(callbacks == 1, "callback exactly once including reentrant dismissal") check(successModel.hasSubmitted, "submitted remains true after dismissal") successModel.submit() check(GiftRepository.requests.count == 1 && !successModel.canSubmit, "permanent success lock") retained(successModel) let failures: [(Bool, String?, String)] = [ (true, nil, I18n.Common.commonError), (true, "{\"message\":\" server transport \"}", "server transport"), (true, "{\"message\":\" \\n\"}", I18n.Common.commonError), (false, "{\"success\":false,\"message\":\" server rejected \"}", "server rejected"), (false, "{\"success\":false,\"message\":\" \\t\"}", I18n.Common.commonError), (false, "{\"success\":false}", I18n.Common.commonError), (false, "malformed JSON", I18n.Common.commonError), (false, "{\"message\":\" decode rejected \"}", "decode rejected") ] for (transport, json, expected) in failures { var failureCallbacks = 0 let model = valid { failureCallbacks += 1 } model.submit() if transport { GiftRepository.pending[0].send(completion: .failure(MoyaError(response: json.map(response)))) } else { GiftRepository.pending[0].send(response(json!)) GiftRepository.pending[0].send(completion: .finished) } drain() check(!model.hasSubmitted, "every failure leaves submission state false") check(!model.isSubmitting && model.canSubmit, "failure unlocks") check(model.isShowPopup && model.errorMessage == expected, "server or fallback error") check(!model.isShowSuccessToast && failureCallbacks == 0, "failure never succeeds") model.successToastDidDismiss() check(failureCallbacks == 0, "failure dismissal ignored") retained(model) model.submit() check(GiftRepository.requests.count == 2, "retry allowed") check(model.errorMessage.isEmpty && !model.isShowPopup, "retry clears stale error") succeed() check(model.hasSubmitted, "retry success marks submitted immediately") check(model.isShowSuccessToast && failureCallbacks == 0, "retry success waits for toast") model.isShowSuccessToast = false model.successToastDidDismiss() model.successToastDidDismiss() check(failureCallbacks == 1, "retry callback once") check(model.hasSubmitted, "retry submitted remains true after dismissal") retained(model) } print("PASS: \(assertions) assertions") } }