feat(gift): 수령지 입력을 추가한다

This commit is contained in:
Yu Sung
2026-10-08 12:32:59 +09:00
parent 52f96f29ca
commit b0336902e6
4 changed files with 445 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
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<Response, MoyaError>] = []
func submitRecipientAddress(
applicationNo: String,
request: SubmitGiftRecipientAddressRequest
) -> AnyPublisher<Response, MoyaError> {
Self.requests.append((applicationNo, request))
let subject = PassthroughSubject<Response, MoyaError>()
Self.pending.append(subject)
return subject.eraseToAnyPublisher()
}
}
@main
enum GiftRecipientAddressCheck {
static var assertions = 0
static let fields: [ReferenceWritableKeyPath<GiftRecipientAddressViewModel, String>] = [
\.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")
}
}