test(gift): 받은 선물 상세를 검증한다
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
private struct Check {
|
||||
var assertions = 0
|
||||
|
||||
mutating func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
assertions += 1
|
||||
guard condition() else { fatalError("Assertion \(assertions) failed: \(message)") }
|
||||
}
|
||||
}
|
||||
|
||||
private func detail(
|
||||
direction: String = "RECEIVED",
|
||||
status: String = GiftStatus.delivered.rawValue,
|
||||
recipientAddress: GiftRecipientAddressResponse? = GiftRecipientAddressResponse(name: "수령인", phoneNumber: "전화", address: "주소"),
|
||||
undeliverableReason: String? = nil,
|
||||
nestedApplicationNo: String? = "nested-only"
|
||||
) -> GiftDetailResponse {
|
||||
GiftDetailResponse(
|
||||
applicationNo: "synthetic",
|
||||
direction: direction,
|
||||
status: status,
|
||||
statusName: "상태",
|
||||
giftInfo: GiftInfoResponse(
|
||||
recipientCreatorNickname: nil,
|
||||
senderNickname: "보낸이",
|
||||
sizeName: "중형",
|
||||
categoryName: "도서",
|
||||
applicationNo: nestedApplicationNo,
|
||||
paidCan: nil,
|
||||
tracking: nil,
|
||||
shippingRequestedAt: nil
|
||||
),
|
||||
senderInfo: nil,
|
||||
recipientAddress: recipientAddress,
|
||||
mailbox: nil,
|
||||
trackingRequired: false,
|
||||
recipientAddressRequired: true,
|
||||
recipientAddressDeadlineAt: nil,
|
||||
delivery: GiftDeliveryResponse(canceledAt: nil, undeliverableAt: nil, undeliverableReason: undeliverableReason),
|
||||
statusTimeline: GiftProgressStep.allCases.map { GiftStatusTimelineResponse(status: $0.rawValue, statusName: "상태", occurredAt: nil) }
|
||||
)
|
||||
}
|
||||
|
||||
@main
|
||||
enum ReceivedGiftDetailCheck {
|
||||
static func main() {
|
||||
var check = Check()
|
||||
|
||||
for status in GiftStatus.allCases {
|
||||
check.expect(GiftDisplayPolicy.receivedDetail(from: detail(status: status.rawValue))?.status == status, "accepts \(status.rawValue)")
|
||||
}
|
||||
check.expect(GiftDisplayPolicy.receivedDetail(from: detail(direction: "SENT")) == nil, "rejects SENT")
|
||||
check.expect(GiftDisplayPolicy.receivedDetail(from: detail(direction: "OTHER")) == nil, "rejects unknown direction")
|
||||
check.expect(GiftDisplayPolicy.receivedDetail(from: detail(status: "UNKNOWN")) == nil, "rejects unknown status")
|
||||
check.expect(GiftDisplayPolicy.sentDetail(from: detail(direction: "SENT"))?.applicationNo == "synthetic", "sent detail uses top-level applicationNo")
|
||||
check.expect(GiftDisplayPolicy.sentDetail(from: detail(direction: "SENT", nestedApplicationNo: nil))?.applicationNo == "synthetic", "sent detail uses top-level applicationNo when nested is nil")
|
||||
|
||||
let model = GiftDisplayPolicy.receivedDetail(from: detail())!
|
||||
check.expect(model.statusName == "상태", "maps statusName")
|
||||
check.expect(model.senderNickname == "보낸이", "maps senderNickname")
|
||||
check.expect(model.sizeName == "중형" && model.categoryName == "도서", "maps size and category")
|
||||
check.expect(Set(Mirror(reflecting: model).children.compactMap { $0.label }) == ["status", "statusName", "undeliverableReason", "senderNickname", "sizeName", "categoryName", "shippingRequestedDateText", "recipientAddressRequired", "recipientAddress", "progress"], "stored-property allowlist")
|
||||
check.expect(GiftDisplayPolicy.receivedDetail(from: detail(undeliverableReason: "수령인 부재"))?.undeliverableReason == "수령인 부재", "maps undeliverable reason")
|
||||
check.expect(model.recipientAddress?.address == "주소", "maps present recipient address")
|
||||
check.expect(GiftDisplayPolicy.receivedDetail(from: detail(recipientAddress: nil))?.recipientAddress == nil, "keeps nil recipient address nil")
|
||||
let undeliverable = GiftDisplayPolicy.receivedDetail(from: detail(status: GiftStatus.undeliverable.rawValue, undeliverableReason: "주소 오류"))!
|
||||
check.expect(!undeliverable.recipientAddressRequired && undeliverable.recipientAddress == nil, "suppresses undeliverable address")
|
||||
check.expect(model.progress.map(\.step) == GiftProgressStep.allCases, "progress has five steps")
|
||||
|
||||
print("PASS ReceivedGiftDetailCheck assertions=\(check.assertions)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
mode="${1:-privacy}"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
models=(
|
||||
"$root/SodaLive/Sources/V2/Gift/Models/GiftModels.swift"
|
||||
"$root/SodaLive/Sources/V2/Gift/Models/GiftDisplayModels.swift"
|
||||
"$root/SodaLive/Sources/V2/Gift/Models/GiftDisplayPolicy.swift"
|
||||
)
|
||||
|
||||
cat > "$tmp/DateParser.swift" <<'EOF'
|
||||
import Foundation
|
||||
enum DateParser { static func parse(_ text: String) -> Date? { nil } }
|
||||
EOF
|
||||
|
||||
if [[ "$mode" == "privacy" ]]; then
|
||||
swiftc "${models[@]}" "$tmp/DateParser.swift" "$root/work/tests/ReceivedGiftDetailCheck.swift" -o "$tmp/privacy"
|
||||
"$tmp/privacy"
|
||||
|
||||
cat > "$tmp/Allowed.swift" <<'EOF'
|
||||
@main
|
||||
enum AllowedProbe { static func main() {
|
||||
func detailProbe() -> GiftDetailResponse { fatalError() }
|
||||
let model = GiftDisplayPolicy.receivedDetail(from: detailProbe())
|
||||
_ = model?.status
|
||||
_ = model?.statusName
|
||||
_ = model?.undeliverableReason
|
||||
_ = model?.senderNickname
|
||||
_ = model?.sizeName
|
||||
_ = model?.categoryName
|
||||
_ = model?.shippingRequestedDateText
|
||||
_ = model?.recipientAddressRequired
|
||||
_ = model?.recipientAddress
|
||||
_ = model?.progress
|
||||
|
||||
} }
|
||||
EOF
|
||||
swiftc "${models[@]}" "$tmp/DateParser.swift" "$tmp/Allowed.swift" -o "$tmp/allowed"
|
||||
echo "PASS allowed-member probe"
|
||||
|
||||
for member in senderInfo mailbox applicationNo paidCan tracking; do
|
||||
cat > "$tmp/Forbidden.swift" <<EOF
|
||||
@main
|
||||
enum ForbiddenProbe { static func main() {
|
||||
func detailProbe() -> GiftDetailResponse { fatalError() }
|
||||
let model = GiftDisplayPolicy.receivedDetail(from: detailProbe())
|
||||
_ = model?.$member
|
||||
} }
|
||||
EOF
|
||||
if swiftc "${models[@]}" "$tmp/DateParser.swift" "$tmp/Forbidden.swift" -o "$tmp/forbidden" 2> "$tmp/error"; then
|
||||
echo "FAIL forbidden member probe: $member unexpectedly compiled" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "has no member" "$tmp/error"; then
|
||||
echo "FAIL forbidden member probe: $member failed for another reason" >&2
|
||||
cat "$tmp/error" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS forbidden member probe: $member has no member"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "foundation" ]]; then
|
||||
component="$root/SodaLive/Sources/V2/Gift/Detail/Components/ReceivedGiftDetailContentView.swift"
|
||||
if [[ -f "$component" ]]; then
|
||||
if ! xcrun swiftc -parse "$component"; then
|
||||
echo "FAIL foundation: component does not parse" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eq 'let[[:space:]]+model:[[:space:]]*ReceivedGiftDetailDisplayModel' "$component"; then
|
||||
echo "FAIL foundation: component does not declare ReceivedGiftDetailDisplayModel model" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "RED foundation: missing ReceivedGiftDetailContentView"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'case \.received(let model)' "$root/SodaLive/Sources/V2/Gift/Detail/GiftDetailView.swift" || ! grep -Fq 'ReceivedGiftDetailContentView(model: model)' "$root/SodaLive/Sources/V2/Gift/Detail/GiftDetailView.swift"; then
|
||||
echo "FAIL foundation RED: received branch integration probe failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS foundation: component and received-branch integration"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "recipient-form" ]]; then
|
||||
form="$root/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressFormView.swift"
|
||||
view_model="$root/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressViewModel.swift"
|
||||
if [[ ! -f "$form" ]]; then
|
||||
echo "RED recipient-form: missing GiftRecipientAddressFormView.swift"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$view_model" ]]; then
|
||||
echo "RED recipient-form: missing GiftRecipientAddressViewModel.swift"
|
||||
exit 1
|
||||
fi
|
||||
xcrun swiftc -parse "$form"
|
||||
xcrun swiftc -parse "$view_model"
|
||||
for input in recipientName recipientPhoneNumber recipientZipCode recipientAddress recipientAddressDetail; do
|
||||
if ! grep -Eq "var[[:space:]]+$input[[:space:]]*:[[:space:]]*String" "$view_model"; then
|
||||
echo "FAIL recipient-form: missing String input state $input" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
for agreement in recipientTermsAgreed recipientPrivacyAgreed; do
|
||||
if ! grep -Eq "var[[:space:]]+$agreement[[:space:]]*:[[:space:]]*Bool" "$view_model"; then
|
||||
echo "FAIL recipient-form: missing Bool agreement state $agreement" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if ! grep -Eq 'let[[:space:]]+model:[[:space:]]*ReceivedGiftDetailDisplayModel' "$form"; then
|
||||
echo "FAIL recipient-form: form does not declare ReceivedGiftDetailDisplayModel model" >&2
|
||||
exit 1
|
||||
fi
|
||||
detail_view="$root/SodaLive/Sources/V2/Gift/Detail/GiftDetailView.swift"
|
||||
if ! grep -q 'case \.received(let model)' "$detail_view" || \
|
||||
! grep -Fq 'model.recipientAddressRequired' "$detail_view" || \
|
||||
! grep -Fq 'GiftRecipientAddressFormView(model: model, viewModel: recipientAddressViewModel)' "$detail_view" || \
|
||||
! grep -Fq 'ReceivedGiftDetailContentView(model: model)' "$detail_view"; then
|
||||
echo "FAIL recipient-form: received branch form/fallback integration probe failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
for probe in \
|
||||
'@ObservedObject var viewModel: GiftRecipientAddressViewModel' \
|
||||
'viewModel.submit()' \
|
||||
'isActive: viewModel.canSubmit' \
|
||||
'.disabled(!viewModel.canSubmit)' \
|
||||
'.disabled(viewModel.isSubmitting || viewModel.hasSubmitted)'; do
|
||||
grep -Fq "$probe" "$form" || { echo "FAIL recipient-form: missing $probe" >&2; exit 1; }
|
||||
done
|
||||
for probe in \
|
||||
'@StateObject private var recipientAddressViewModel: GiftRecipientAddressViewModel' \
|
||||
'_viewModel = StateObject(wrappedValue: detailViewModel)' \
|
||||
'GiftRecipientAddressViewModel(applicationNo: applicationNo, onSubmitted:' \
|
||||
'detailViewModel.fetch()' \
|
||||
'.sodaToast(isPresented: $recipientAddressViewModel.isShowPopup, message: recipientAddressViewModel.errorMessage)' \
|
||||
'.sodaToast(isPresented: $recipientAddressViewModel.isShowSuccessToast, message: recipientAddressViewModel.successMessage)' \
|
||||
'.onChange(of: recipientAddressViewModel.isShowSuccessToast)' \
|
||||
'if !isPresented && isActive' \
|
||||
'recipientAddressViewModel.successToastDidDismiss()' \
|
||||
'isActive = true' \
|
||||
'isActive = false' \
|
||||
'if recipientAddressViewModel.hasSubmitted && !viewModel.isLoading && !viewModel.errorMessage.isEmpty' \
|
||||
'Button(action: viewModel.fetch)' \
|
||||
'recipientAddressViewModel.isSubmitting || recipientAddressViewModel.isShowSuccessToast' \
|
||||
'(recipientAddressViewModel.hasSubmitted && viewModel.isLoading)'; do
|
||||
grep -Fq "$probe" "$detail_view" || { echo "FAIL recipient-form: missing $probe" >&2; exit 1; }
|
||||
done
|
||||
if grep -Fq 'recipientAddressViewModel.submit()' "$detail_view"; then
|
||||
echo "FAIL recipient-form: detail retry must not POST" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS recipient-form: injection, submission, toast lifecycle, GET-only retry and fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "received-states" ]]; then
|
||||
component="$root/SodaLive/Sources/V2/Gift/Detail/Components/ReceivedGiftDetailContentView.swift"
|
||||
detail="$root/SodaLive/Sources/V2/Gift/Detail/GiftDetailView.swift"
|
||||
xcrun swiftc -parse "$component" "$detail"
|
||||
python3 - "$component" "$detail" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
component, detail = (Path(path).read_text() for path in sys.argv[1:])
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
sys.exit("FAIL received-states: " + message)
|
||||
print("PASS received-states: " + message)
|
||||
|
||||
def block(source, marker):
|
||||
require(marker in source, "structure " + marker)
|
||||
start = source.index("{", source.index(marker))
|
||||
depth = 1
|
||||
for end in range(start + 1, len(source)):
|
||||
depth += (source[end] == "{") - (source[end] == "}")
|
||||
if depth == 0:
|
||||
return source[start + 1:end], source[end + 1:]
|
||||
sys.exit("FAIL received-states: unbalanced block " + marker)
|
||||
|
||||
content, _ = block(component, "struct ReceivedGiftDetailContentView: View")
|
||||
title, _ = block(content, "private var heroTitle: String")
|
||||
label, _ = block(content, "private var statusLabel: String?")
|
||||
for state in ("trackingRegistered", "arrivedAtMailbox", "inspectionCompleted", "delivered", "undeliverable"):
|
||||
require(re.search(r"case [^:]*\." + state + r"\b[^:]*:", title), "hero state " + state)
|
||||
for text in ("배송지 입력 완료", "전달 완료", "전달 불가"):
|
||||
require('"' + text + '"' in label, "label " + text)
|
||||
for text in ("배송지 입력이 완료되었어요", "선물을 전달받았어요", "전달할 수 없는 품목입니다"):
|
||||
require('"' + text + '"' in title, "title " + text)
|
||||
for state in ("arrivedAtMailbox", "inspectionCompleted"):
|
||||
require(re.search(r"case [^:]*\." + state + r"\b[^:]*:\s*return model.statusName", title), "backend copy " + state)
|
||||
body, _ = block(content, "var body: some View")
|
||||
stack, _ = block(body, "VStack")
|
||||
require(stack.strip().startswith("hero"), "hero precedes exclusive content branch")
|
||||
unavailable, remainder = block(stack, "if model.status == .undeliverable")
|
||||
require(unavailable.strip() == "undeliverableReason", "undeliverable branch contains only reason/warning")
|
||||
require(remainder.strip().startswith("else {"), "normal content is exclusive else")
|
||||
normal, trailing = block(remainder, "else")
|
||||
require(not trailing.strip(), "no content after exclusive branch")
|
||||
require("GiftProgressView(progress: model.progress)" in normal and "giftInfo" in normal and "recipientAddress" in normal, "normal progress, gift and address")
|
||||
reason, _ = block(content, "private var undeliverableReason: some View")
|
||||
require("model.undeliverableReason?.trimmingCharacters" in reason and "!reason.isEmpty" in reason, "optional nonblank reason")
|
||||
require("해당 선물은 정책에 따라 폐기되며 반송되지 않습니다." in reason, "disposal warning")
|
||||
require(not re.search(r"GiftProgressView|giftInfo|recipientAddress|GiftRecipientAddressFormView", unavailable + reason), "no progress/gift/address/form in undeliverable content")
|
||||
require("보내주신 선물은 소다라이브 선물 정책에 따라 크리에이터에게 전달할 수 없는 품목으로 확인되었습니다." in content, "existing policy explanation")
|
||||
address, _ = block(content, "private var recipientAddress: some View")
|
||||
optional, rest = block(address, "if let address = model.recipientAddress")
|
||||
require(not rest.strip(), "nil address renders no section or placeholder")
|
||||
for probe in ('GiftInfoSectionView(title: "받는 주소")', 'GiftInfoRow(title: "이름", value: address.name)', 'GiftInfoRow(title: "휴대폰 번호", value: address.phoneNumber)', 'GiftInfoRow(title: "주소", value: address.address)'):
|
||||
require(probe in optional, "address row " + probe)
|
||||
require(not re.search(r"model\.(senderInfo|mailbox|applicationNo|paidCan|tracking)\b", component), "privacy-safe component")
|
||||
actions, _ = block(component, "struct ReceivedGiftDetailActionBar: View")
|
||||
require("guard !isNavigating else { return }" in actions and "isNavigating = true" in actions and ".disabled(isNavigating)" in actions, "inquiry repeat guard")
|
||||
require("AppState.shared.setAppStep(step: GiftConstants.inquiryStep)" in actions and '"문의하기"' in actions, "existing inquiry route")
|
||||
appear, _ = block(actions, ".onAppear")
|
||||
require("isNavigating = false" in appear, "navigation guard resets")
|
||||
require("let onPolicy: () -> Void" in actions and "onPolicy()" in actions and '"선물 정책 확인"' in actions, "policy callback")
|
||||
require(not re.search(r"https?://|openURL|URL\(", component), "no invented URL")
|
||||
received = detail.split("case .received(let model):", 1)[1].split("case .canceled(let model):", 1)[0]
|
||||
form, fallback = block(received, "if model.recipientAddressRequired")
|
||||
require("GiftRecipientAddressFormView(model: model, viewModel: recipientAddressViewModel)" in form, "form remains required-flag controlled")
|
||||
require(fallback.strip().startswith("else {"), "content remains form fallback")
|
||||
inset, _ = block(fallback, ".safeAreaInset(edge: .bottom, spacing: 0)")
|
||||
only_unavailable, trailing = block(inset, "if model.status == .undeliverable")
|
||||
require(not trailing.strip() and inset.strip().startswith("if model.status == .undeliverable"), "bottom actions only for received undeliverable")
|
||||
require("ReceivedGiftDetailActionBar(onPolicy: openGiftTerms)" in only_unavailable, "received policy action opens gift terms")
|
||||
require(detail.count("ReceivedGiftDetailActionBar") == 1, "no sent/canceled action insertion")
|
||||
sent = detail.split("case .sent(let model):", 1)[1].split("case .received(let model):", 1)[0]
|
||||
require("onPolicy: openGiftTerms" in sent, "sent policy action opens gift terms")
|
||||
require('@Environment(\\.openURL) private var openURL' in detail, "system URL opener")
|
||||
open_terms, _ = block(detail, "private func openGiftTerms()")
|
||||
require("GiftConstants.termsURL" in open_terms and "openURL(url)" in open_terms, "confirmed gift terms URL")
|
||||
require("isShowingPolicyToast" not in detail and "GiftConstants.unavailableActionMessage" not in detail, "policy toast fallback removed")
|
||||
PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "usage: $0 [privacy|foundation|recipient-form|received-states]" >&2
|
||||
exit 2
|
||||
Reference in New Issue
Block a user