From 6c9d4e8d2a50ca4990f9b32f4ff9d1664f5b483c Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 8 Oct 2026 12:32:59 +0900 Subject: [PATCH] =?UTF-8?q?test(gift):=20=EC=97=B0=EB=9D=BD=EC=B2=98=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B3=84=EC=95=BD=EC=9D=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/tests/GiftContactInputCheck.swift | 49 ++++++ work/tests/check-gift-contact-input.sh | 198 +++++++++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 work/tests/GiftContactInputCheck.swift create mode 100755 work/tests/check-gift-contact-input.sh diff --git a/work/tests/GiftContactInputCheck.swift b/work/tests/GiftContactInputCheck.swift new file mode 100644 index 00000000..55377dcb --- /dev/null +++ b/work/tests/GiftContactInputCheck.swift @@ -0,0 +1,49 @@ +import Foundation + +@main +enum GiftContactInputCheck { + static var assertions = 0 + + static func check(_ condition: Bool, _ message: String) { + guard condition else { + print("FAIL gift-contact-input: \(message)") + exit(1) + } + assertions += 1 + } + + static func main() { + let names = GiftContactInputRule.name + check(names.acceptsEditing(""), "empty name is editable") + check(!names.acceptsSubmission(""), "empty name is not submittable") + for value in ["가", "가나다라마바사아자차카타", "A", "AAAAAAAAAAAA", "가나다", "ㅎㅏㄴ", "Soda", "소다Soda"] { + check(names.acceptsEditing(value), "name accepts \(value)") + check(names.acceptsSubmission(value), "name submits \(value)") + } + for value in ["가나다라마바사아자차카타파", "AAAAAAAAAAAAA", " ", "123", "!", "🙂", "日本"] { + check(!names.acceptsEditing(value), "name rejects \(value)") + check(!names.acceptsSubmission(value), "name does not submit \(value)") + } + + let phones = GiftContactInputRule.phoneNumber + check(phones.acceptsEditing(""), "empty phone is editable") + check(!phones.acceptsSubmission(""), "empty phone is not submittable") + for length in 1...11 { + let value = String(repeating: "1", count: length) + check(phones.acceptsEditing(value), "phone accepts length \(length)") + check(!phones.acceptsSubmission(value), "phone does not submit length \(length)") + } + check(phones.acceptsEditing("01012345678"), "valid phone is editable") + check(phones.acceptsSubmission("01012345678"), "valid phone submits") + for value in [String(repeating: "1", count: 12), "1 2", "+123", "abc", "🙂", "123", "010-1234-5678", "010 12345678"] { + check(!phones.acceptsEditing(value), "phone rejects \(value)") + check(!phones.acceptsSubmission(value), "phone does not submit \(value)") + } + for value in ["01112345678", "11012345678", "12345678901"] { + check(phones.acceptsEditing(value), "phone accepts wrong-prefix editing \(value)") + check(!phones.acceptsSubmission(value), "phone rejects wrong-prefix submission \(value)") + } + + print("PASS gift-contact-input: \(assertions) assertions") + } +} diff --git a/work/tests/check-gift-contact-input.sh b/work/tests/check-gift-contact-input.sh new file mode 100755 index 00000000..a862bc3e --- /dev/null +++ b/work/tests/check-gift-contact-input.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +temp_dir="$(mktemp -d)" +trap 'rm -rf "$temp_dir"' EXIT + +cat > "$temp_dir/source_contract_verifier.rb" <<'RUBY' +def strip_comments(source) + source.gsub(%r{//[^\n]*}, '').gsub(%r{/\*.*?\*/}m, '') +end + +def normalized(source) + strip_comments(source).gsub(/\s+/, ' ').gsub(/\s*([(),:])\s*/, '\1').strip +end + +class ContractFailure < StandardError; end + +def require_contract(condition, message) + raise ContractFailure, message unless condition +end + +def verify_form(source, name_binding, phone_binding) + text = normalized(source) + require_contract(text.scan("GiftContactTextField(text:#{name_binding},rule:.name)").length == 1, 'name field contract') + require_contract(text.scan("GiftContactTextField(text:#{phone_binding},rule:.phoneNumber)").length == 1, 'phone field contract') + require_contract(!text.match?(%r{\bTextField\([^)]*#{Regexp.escape(name_binding)}|\bTextField\([^)]*#{Regexp.escape(phone_binding)}}), 'direct contact TextField binding') + true +end + +def can_submit_body(source) + text = strip_comments(source) + declaration = text.match(/\bvar\s+canSubmit\s*:\s*Bool\s*\{/) + require_contract(declaration, 'canSubmit declaration') + marker = declaration.begin(0) + start = text.index('{', marker) + depth = 1 + index = start + 1 + while index < text.length && depth > 0 + depth += 1 if text[index] == '{' + depth -= 1 if text[index] == '}' + index += 1 + end + require_contract(depth == 0, 'canSubmit body is balanced') + text[start + 1...index - 1] +end + +def verify_model(source, name_property, phone_property) + body = normalized(can_submit_body(source)) + name_call = "GiftContactInputRule.name.acceptsSubmission(#{name_property})" + phone_call = "GiftContactInputRule.phoneNumber.acceptsSubmission(#{phone_property})" + expression = body.delete_prefix('return ').strip + require_contract(!expression.start_with?('_ ='), 'discarded submission validation') + require_contract(!expression.include?('return'), 'additional return statement') + operands = expression.split('&&').map(&:strip) + require_contract(operands.include?(name_call), 'name submission validation operand') + require_contract(operands.include?(phone_call), 'phone submission validation operand') + true +end + +if ARGV.any? + source = File.read(ARGV[0]) + if ARGV[1].start_with?('$') + verify_form(source, ARGV[1], ARGV[2]) + else + verify_model(source, ARGV[1], ARGV[2]) + end + exit +end + +valid_form = <<~SWIFT + struct Form { + // fake GiftContactTextField(text: $wrong, rule: .name) + var body: some View { + GiftContactTextField( + text: $name, + rule: .name + ) + GiftContactTextField(text: $phoneNumber, + rule: .phoneNumber) + } + } +SWIFT +verify_form(valid_form, '$name', '$phoneNumber') +puts 'PASS gift-contact-input self-probe: multiline form contract' + +invalid_form = <<~SWIFT + // GiftContactTextField(text: $name, rule: .name) + // GiftContactTextField(text: $phoneNumber, rule: .phoneNumber) + // TextField("", text: $name) + // TextField("", text: $phoneNumber) +SWIFT +rejected = false +begin + verify_form(invalid_form, '$name', '$phoneNumber') + abort 'FAIL gift-contact-input self-probe: commented/fake form unexpectedly passed' +rescue ContractFailure + rejected = true +end +abort 'FAIL gift-contact-input self-probe: commented/fake form unexpectedly passed' unless rejected +puts 'PASS gift-contact-input self-probe: commented/fake form rejected' + +valid_model = <<~SWIFT + var canSubmit + : Bool { + !isSubmitting && + GiftContactInputRule.name.acceptsSubmission(name) && + GiftContactInputRule.phoneNumber.acceptsSubmission(phoneNumber) && + termsAgreed + } +SWIFT +verify_model(valid_model, 'name', 'phoneNumber') +puts 'PASS gift-contact-input self-probe: canSubmit body contract' + +explicit_model = <<~SWIFT + var canSubmit : Bool { + return hasNotSubmitted && + GiftContactInputRule.name.acceptsSubmission(name) && + GiftContactInputRule.phoneNumber.acceptsSubmission(phoneNumber) && + privacyAgreed + } +SWIFT +verify_model(explicit_model, 'name', 'phoneNumber') +puts 'PASS gift-contact-input self-probe: explicit-return conjunction' + +invalid_model = <<~SWIFT + var canSubmit : Bool { + _ = GiftContactInputRule.name.acceptsSubmission(name) + _ = GiftContactInputRule.phoneNumber.acceptsSubmission(phoneNumber) + return true + } +SWIFT +rejected = false +begin + verify_model(invalid_model, 'name', 'phoneNumber') + abort 'FAIL gift-contact-input self-probe: fake canSubmit unexpectedly passed' +rescue ContractFailure + rejected = true +end +abort 'FAIL gift-contact-input self-probe: fake canSubmit unexpectedly passed' unless rejected +puts 'PASS gift-contact-input self-probe: fake canSubmit rejected' +RUBY +ruby "$temp_dir/source_contract_verifier.rb" + +rule="$root/SodaLive/Sources/V2/Gift/Components/GiftContactInputRule.swift" +if [[ -f "$rule" ]]; then + swiftc -parse-as-library "$rule" "$root/work/tests/GiftContactInputCheck.swift" -o "$temp_dir/gift-contact-input" +else + swiftc -parse-as-library "$root/work/tests/GiftContactInputCheck.swift" -o "$temp_dir/gift-contact-input" +fi +"$temp_dir/gift-contact-input" + +component="$root/SodaLive/Sources/V2/Gift/Components/GiftContactTextField.swift" +if [[ ! -f "$component" ]]; then + echo "RED gift-contact-input: missing GiftContactTextField.swift" >&2 + exit 1 +fi +for probe in 'markedTextRange' 'NSString' 'NSRange' '.numberPad'; do + grep -Fq "$probe" "$component" || { echo "RED gift-contact-input: missing component contract $probe" >&2; exit 1; } +done + +typography="$root/SodaLive/Sources/Extensions/FontModifier.swift" +ruby - "$component" "$typography" <<'RUBY' +def source(path) + File.read(path).gsub(%r{//[^\n]*|/\*.*?\*/}m, '').gsub(/\s+/, ' ').strip +end + +component = source(ARGV[0]) +typography = source(ARGV[1]) +abort 'RED gift-contact-input: hardcoded Font.preRegular bypasses localized font policy' if component.include?('Font.preRegular') +abort 'RED gift-contact-input: missing shared body3 localized font resolver' unless component.include?('SodaTypography.body3.localizedFontName()') +abort 'RED gift-contact-input: missing localized UIFont with system regular fallback' unless component.match?(/textField\.font\s*=\s*SodaTypography\.body3\.localizedFontName\(\)\s*\.flatMap\s*\{\s*UIFont\(name:\s*\$0,\s*size:\s*SodaTypography\.body3\.size\)\s*\}\s*\?\?\s*UIFont\.systemFont\(ofSize:\s*SodaTypography\.body3\.size,\s*weight:\s*\.regular\)/) +abort 'RED gift-contact-input: SodaTypography must delegate localized font resolution to existing policy' unless typography.match?(/func localizedFontName\(locale:\s*Locale\s*=\s*\.autoupdatingCurrent\)\s*->\s*String\?\s*\{\s*(?:return\s+)?fontName\(for:\s*\.localized,\s*weight:\s*weight,\s*locale:\s*locale\)\s*\}/) +puts 'PASS gift-contact-input: shared localized typography contract' +RUBY + +sender="$root/SodaLive/Sources/V2/Gift/Send/Components/GiftSenderFormView.swift" +recipient="$root/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressFormView.swift" +sender_model="$root/SodaLive/Sources/V2/Gift/Send/GiftSendViewModel.swift" +recipient_model="$root/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressViewModel.swift" +for file in "$sender" "$recipient" "$sender_model" "$recipient_model"; do + [[ -f "$file" ]] || { echo "RED gift-contact-input: missing integration file $(basename "$file")" >&2; exit 1; } +done +for file in "$sender" "$recipient"; do + if [[ "$file" == "$sender" ]]; then + ruby "$temp_dir/source_contract_verifier.rb" "$file" '$name' '$phoneNumber' + else + ruby "$temp_dir/source_contract_verifier.rb" "$file" '$viewModel.recipientName' '$viewModel.recipientPhoneNumber' + fi +done +for file in "$sender_model" "$recipient_model"; do + if [[ "$file" == "$sender_model" ]]; then + ruby "$temp_dir/source_contract_verifier.rb" "$file" senderName senderPhoneNumber + else + ruby "$temp_dir/source_contract_verifier.rb" "$file" recipientName recipientPhoneNumber + fi +done +echo "PASS gift-contact-input: source contracts"