feat(gift): 수령지 입력을 추가한다
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user