test(gift): 연락처 입력 계약을 검증한다
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Executable
+198
@@ -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"
|
||||
Reference in New Issue
Block a user