diff --git a/work/tests/ReceivedGiftDetailCheck.swift b/work/tests/ReceivedGiftDetailCheck.swift new file mode 100644 index 00000000..600e28f2 --- /dev/null +++ b/work/tests/ReceivedGiftDetailCheck.swift @@ -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)") + } +} diff --git a/work/tests/check-received-gift-detail.sh b/work/tests/check-received-gift-detail.sh new file mode 100644 index 00000000..797a8c26 --- /dev/null +++ b/work/tests/check-received-gift-detail.sh @@ -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" < 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