221 lines
9.9 KiB
Swift
221 lines
9.9 KiB
Swift
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")
|
|
}
|
|
}
|