diff --git a/Source/Formatter/TextFormatable.swift b/Source/Formatter/TextFormatable.swift index 0968983..ff78550 100644 --- a/Source/Formatter/TextFormatable.swift +++ b/Source/Formatter/TextFormatable.swift @@ -1,24 +1,11 @@ import Foundation public protocol TextFormatable { - var uniqueID: String { get } - func formatText(_ string: String) -> String + func format(_ value: String) -> String } public extension TextFormatable { - var uniqueID: String { - makeUniqueID() - } - - func makeUniqueID() -> String { - String(describing: type(of: self)) - } - func toFormatter() -> TextFormatter { return .init(self) } - - static func ==(lhs: Self, rhs: Self) -> Bool { - return lhs.uniqueID == rhs.uniqueID - } } diff --git a/Source/Formatter/TextFormatter.Custom.swift b/Source/Formatter/TextFormatter.Custom.swift index 6cb40f5..762d299 100644 --- a/Source/Formatter/TextFormatter.Custom.swift +++ b/Source/Formatter/TextFormatter.Custom.swift @@ -13,7 +13,7 @@ private struct CustomFormatter: TextFormatable { self.formatter = formatter } - func formatText(_ string: String) -> String { - return formatter(string) + func format(_ value: String) -> String { + return formatter(value) } } diff --git a/Source/Formatter/TextFormatter.Email.swift b/Source/Formatter/TextFormatter.Email.swift index 4de5e51..9a5aa45 100644 --- a/Source/Formatter/TextFormatter.Email.swift +++ b/Source/Formatter/TextFormatter.Email.swift @@ -12,9 +12,9 @@ private struct EmailTextFormatter: TextFormatable { + "1234567890" + "-_.+" - public func formatText(_ string: String) -> String { + public func format(_ value: String) -> String { var hasDomain = false - return string.filter { + return value.filter { if EmailTextFormatter.kAllowedCharacters.contains($0) { return true } else if $0 == "@" { diff --git a/Source/Formatter/TextFormatter.Identity.swift b/Source/Formatter/TextFormatter.Identity.swift index 9ace034..75f63e3 100644 --- a/Source/Formatter/TextFormatter.Identity.swift +++ b/Source/Formatter/TextFormatter.Identity.swift @@ -7,7 +7,7 @@ public extension TextFormatter { } private struct IdentityTextFormatter: TextFormatable { - public func formatText(_ string: String) -> String { - return string + public func format(_ value: String) -> String { + return value } } diff --git a/Source/Formatter/TextFormatter.LengthLimited.swift b/Source/Formatter/TextFormatter.LengthLimited.swift index 863a07a..dc68725 100644 --- a/Source/Formatter/TextFormatter.LengthLimited.swift +++ b/Source/Formatter/TextFormatter.LengthLimited.swift @@ -13,11 +13,7 @@ private struct MaxCharsFormatter: TextFormatable { self.maxChars = maxChars } - public func formatText(_ string: String) -> String { - return string[maxLength: maxChars] - } - - var uniqueID: String { - return makeUniqueID() + " \(maxChars)" + public func format(_ value: String) -> String { + return value[maxLength: maxChars] } } diff --git a/Source/Formatter/TextFormatter.NumbersOnly.swift b/Source/Formatter/TextFormatter.NumbersOnly.swift index 456f827..3abb1c9 100644 --- a/Source/Formatter/TextFormatter.NumbersOnly.swift +++ b/Source/Formatter/TextFormatter.NumbersOnly.swift @@ -7,9 +7,9 @@ public extension TextFormatter { } private struct NumbersOnlyFormatter: TextFormatable { - public func formatText(_ string: String) -> String { + public func format(_ value: String) -> String { let kAllowedCharacters = "0123456789" - let formattedText = string.filter { character in + let formattedText = value.filter { character in return kAllowedCharacters.contains(character) } return formattedText diff --git a/Source/Formatter/TextFormatter.StripLeadingSpaces.swift b/Source/Formatter/TextFormatter.StripLeadingSpaces.swift index 3134819..3c9b386 100644 --- a/Source/Formatter/TextFormatter.StripLeadingSpaces.swift +++ b/Source/Formatter/TextFormatter.StripLeadingSpaces.swift @@ -7,8 +7,8 @@ public extension TextFormatter { } private struct StripLeadingSpaces: TextFormatable { - public func formatText(_ string: String) -> String { - let formattedString = string.reduce("") { result, nextCharacter -> String in + public func format(_ value: String) -> String { + let formattedString = value.reduce("") { result, nextCharacter -> String in if nextCharacter == " ", result.isEmpty { return result } diff --git a/Source/Formatter/TextFormatter.StripSpaces.swift b/Source/Formatter/TextFormatter.StripSpaces.swift index 93d5761..c5a6de9 100644 --- a/Source/Formatter/TextFormatter.StripSpaces.swift +++ b/Source/Formatter/TextFormatter.StripSpaces.swift @@ -7,7 +7,7 @@ public extension TextFormatter { } private struct StripLeadingAndTrailingSpaces: TextFormatable { - func formatText(_ string: String) -> String { - return string.trimmingCharacters(in: .whitespacesAndNewlines) + func format(_ value: String) -> String { + return value.trimmingCharacters(in: .whitespacesAndNewlines) } } diff --git a/Source/Formatter/TextFormatter.swift b/Source/Formatter/TextFormatter.swift index be895ab..7cf074b 100644 --- a/Source/Formatter/TextFormatter.swift +++ b/Source/Formatter/TextFormatter.swift @@ -1,6 +1,6 @@ import Foundation -public final class TextFormatter: Equatable, ExpressibleByArrayLiteral { +public final class TextFormatter: ExpressibleByArrayLiteral { private let formatters: [TextFormatable] public required init(_ formatables: [TextFormatable]) { @@ -21,10 +21,10 @@ public final class TextFormatter: Equatable, ExpressibleByArrayLiteral { self.init(formatables) } - public func formatText(_ text: String) -> String { - var formattedText = text + public func format(_ value: String) -> String { + var formattedText = value for formatter in formatters { - formattedText = formatter.formatText(formattedText) + formattedText = formatter.format(formattedText) } return formattedText } @@ -43,10 +43,4 @@ public final class TextFormatter: Equatable, ExpressibleByArrayLiteral { let combinedValidations = lhs.formatters + rhs.formatters return .init(combinedValidations) } - - public static func ==(lhs: TextFormatter, rhs: TextFormatter) -> Bool { - let lhsIDs = Set(lhs.formatters.map(\.uniqueID)) - let rhsIDs = Set(rhs.formatters.map(\.uniqueID)) - return lhsIDs == rhsIDs - } } diff --git a/Source/RangeExt.swift b/Source/RangeExt.swift new file mode 100644 index 0000000..cbefd57 --- /dev/null +++ b/Source/RangeExt.swift @@ -0,0 +1,24 @@ +import Foundation + +public extension Array { + func combineRanges() -> [Range] where Element == Range { + var result: [Range] = [] + var lastRange: Range? + for range in self { + if let _lastRange = lastRange { + if range.lowerBound <= _lastRange.upperBound { + lastRange = _lastRange.lowerBound.. Void public var textDidChanged: (_ newValue: String) -> Void - public var errorDidChanged: (_ state: TextValidationResult) -> Void + public var errorDidChanged: (_ state: [TextValidationResult]) -> Void public var didEndEditing: () -> Void /// Return `true` if you want to resign first responder @@ -25,7 +25,7 @@ public extension SmartTextField { didBeginEditing: @escaping () -> Void = {}, dateDidChanged: @escaping (Date) -> Void = { _ in }, textDidChanged: @escaping (String) -> Void = { _ in }, - errorDidChanged: @escaping (TextValidationResult) -> Void = { _ in }, + errorDidChanged: @escaping ([TextValidationResult]) -> Void = { _ in }, didEndEditing: @escaping () -> Void = {}, didTapReturnButton: @escaping () -> Bool = { true }, didTapToolbarDoneButton: @escaping () -> Void = {}, @@ -47,7 +47,7 @@ public extension SmartTextField { didBeginEditing: @escaping () -> Void = {}, dateDidChanged: @escaping (Date) -> Void = { _ in }, textDidChanged: @escaping (String) -> Void = { _ in }, - errorDidChanged: @escaping (TextValidationResult) -> Void = { _ in }, + errorDidChanged: @escaping ([TextValidationResult]) -> Void = { _ in }, didEndEditing: @escaping () -> Void = {}, didTapReturnButton: @escaping () -> Bool = { true }, clearButton: @escaping () -> Void = {}) { @@ -65,7 +65,7 @@ public extension SmartTextField { public required init(shouldBeginEditing: @escaping () -> Bool = { true }, didBeginEditing: @escaping () -> Void = {}, textDidChanged: @escaping (String) -> Void = { _ in }, - errorDidChanged: @escaping (TextValidationResult) -> Void = { _ in }, + errorDidChanged: @escaping ([TextValidationResult]) -> Void = { _ in }, didEndEditing: @escaping () -> Void = {}, didTapReturnButton: @escaping () -> Bool = { true }, clearButton: @escaping () -> Void = {}) { @@ -80,12 +80,4 @@ public extension SmartTextField { #endif } } - -// MARK: - SmartTextField.Eventier + Equatable - -extension SmartTextField.Eventier: Equatable { - public static func ==(lhs: SmartTextField.Eventier, rhs: SmartTextField.Eventier) -> Bool { - return lhs === rhs - } -} #endif diff --git a/Source/SmartTextField.State.swift b/Source/SmartTextField.State.swift index 3478e3e..9ea7391 100644 --- a/Source/SmartTextField.State.swift +++ b/Source/SmartTextField.State.swift @@ -9,7 +9,7 @@ public extension SmartTextField { } #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) - struct DatePicker: Equatable { + struct DatePicker { public let calendar: Calendar public let dateFormatter: Foundation.DateFormatter public let minDate: Date @@ -36,7 +36,7 @@ public extension SmartTextField { } #endif - struct Configuration: Equatable { + struct Configuration { public enum Placeholder: Equatable, ExpressibleByStringLiteral { case text(String) case attributed(NSAttributedString) diff --git a/Source/SmartTextField.swift b/Source/SmartTextField.swift index ff8a51c..7cdaead 100644 --- a/Source/SmartTextField.swift +++ b/Source/SmartTextField.swift @@ -38,8 +38,8 @@ public final class SmartTextField: UIView { } } - public var silentErrorState: TextValidationResult { - return textValidator.isTextValid(real.text ?? "") + public var validationState: [TextValidationResult] { + return textValidator.validate(real.text ?? "") } #if os(iOS) || targetEnvironment(macCatalyst) || os(visionOS) @@ -88,13 +88,13 @@ public final class SmartTextField: UIView { textFormatter = viewState.textFormatter textValidator = viewState.textValidator - real.text = textFormatter.formatText(real.text ?? "") + real.text = textFormatter.format(real.text ?? "") } public func setText(_ text: String?) { let oldText = real.text - let newText = text.map(textFormatter.formatText) ?? "" + let newText = text.map(textFormatter.format) ?? "" real.text = newText if oldText != newText { @@ -223,7 +223,7 @@ public final class SmartTextField: UIView { private func checkError() { if !isFirstResponder { - let state = textValidator.isTextValid(real.text ?? "") + let state = textValidator.validate(real.text ?? "") eventier.errorDidChanged(state) } } @@ -247,7 +247,7 @@ extension SmartTextField: UITextFieldDelegate { let original = textField.text ?? "" let updated = (original as NSString).replacingCharacters(in: range, with: string) as String - let formatted = textFormatter.formatText(updated) + let formatted = textFormatter.format(updated) textField.text = formatted var offset = 0 diff --git a/Source/StringExt.swift b/Source/StringExt.swift index a9bfcc5..4dfeb89 100644 --- a/Source/StringExt.swift +++ b/Source/StringExt.swift @@ -14,4 +14,28 @@ public extension String { let end = index(startIndex, offsetBy: min(from + maxLength, count)) return String(self[start.. [Range] { + var ranges: [Range] = [] + while let range = range(of: substring, + options: options, + range: (ranges.last?.upperBound ?? startIndex).. [Range] { + var ranges: [Range] = [] + while let range = rangeOfCharacter(from: aSet, + options: options, + range: (ranges.last?.upperBound ?? startIndex).. Bool + func validate(_ value: String) -> TextValidationResult } public extension TextValidatable { - var uniqueID: String { - return makeUniqueID() - } - - func makeUniqueID() -> String { - return String(describing: type(of: self)) + (errorText.map { " errorText: \($0)" } ?? "") - } - func toValidator() -> TextValidator { return .init(self) } - - static func ==(lhs: Self, rhs: Self) -> Bool { - return lhs.uniqueID == rhs.uniqueID - } } diff --git a/Source/Validator/TextValidationResult.swift b/Source/Validator/TextValidationResult.swift index 3681dc7..048620e 100644 --- a/Source/Validator/TextValidationResult.swift +++ b/Source/Validator/TextValidationResult.swift @@ -1,27 +1,29 @@ import Foundation -public enum TextValidationResult: Equatable { - case valid - case invalid - case invalidWithErrorText(String) +public struct TextValidationResult: Equatable { + public let invalidRanges: [Range] + public let errorText: String? + public let isValid: Bool - public var isValid: Bool { - switch self { - case .invalid, - .invalidWithErrorText: - return false - case .valid: - return true - } + public static let valid: Self = .init(invalidRanges: [], errorText: nil, isValid: true) + public static func invalid(withErrorText: String? = nil) -> Self { + return .init(invalidRanges: [], errorText: withErrorText, isValid: false) } - public var errorText: String? { - switch self { - case .invalidWithErrorText(let text): - return text - case .invalid, - .valid: - return nil - } + public init(invalidRanges: [Range] = [], + errorText: String? = nil, + isValid: Bool) { + self.invalidRanges = invalidRanges + self.errorText = errorText + self.isValid = isValid } } + +public extension TextValidationResult { + static let invalid: Self = .init(isValid: false) +} + +public extension [TextValidationResult] { + static let invalid: Self = [.invalid] + static let valid: Self = [] +} diff --git a/Source/Validator/TextValidator.Custom.swift b/Source/Validator/TextValidator.Custom.swift index 0f453e1..1bdc4ee 100644 --- a/Source/Validator/TextValidator.Custom.swift +++ b/Source/Validator/TextValidator.Custom.swift @@ -1,17 +1,19 @@ import Foundation public extension TextValidator { + typealias CustomValidatorClosure = (_ value: String, _ errorText: String?) -> TextValidationResult + static func custom(errorText: String? = nil, - _ validator: @escaping (String) -> Bool) -> TextValidator { - return CustomValidator(validator: validator, errorText: errorText).toValidator() + _ validator: @escaping CustomValidatorClosure) -> TextValidator { + return CustomValidator(errorText: errorText, validator: validator).toValidator() } } private struct CustomValidator: TextValidatable { - let validator: (String) -> Bool let errorText: String? + let validator: TextValidator.CustomValidatorClosure - func isValid(string: String) -> Bool { - return validator(string) + func validate(_ value: String) -> TextValidationResult { + return validator(value, errorText) } } diff --git a/Source/Validator/TextValidator.Email.swift b/Source/Validator/TextValidator.Email.swift index d674498..9715097 100644 --- a/Source/Validator/TextValidator.Email.swift +++ b/Source/Validator/TextValidator.Email.swift @@ -9,21 +9,63 @@ public extension TextValidator { private struct EmailValidation: TextValidatable { let errorText: String? - init(errorText: String?) { - self.errorText = errorText - } - private static let emailPredicate: NSPredicate = { let emailStartValidCharacters = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+" let emailLastGroupValidCharacters = "(?:\\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*" let emailDomainStartValidCharacter = "[A-Za-z0-9]" let emailDomainEndingValidCharacters = "([A-Za-z0-9-]*[A-Za-z0-9])*" let emailDomainExtensionValidCharacters = "(\\.[A-Za-z]{2,}){1,}" - let emailRegEx = emailStartValidCharacters + emailLastGroupValidCharacters + "@" + emailDomainStartValidCharacter + emailDomainEndingValidCharacters + emailDomainExtensionValidCharacters + let emailRegEx = [ + emailStartValidCharacters, + emailLastGroupValidCharacters, + "@", + emailDomainStartValidCharacter, + emailDomainEndingValidCharacters, + emailDomainExtensionValidCharacters + ].joined() return NSPredicate(format: "SELF MATCHES %@", emailRegEx) }() - func isValid(string: String) -> Bool { - return EmailValidation.emailPredicate.evaluate(with: string) + private static let aSet = CharacterSet(charactersIn: "@._-") + .union(.lowercaseLetters) + .union(.uppercaseLetters) + .union(.decimalDigits) + .inverted + .union(.init(charactersIn: "+!#$%&'*+/=?^_`{|}~\",\\<>;:[]()")) + + func validate(_ value: String) -> TextValidationResult { + if Self.emailPredicate.evaluate(with: value) { + return .valid + } + + let common = value.ranges(of: Self.aSet) + let at = value.ranges(of: .init(charactersIn: "@")).dropFirst() + let points = value.ranges(of: ".") + .combineRanges() + .filter { range in + return value[range].count > 1 + } + let weirdPairs = [ + "-@", "@-", + "@.", ".@", + ".-", "-.", + "@@" + ].map { + value.ranges(of: $0) + } + .flatMap { $0 } + + let combinations = common + at + points + weirdPairs + let result = combinations + .sorted { a, b in + if a.lowerBound == b.lowerBound { + return a.upperBound < b.upperBound + } + return a.lowerBound < b.lowerBound + } + .combineRanges() + return .init(invalidRanges: result, + errorText: errorText, + isValid: false) } } diff --git a/Source/Validator/TextValidator.Identity.swift b/Source/Validator/TextValidator.Identity.swift index f405e3b..9bbff9d 100644 --- a/Source/Validator/TextValidator.Identity.swift +++ b/Source/Validator/TextValidator.Identity.swift @@ -7,9 +7,7 @@ public extension TextValidator { } private struct IdentityTextValidator: TextValidatable { - let errorText: String? = nil - - func isValid(string _: String) -> Bool { - return true + func validate(_: String) -> TextValidationResult { + return .valid } } diff --git a/Source/Validator/TextValidator.IncludesLowerAndUppercase.swift b/Source/Validator/TextValidator.IncludesLowerAndUppercase.swift deleted file mode 100644 index 1d238fa..0000000 --- a/Source/Validator/TextValidator.IncludesLowerAndUppercase.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -public extension TextValidator { - static func includesLowerAndUppercase(errorText: String? = nil) -> TextValidator { - return IncludesUpperAndLowercaseCharactersValidator(errorText: errorText).toValidator() - } -} - -private struct IncludesUpperAndLowercaseCharactersValidator: TextValidatable { - let errorText: String? - - init(errorText: String?) { - self.errorText = errorText - } - - func isValid(string: String) -> Bool { - let containsAnUppercase = string.rangeOfCharacter(from: .uppercaseLetters) != nil - let containsALowercase = string.rangeOfCharacter(from: .lowercaseLetters) != nil - return containsAnUppercase && containsALowercase - } -} diff --git a/Source/Validator/TextValidator.IncludesLowercase.swift b/Source/Validator/TextValidator.IncludesLowercase.swift new file mode 100644 index 0000000..02a9165 --- /dev/null +++ b/Source/Validator/TextValidator.IncludesLowercase.swift @@ -0,0 +1,19 @@ +import Foundation + +public extension TextValidator { + static func includesLowercase(errorText: String? = nil) -> TextValidator { + return IncludesLowercaseCharactersValidator(errorText: errorText).toValidator() + } +} + +private struct IncludesLowercaseCharactersValidator: TextValidatable { + let errorText: String? + + func validate(_ value: String) -> TextValidationResult { + let containsALowercase = value.rangeOfCharacter(from: .lowercaseLetters) != nil + if containsALowercase { + return .valid + } + return .invalid(withErrorText: errorText) + } +} diff --git a/Source/Validator/TextValidator.IncludesUppercase.swift b/Source/Validator/TextValidator.IncludesUppercase.swift new file mode 100644 index 0000000..4bc2e82 --- /dev/null +++ b/Source/Validator/TextValidator.IncludesUppercase.swift @@ -0,0 +1,19 @@ +import Foundation + +public extension TextValidator { + static func includesUppercase(errorText: String? = nil) -> TextValidator { + return IncludesUppercaseCharactersValidator(errorText: errorText).toValidator() + } +} + +private struct IncludesUppercaseCharactersValidator: TextValidatable { + let errorText: String? + + func validate(_ value: String) -> TextValidationResult { + let containsAnUppercase = value.rangeOfCharacter(from: .uppercaseLetters) != nil + if containsAnUppercase { + return .valid + } + return .invalid(withErrorText: errorText) + } +} diff --git a/Source/Validator/TextValidator.LengthLimited.swift b/Source/Validator/TextValidator.LengthLimited.swift index 08513b6..d472f5c 100644 --- a/Source/Validator/TextValidator.LengthLimited.swift +++ b/Source/Validator/TextValidator.LengthLimited.swift @@ -7,21 +7,23 @@ public extension TextValidator { } private struct LengthLimitedValidation: TextValidatable { + let limitCharacters: Int + let canBeEmpty: Bool let errorText: String? - private let limitCharacters: Int - private let canBeEmpty: Bool - init(limitCharacters: Int, canBeEmpty: Bool, errorText: String?) { - self.limitCharacters = limitCharacters - self.canBeEmpty = canBeEmpty - self.errorText = errorText - } - - func isValid(string: String) -> Bool { - return string.count == limitCharacters || (canBeEmpty && string.isEmpty) - } + func validate(_ value: String) -> TextValidationResult { + if value.count == limitCharacters || (canBeEmpty && value.isEmpty) { + return .valid + } - var uniqueID: String { - return makeUniqueID() + " \(limitCharacters)" + let ranges: [Range] + if limitCharacters < value.count { + ranges = [value.index(value.startIndex, offsetBy: limitCharacters).. Bool { - return string.count <= maxCharacters - } + func validate(_ value: String) -> TextValidationResult { + if value.count <= maxCharacters { + return .valid + } - var uniqueID: String { - return makeUniqueID() + " \(maxCharacters)" + return .init(invalidRanges: [value.index(value.startIndex, offsetBy: maxCharacters).. Bool { - return string.count >= minCharacters - } - - var uniqueID: String { - return makeUniqueID() + " \(minCharacters)" + func validate(_ value: String) -> TextValidationResult { + if value.count >= minCharacters { + return .valid + } + return .invalid(withErrorText: errorText) } } diff --git a/Source/Validator/TextValidator.NotEmpty.swift b/Source/Validator/TextValidator.NotEmpty.swift index b1f9d7b..422d4f2 100644 --- a/Source/Validator/TextValidator.NotEmpty.swift +++ b/Source/Validator/TextValidator.NotEmpty.swift @@ -9,11 +9,11 @@ public extension TextValidator { private struct NotEmptyValidator: TextValidatable { let errorText: String? - init(errorText: String?) { - self.errorText = errorText - } + func validate(_ value: String) -> TextValidationResult { + if value.isEmpty == false { + return .valid + } - func isValid(string: String) -> Bool { - return string.isEmpty == false + return .invalid(withErrorText: errorText) } } diff --git a/Source/Validator/TextValidator.NumbersOnly.swift b/Source/Validator/TextValidator.NumbersOnly.swift index cedd7b5..46267f2 100644 --- a/Source/Validator/TextValidator.NumbersOnly.swift +++ b/Source/Validator/TextValidator.NumbersOnly.swift @@ -9,12 +9,14 @@ public extension TextValidator { private struct NumbersOnlyValidator: TextValidatable { let errorText: String? - init(errorText: String?) { - self.errorText = errorText - } - - func isValid(string: String) -> Bool { + func validate(_ value: String) -> TextValidationResult { let numberCharacters = CharacterSet.decimalDigits.inverted - return string.rangeOfCharacter(from: numberCharacters) == nil + let ranges = value.ranges(of: numberCharacters) + if ranges.isEmpty { + return .valid + } + return .init(invalidRanges: ranges, + errorText: errorText, + isValid: false) } } diff --git a/Source/Validator/TextValidator.swift b/Source/Validator/TextValidator.swift index 8bccc96..253aa3c 100644 --- a/Source/Validator/TextValidator.swift +++ b/Source/Validator/TextValidator.swift @@ -1,6 +1,6 @@ import Foundation -public final class TextValidator: Equatable, ExpressibleByArrayLiteral { +public final class TextValidator: ExpressibleByArrayLiteral { public typealias Eventier = (TextValidator, TextValidationResult) -> Void private let validators: [TextValidatable] @@ -23,16 +23,11 @@ public final class TextValidator: Equatable, ExpressibleByArrayLiteral { self.init(validatables) } - public func isTextValid(_ text: String) -> TextValidationResult { - let result: TextValidationResult = validators - .first(where: { - !$0.isValid(string: text) - }) - .map { - $0.errorText.map { .invalidWithErrorText($0) } ?? .invalid - } ?? .valid - - return result + public func validate(_ value: String) -> [TextValidationResult] { + return validators.compactMap { + let result = $0.validate(value) + return result.isValid ? nil : result + } } public static func +(lhs: TextValidator, rhs: TextValidatable) -> TextValidator { @@ -49,10 +44,4 @@ public final class TextValidator: Equatable, ExpressibleByArrayLiteral { let validators = lhs.validators + rhs.validators return .init(validators) } - - public static func ==(lhs: TextValidator, rhs: TextValidator) -> Bool { - let lhsIDs = Set(lhs.validators.map(\.uniqueID)) - let rhsIDs = Set(rhs.validators.map(\.uniqueID)) - return lhsIDs == rhsIDs - } } diff --git a/Tests/Emails.swift b/Tests/Emails.swift new file mode 100644 index 0000000..0ba744e --- /dev/null +++ b/Tests/Emails.swift @@ -0,0 +1,53 @@ +import Foundation + +enum Emails { + static let validEmails: [String] = [ + "j@b.com", + "email@example.com", + "firstname.lastname@example.com", + "1tom1@blabla.com", + "email@subdomain.example.com", + "firstname+lastname@example.com", + "tomCat@blabla1.com", + "1234567890@example.com", + "email@example-one.com", + "_______@example.com", + "email@example.co.jp", + "firstname-lastname@example.com", + "mel@samel.rocks", + "dont_buy@adainthatendsin.io", + "email@example.biz", + "email@example.name", + "pata@pata.tv", + "email@example.museum", + "tomCat@1blabla.com" + ] + + static let invalidEmails: [String] = [ + "plainaddress", + "#@%^%#$@#$@#.com", + "@example.com", + "Joe Smith ", + "email.example.com", + "email@example@example.com", + ".email@example.com", + "email.@example.com", + "email..email@example.com", + "あいうえお@example.com", + "email@example.com (Joe Smith)", + "email@example", + "email@-example.com", + "email@111.222.333.44444", + "Abc.b...123@example.com", + "email@example..com", + "email@example-.com", + "email@.com", + "email.@.", + "email.@", + "email@", + "tomCat@@1blabla.com", + "!tomCat@@1blabla.com", + "+tomCat@@1blabla.com", + "t!+omCat@@1blabla.com" + ] +} diff --git a/Tests/TextFormatterTests.swift b/Tests/Formatter/TextFormatterTests.swift similarity index 55% rename from Tests/TextFormatterTests.swift rename to Tests/Formatter/TextFormatterTests.swift index ec1fb2c..c7314d8 100644 --- a/Tests/TextFormatterTests.swift +++ b/Tests/Formatter/TextFormatterTests.swift @@ -14,36 +14,18 @@ final class TextFormatterTests: XCTestCase { self.uniqueID = uniqueID } - func formatText(_ string: String) -> String { - return uniqueID == string ? "uniqueID_" + string : string + func format(_ value: String) -> String { + return uniqueID == value ? "uniqueID_" + value : value } } private func applyTest(subject: TextFormatter, _ formatables: [TextFormatable], file: StaticString = #filePath, line: UInt = #line) { - XCTAssertEqual(subject, TextFormatter(formatables), file: file, line: line) - XCTAssertEqual(subject, TextFormatter(formatables.reversed()), file: file, line: line) - - XCTAssertNotEqual(subject, .numbersOnly, file: file, line: line) - XCTAssertNotEqual(subject, .numbersOnly + .identity, file: file, line: line) - XCTAssertNotEqual(subject, .stripLeadingSpaces, file: file, line: line) - XCTAssertNotEqual(subject, .stripLeadingSpaces + .email, file: file, line: line) - - XCTAssertEqual(subject.formatText("1"), "uniqueID_1", file: file, line: line) - XCTAssertEqual(subject.formatText("2"), formatables.count >= 2 ? "uniqueID_2" : "2", file: file, line: line) - XCTAssertEqual(subject.formatText("12"), "12", file: file, line: line) + XCTAssertEqual(subject.format("1"), "uniqueID_1", file: file, line: line) + XCTAssertEqual(subject.format("2"), formatables.count >= 2 ? "uniqueID_2" : "2", file: file, line: line) + XCTAssertEqual(subject.format("12"), "12", file: file, line: line) } func test_spec() { - let subject: TextFormatter = .stripLeadingSpaces + .numbersOnly - - XCTAssertEqual(subject, .stripLeadingSpaces + .numbersOnly) - XCTAssertEqual(subject, .numbersOnly + .stripLeadingSpaces) - - XCTAssertNotEqual(subject, .numbersOnly) - XCTAssertNotEqual(subject, .numbersOnly + .identity) - XCTAssertNotEqual(subject, .stripLeadingSpaces) - XCTAssertNotEqual(subject, .stripLeadingSpaces + .email) - let mock1: TextFormatable = TestFormatter(uniqueID: "1") let mock2: TextFormatable = TestFormatter(uniqueID: "2") diff --git a/Tests/Formatter/TextFormatter_CustomTests.swift b/Tests/Formatter/TextFormatter_CustomTests.swift new file mode 100644 index 0000000..08c0441 --- /dev/null +++ b/Tests/Formatter/TextFormatter_CustomTests.swift @@ -0,0 +1,15 @@ +import SmartText +import SpryKit +import XCTest + +final class TextFormatter_CustomTests: XCTestCase { + func test_spec() { + let subject: TextFormatter = .custom { + return $0 == "0" ? $0 + "_end" : "start_" + $0 + } + + XCTAssertEqual(subject.format("0"), "0_end") + XCTAssertEqual(subject.format("1"), "start_1") + XCTAssertEqual(subject.format("1asd"), "start_1asd") + } +} diff --git a/Tests/Formatter/TextFormatter_EmailTests.swift b/Tests/Formatter/TextFormatter_EmailTests.swift new file mode 100644 index 0000000..8c3c6fe --- /dev/null +++ b/Tests/Formatter/TextFormatter_EmailTests.swift @@ -0,0 +1,28 @@ +import SmartText +import SpryKit +import XCTest + +final class TextFormatter_EmailTests: XCTestCase { + func test_emails() { + let subject: TextFormatter = .email + + var expected: [String] = Emails.validEmails.map { + return subject.format($0) + } + XCTAssertEqual(Emails.validEmails, expected) + + expected = Emails.validEmails.map { + let modifiedEmail = " \n " + $0.replacingOccurrences(of: "@", with: "!*@@()") + " \n " + return subject.format(modifiedEmail) + } + XCTAssertEqual(Emails.validEmails, expected) + + expected = Emails.validEmails.map { + let modifiedEmail = $0.uppercased() + return subject.format(modifiedEmail) + } + + let actual = Emails.validEmails.map { $0.uppercased() } + XCTAssertEqual(actual, expected) + } +} diff --git a/Tests/TextFormatter_IdentityTests.swift b/Tests/Formatter/TextFormatter_IdentityTests.swift similarity index 57% rename from Tests/TextFormatter_IdentityTests.swift rename to Tests/Formatter/TextFormatter_IdentityTests.swift index a58eeb8..33be9a4 100644 --- a/Tests/TextFormatter_IdentityTests.swift +++ b/Tests/Formatter/TextFormatter_IdentityTests.swift @@ -7,13 +7,10 @@ final class TextFormatter_IdentityTests: XCTestCase { func test_spec() { let subject: TextFormatter = .identity - XCTAssertEqual(subject, .identity) - XCTAssertNotEqual(subject, .email) - let text = " a 1 b 2 c 3 ! " - XCTAssertEqual(subject.formatText(text), text) + XCTAssertEqual(subject.format(text), text) let text2 = UUID().uuidString - XCTAssertEqual(subject.formatText(text2), text2) + XCTAssertEqual(subject.format(text2), text2) } } diff --git a/Tests/Formatter/TextFormatter_LengthLimitedTests.swift b/Tests/Formatter/TextFormatter_LengthLimitedTests.swift new file mode 100644 index 0000000..c106b64 --- /dev/null +++ b/Tests/Formatter/TextFormatter_LengthLimitedTests.swift @@ -0,0 +1,18 @@ +import SmartText +import SpryKit +import XCTest + +final class TextFormatter_LengthLimitedTests: XCTestCase { + func test_spec() { + let subject: TextFormatter = .lengthLimited(4) + + XCTAssertEqual(subject.format(""), "") + XCTAssertEqual(subject.format("1"), "1") + XCTAssertEqual(subject.format("12"), "12") + XCTAssertEqual(subject.format("123"), "123") + XCTAssertEqual(subject.format("1234"), "1234") + XCTAssertEqual(subject.format("12345"), "1234") + XCTAssertEqual(subject.format("123456"), "1234") + XCTAssertEqual(subject.format("1234567"), "1234") + } +} diff --git a/Tests/Formatter/TextFormatter_NumbersOnlyTests.swift b/Tests/Formatter/TextFormatter_NumbersOnlyTests.swift new file mode 100644 index 0000000..9ab085d --- /dev/null +++ b/Tests/Formatter/TextFormatter_NumbersOnlyTests.swift @@ -0,0 +1,13 @@ +import SmartText +import SpryKit +import XCTest + +final class TextFormatter_NumbersOnlyTests: XCTestCase { + func test_spec() { + let subject: TextFormatter = .numbersOnly + + XCTAssertEqual(subject.format("123ABV34"), "12334") + XCTAssertEqual(subject.format("vc123ABV34"), "12334") + XCTAssertEqual(subject.format("vc123ABV34\\2"), "123342") + } +} diff --git a/Tests/Formatter/TextFormatter_StripLeadingSpacesTests.swift b/Tests/Formatter/TextFormatter_StripLeadingSpacesTests.swift new file mode 100644 index 0000000..a17f337 --- /dev/null +++ b/Tests/Formatter/TextFormatter_StripLeadingSpacesTests.swift @@ -0,0 +1,13 @@ +import SmartText +import SpryKit +import XCTest + +final class TextFormatter_StripLeadingSpacesTests: XCTestCase { + func test_spec() { + let subject: TextFormatter = .stripLeadingSpaces + + XCTAssertEqual(subject.format(" absd 456 "), "absd 456 ") + XCTAssertEqual(subject.format(" \n absd 456 "), "\n absd 456 ") + XCTAssertEqual(subject.format("absd 456 "), "absd 456 ") + } +} diff --git a/Tests/Formatter/TextFormatter_StripSpacesTests.swift b/Tests/Formatter/TextFormatter_StripSpacesTests.swift new file mode 100644 index 0000000..b8a955a --- /dev/null +++ b/Tests/Formatter/TextFormatter_StripSpacesTests.swift @@ -0,0 +1,13 @@ +import SmartText +import SpryKit +import XCTest + +final class TextFormatter_StripSpacesTests: XCTestCase { + func test_spec() { + let subject: TextFormatter = .stripLeadingAndTrailingSpaces + + XCTAssertEqual(subject.format(" absd 456 "), "absd 456") + XCTAssertEqual(subject.format(" absd 456"), "absd 456") + XCTAssertEqual(subject.format("absd 456 "), "absd 456") + } +} diff --git a/Tests/String+RangesTests.swift b/Tests/String+RangesTests.swift new file mode 100644 index 0000000..77abf42 --- /dev/null +++ b/Tests/String+RangesTests.swift @@ -0,0 +1,80 @@ +import SmartText +import XCTest + +final class String_RangesTests: XCTestCase { + func test_CharacterSet() { + run_range_test(of: .decimalDigits, in: "123abs456", sub: ["123", "456"]) + run_range_test(of: .decimalDigits, in: "abc", sub: []) + run_range_test(of: .decimalDigits, in: "123", sub: ["123"]) + run_range_test(of: .decimalDigits, in: "", sub: []) + run_range_test(of: .decimalDigits, in: "123456", sub: ["123456"]) + run_range_test(of: .decimalDigits, in: "abc123456", sub: ["123456"]) + run_range_test(of: .decimalDigits, in: "123456abc", sub: ["123456"]) + run_range_test(of: .decimalDigits, in: "abc123456abc", sub: ["123456"]) + run_range_test(of: .decimalDigits, in: "abc123456abc123456", sub: ["123456", "123456"]) + + run_range_test(of: .decimalDigits.inverted, in: "123abs456", sub: ["abs"]) + run_range_test(of: .decimalDigits.inverted, in: "abc", sub: ["abc"]) + run_range_test(of: .decimalDigits.inverted, in: "123", sub: []) + run_range_test(of: .decimalDigits.inverted, in: "", sub: []) + run_range_test(of: .decimalDigits.inverted, in: "123456", sub: []) + run_range_test(of: .decimalDigits.inverted, in: "abc123456", sub: ["abc"]) + run_range_test(of: .decimalDigits.inverted, in: "123456abc", sub: ["abc"]) + run_range_test(of: .decimalDigits.inverted, in: "abc123456abc", sub: ["abc", "abc"]) + run_range_test(of: .decimalDigits.inverted, in: "abc123456abc123456", sub: ["abc", "abc"]) + } + + func test_String() { + run_range_test(of: "123", in: "123abs456", sub: ["123"]) + run_range_test(of: "123", in: "abc", sub: []) + run_range_test(of: "123", in: "123", sub: ["123"]) + run_range_test(of: "123", in: "", sub: []) + run_range_test(of: "123", in: "123456", sub: ["123"]) + run_range_test(of: "123", in: "abc123456", sub: ["123"]) + run_range_test(of: "123", in: "123456abc", sub: ["123"]) + run_range_test(of: "123", in: "abc123456abc", sub: ["123"]) + run_range_test(of: "123", in: "abc123456abc123456", sub: ["123", "123"]) + run_range_test(of: "123", in: "abc123123123123456abc123456", sub: ["123", "123", "123", "123", "123"]) + } + + func test_combineRanges() { + run_range_test(of: [Range](), sub: []) + run_range_test(of: [1..<3], sub: [1..<3]) + run_range_test(of: [1..<3, 2..<4], sub: [1..<4]) + run_range_test(of: [1..<3, 3..<5], sub: [1..<5]) + run_range_test(of: [1..<3, 4..<5], sub: [1..<3, 4..<5]) + run_range_test(of: [1..<3, 2..<4, 5..<7], sub: [1..<4, 5..<7]) + run_range_test(of: [1..<3, 2..<4, 5..<7, 6..<8], sub: [1..<4, 5..<8]) + run_range_test(of: [1..<3, 2..<4, 5..<7, 6..<8, 10..<12], sub: [1..<4, 5..<8, 10..<12]) + } +} + +private extension XCTestCase { + func run_range_test(of aSet: CharacterSet, + in str: String, + sub: [String], + file: StaticString = #filePath, + line: UInt = #line) { + let resultArr = str.ranges(of: aSet) + XCTAssertEqual(resultArr.count, sub.count, file: file, line: line) + XCTAssertEqual(resultArr.map { String(str[$0]) }, sub, file: file, line: line) + } + + func run_range_test(of aStr: String, + in str: String, + sub: [String], + file: StaticString = #filePath, + line: UInt = #line) { + let resultArr = str.ranges(of: aStr) + XCTAssertEqual(resultArr.count, sub.count, file: file, line: line) + XCTAssertEqual(resultArr.map { String(str[$0]) }, sub, file: file, line: line) + } + + func run_range_test(of ranges: [Range], + sub: [Range], + file: StaticString = #filePath, + line: UInt = #line) { + let result = ranges.combineRanges() + XCTAssertEqual(result, sub) + } +} diff --git a/Tests/TextFormatter_CustomTests.swift b/Tests/TextFormatter_CustomTests.swift deleted file mode 100644 index 2a22ad2..0000000 --- a/Tests/TextFormatter_CustomTests.swift +++ /dev/null @@ -1,18 +0,0 @@ -import SmartText -import SpryKit -import XCTest - -final class TextFormatter_CustomTests: XCTestCase { - func test_spec() { - let subject: TextFormatter = .custom { - return $0 == "0" ? $0 + "_end" : "start_" + $0 - } - - XCTAssertEqual(subject, .custom { $0 }) - XCTAssertNotEqual(subject, .identity) - - XCTAssertEqual(subject.formatText("0"), "0_end") - XCTAssertEqual(subject.formatText("1"), "start_1") - XCTAssertEqual(subject.formatText("1asd"), "start_1asd") - } -} diff --git a/Tests/TextFormatter_EmailTests.swift b/Tests/TextFormatter_EmailTests.swift deleted file mode 100644 index c547794..0000000 --- a/Tests/TextFormatter_EmailTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -import SmartText -import SpryKit -import XCTest - -final class TextFormatter_EmailTests: XCTestCase { - private typealias Constants = TextValidator.Constant - - func test_emails() { - let subject: TextFormatter = .email - - XCTAssertEqual(subject, .email) - XCTAssertNotEqual(subject, .identity) - - var expected: [String] = Constants.validEmails.map { - return subject.formatText($0) - } - XCTAssertEqual(Constants.validEmails, expected) - - expected = Constants.validEmails.map { - let modifiedEmail = " \n " + $0.replacingOccurrences(of: "@", with: "!*@@()") + " \n " - return subject.formatText(modifiedEmail) - } - XCTAssertEqual(Constants.validEmails, expected) - - expected = Constants.validEmails.map { - let modifiedEmail = $0.uppercased() - return subject.formatText(modifiedEmail) - } - - let actual = Constants.validEmails.map { $0.uppercased() } - XCTAssertEqual(actual, expected) - } -} diff --git a/Tests/TextFormatter_LengthLimitedTests.swift b/Tests/TextFormatter_LengthLimitedTests.swift deleted file mode 100644 index 1568cd3..0000000 --- a/Tests/TextFormatter_LengthLimitedTests.swift +++ /dev/null @@ -1,23 +0,0 @@ -import SmartText -import SpryKit -import XCTest - -final class TextFormatter_LengthLimitedTests: XCTestCase { - func test_spec() { - let subject: TextFormatter = .lengthLimited(4) - - XCTAssertEqual(subject, .lengthLimited(4)) - XCTAssertNotEqual(subject, .identity) - XCTAssertNotEqual(subject, .lengthLimited(3)) - XCTAssertNotEqual(subject, .lengthLimited(5)) - - XCTAssertEqual(subject.formatText(""), "") - XCTAssertEqual(subject.formatText("1"), "1") - XCTAssertEqual(subject.formatText("12"), "12") - XCTAssertEqual(subject.formatText("123"), "123") - XCTAssertEqual(subject.formatText("1234"), "1234") - XCTAssertEqual(subject.formatText("12345"), "1234") - XCTAssertEqual(subject.formatText("123456"), "1234") - XCTAssertEqual(subject.formatText("1234567"), "1234") - } -} diff --git a/Tests/TextFormatter_NumbersOnlyTests.swift b/Tests/TextFormatter_NumbersOnlyTests.swift deleted file mode 100644 index 30be979..0000000 --- a/Tests/TextFormatter_NumbersOnlyTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SmartText -import SpryKit -import XCTest - -final class TextFormatter_NumbersOnlyTests: XCTestCase { - func test_spec() { - let subject: TextFormatter = .numbersOnly - - XCTAssertEqual(subject, .numbersOnly) - XCTAssertNotEqual(subject, .identity) - - XCTAssertEqual(subject.formatText("123ABV34"), "12334") - XCTAssertEqual(subject.formatText("vc123ABV34"), "12334") - XCTAssertEqual(subject.formatText("vc123ABV34\\2"), "123342") - } -} diff --git a/Tests/TextFormatter_StripLeadingSpacesTests.swift b/Tests/TextFormatter_StripLeadingSpacesTests.swift deleted file mode 100644 index b68d6cd..0000000 --- a/Tests/TextFormatter_StripLeadingSpacesTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SmartText -import SpryKit -import XCTest - -final class TextFormatter_StripLeadingSpacesTests: XCTestCase { - func test_spec() { - let subject: TextFormatter = .stripLeadingSpaces - - XCTAssertEqual(subject, .stripLeadingSpaces) - XCTAssertNotEqual(subject, .identity) - - XCTAssertEqual(subject.formatText(" absd 456 "), "absd 456 ") - XCTAssertEqual(subject.formatText(" \n absd 456 "), "\n absd 456 ") - XCTAssertEqual(subject.formatText("absd 456 "), "absd 456 ") - } -} diff --git a/Tests/TextFormatter_StripSpacesTests.swift b/Tests/TextFormatter_StripSpacesTests.swift deleted file mode 100644 index c4e97e3..0000000 --- a/Tests/TextFormatter_StripSpacesTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SmartText -import SpryKit -import XCTest - -final class TextFormatter_StripSpacesTests: XCTestCase { - func test_spec() { - let subject: TextFormatter = .stripLeadingAndTrailingSpaces - - XCTAssertEqual(subject, .stripLeadingAndTrailingSpaces) - XCTAssertNotEqual(subject, .identity) - - XCTAssertEqual(subject.formatText(" absd 456 "), "absd 456") - XCTAssertEqual(subject.formatText(" absd 456"), "absd 456") - XCTAssertEqual(subject.formatText("absd 456 "), "absd 456") - } -} diff --git a/Tests/TextValidator+TestHelper.swift b/Tests/TextValidator+TestHelper.swift deleted file mode 100644 index bf8ec07..0000000 --- a/Tests/TextValidator+TestHelper.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation -import SmartText -import SpryKit - -// MARK: - TextValidator + SpryEquatable - -extension TextValidator: SpryEquatable { - static func testMake() -> TextValidator { - return .identity - } -} - -// MARK: - TextValidator + SpryFriendlyStringConvertible - -extension TextValidator: SpryFriendlyStringConvertible { - public var friendlyDescription: String { - let propertyReflector = PropertyReflector.scan(self) - let formatters: [TextValidatable] = propertyReflector.property(named: "validators") ?? [] - return "TextValidator: " + formatters.compactMap { $0.uniqueID.components(separatedBy: ".").last }.joined(separator: ", ") - } -} - -// MARK: - TextValidator.Constant - -extension TextValidator { - enum Constant { - static let validEmails: [String] = [ - "j@b.com", - "email@example.com", - "firstname.lastname@example.com", - "1tom1@readdle.com", - "email@subdomain.example.com", - "firstname+lastname@example.com", - "tomCat@readdle1.com", - "1234567890@example.com", - "email@example-one.com", - "_______@example.com", - "email@example.co.jp", - "firstname-lastname@example.com", - "mel@smil.racks", - "dontbuy@asdainthatendsin.io", - "email@example.biz", - "email@example.name", - "crlynda@crlynda.tv", - "email@example.museum", - "tomCat@1readdle.com" - ] - - static let invalidEmails: [String] = [ - "plainaddress", - "#@%^%#$@#$@#.com", - "@example.com", - "Joe Smith ", - "email.example.com", - "email@example@example.com", - ".email@example.com", - "email.@example.com", - "email..email@example.com", - "あいうえお@example.com", - "email@example.com (Joe Smith)", - "email@example", - "email@-example.com", - "email@111.222.333.44444", - "Abc..123@example.com", - "email@example..com", - "email@example-.com", - "email@.com", - "email@.", - "email@", - "tomCat@@1readdle.com" - ] - } -} diff --git a/Tests/TextValidatorTests.swift b/Tests/TextValidatorTests.swift deleted file mode 100644 index 0a5908a..0000000 --- a/Tests/TextValidatorTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -import SmartText -import SpryKit -import XCTest - -final class TextValidatorTests: XCTestCase { - private enum Constant { - static let sharedName = "TextValidator_shared_behavior" - static let sharedValidatorsKey = "validatables" - static let sharedError1Key = "errorText1" - static let sharedError2Key = "errorText2" - } - - private struct TestValidator: TextValidatable { - let uniqueID: String - let errorText: String? = nil - - init(uniqueID: String) { - self.uniqueID = uniqueID - } - - func isValid(string: String) -> Bool { - return uniqueID != string - } - } - - private func applyTest(subject: TextValidator, _ validatables: [TextValidatable], file: StaticString = #filePath, line: UInt = #line) { - XCTAssertEqual(subject, TextValidator(validatables), file: file, line: line) - XCTAssertEqual(subject, TextValidator(validatables.reversed()), file: file, line: line) - - XCTAssertNotEqual(subject, .identity, file: file, line: line) - XCTAssertNotEqual(subject, .notEmpty() + .identity, file: file, line: line) - XCTAssertNotEqual(subject, .identity + .notEmpty(), file: file, line: line) - - XCTAssertEqual(subject.isTextValid("1"), .invalid, file: file, line: line) - XCTAssertEqual(subject.isTextValid("2"), validatables.count >= 2 ? .invalid : .valid, file: file, line: line) - XCTAssertEqual(subject.isTextValid("12"), .valid, file: file, line: line) - } - - func test_spec() { - let subject: TextValidator = .email() + .includesLowerAndUppercase() - XCTAssertEqual(subject, .email() + .includesLowerAndUppercase()) - XCTAssertEqual(subject, .includesLowerAndUppercase() + .email()) - - XCTAssertNotEqual(subject, .email() + .identity) - XCTAssertNotEqual(subject, .includesLowerAndUppercase() + .identity) - XCTAssertNotEqual(subject, .email()) - XCTAssertNotEqual(subject, .identity) - - let mock1 = TestValidator(uniqueID: "1") - let mock2 = TestValidator(uniqueID: "2") - - let mockV1: TextValidator = mock1.toValidator() - let mockV2: TextValidator = mock2.toValidator() - - applyTest(subject: .init(arrayLiteral: mockV1, mockV2), [mock1, mock2]) - applyTest(subject: [mockV1, mockV2], [mock1, mock2]) - applyTest(subject: .init([mock1, mock2]), [mock1, mock2]) - applyTest(subject: .init(mock1), [mock1]) - applyTest(subject: .init(validators: [TextValidator(mock1), TextValidator(mock2)]), [mock1, mock2]) - applyTest(subject: TextValidator(mock1) + TextValidator(mock2), [mock1, mock2]) - applyTest(subject: TextValidator(mock1) + mock2, [mock1, mock2]) - applyTest(subject: mock1 + TextValidator(mock2), [mock1, mock2]) - applyTest(subject: mock1 + TextValidator(mock2) + mock1, [mock1, mock2]) - applyTest(subject: TextValidator(mock2) + mock1 + mock2, [mock1, mock2]) - applyTest(subject: mock1 + [mockV2, mockV1], [mock1, mock2]) - applyTest(subject: TextValidator(mock2) + [mockV1, mockV2], [mock1, mock2]) - } -} diff --git a/Tests/TextValidator_CustomTests.swift b/Tests/TextValidator_CustomTests.swift deleted file mode 100644 index a787aae..0000000 --- a/Tests/TextValidator_CustomTests.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation -import SmartText -import XCTest - -final class TextValidator_CustomTests: XCTestCase { - func test_spec() { - let subject: TextValidator = .custom { str in - return str.isEmpty || str.hasPrefix("123") - } - - XCTAssertEqual(subject, .custom { $0.isEmpty }) - XCTAssertNotEqual(subject, .email()) - - XCTAssertEqual(subject.isTextValid(""), .valid) - XCTAssertEqual(subject.isTextValid("123"), .valid) - XCTAssertEqual(subject.isTextValid("123qwwe"), .valid) - - XCTAssertEqual(subject.isTextValid("0"), .invalid) - XCTAssertEqual(subject.isTextValid("sd0fsd 🤓"), .invalid) - XCTAssertEqual(subject.isTextValid("asd"), .invalid) - XCTAssertEqual(subject.isTextValid("asd123"), .invalid) - XCTAssertEqual(subject.isTextValid("1223asd"), .invalid) - } -} diff --git a/Tests/TextValidator_EmailTests.swift b/Tests/TextValidator_EmailTests.swift deleted file mode 100644 index c5aa5fe..0000000 --- a/Tests/TextValidator_EmailTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -import SmartText -import XCTest - -final class TextValidator_EmailTests: XCTestCase { - private typealias Constants = TextValidator.Constant - - func test_spec() { - let subject: TextValidator = .email() - - XCTAssertEqual(subject, .email()) - XCTAssertNotEqual(subject, .identity) - - for email in Constants.invalidEmails { - XCTAssertEqual(subject.isTextValid(email), .invalid, email) - - let uppercasedEmail = email.uppercased() - XCTAssertEqual(subject.isTextValid(uppercasedEmail), .invalid, email) - - let lowercasedEmail = email.lowercased() - XCTAssertEqual(subject.isTextValid(lowercasedEmail), .invalid, email) - } - - for email in Constants.validEmails { - XCTAssertEqual(subject.isTextValid(email), .valid, email) - - let uppercasedEmail = email.uppercased() - XCTAssertEqual(subject.isTextValid(uppercasedEmail), .valid, email) - - let lowercasedEmail = email.lowercased() - XCTAssertEqual(subject.isTextValid(lowercasedEmail), .valid, email) - } - } -} diff --git a/Tests/TextValidator_IdentityTests.swift b/Tests/TextValidator_IdentityTests.swift deleted file mode 100644 index c1b896f..0000000 --- a/Tests/TextValidator_IdentityTests.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation -import SmartText -import XCTest - -final class TextValidator_IdentityTests: XCTestCase { - func test_spec() { - let subject: TextValidator = .identity - - XCTAssertEqual(subject, .identity) - XCTAssertNotEqual(subject, .email()) - - XCTAssertEqual(subject.isTextValid(""), .valid) - XCTAssertEqual(subject.isTextValid("sdfsd 🤓"), .valid) - XCTAssertEqual(subject.isTextValid(UUID().uuidString), .valid) - } -} diff --git a/Tests/TextValidator_IncludesLowerAndUppercaseTests.swift b/Tests/TextValidator_IncludesLowerAndUppercaseTests.swift deleted file mode 100644 index 7035916..0000000 --- a/Tests/TextValidator_IncludesLowerAndUppercaseTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import SmartText -import XCTest - -final class TextValidator_IncludesLowerAndUppercaseTests: XCTestCase { - func test_spec() { - let subject: TextValidator = .includesLowerAndUppercase() - - XCTAssertEqual(subject, .includesLowerAndUppercase()) - XCTAssertNotEqual(subject, .identity) - - XCTAssertEqual(subject.isTextValid(""), .invalid) - XCTAssertEqual(subject.isTextValid("abc"), .invalid) - XCTAssertEqual(subject.isTextValid("ABC"), .invalid) - - XCTAssertEqual(subject.isTextValid("aB"), .valid) - XCTAssertEqual(subject.isTextValid("aBc"), .valid) - XCTAssertEqual(subject.isTextValid("AbC"), .valid) - XCTAssertEqual(subject.isTextValid("A123abc"), .valid) - } -} diff --git a/Tests/TextValidator_MaxLengthLimitedTests.swift b/Tests/TextValidator_MaxLengthLimitedTests.swift deleted file mode 100644 index 4cd6936..0000000 --- a/Tests/TextValidator_MaxLengthLimitedTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import SmartText -import XCTest - -final class TextValidator_MaxLengthLimitedTests: XCTestCase { - func test_spec() { - let subject: TextValidator = .maxLengthLimited(4) - - XCTAssertEqual(subject, .maxLengthLimited(4)) - XCTAssertNotEqual(subject, .identity) - - XCTAssertEqual(subject.isTextValid(""), .valid) - XCTAssertEqual(subject.isTextValid("1"), .valid) - XCTAssertEqual(subject.isTextValid("12"), .valid) - XCTAssertEqual(subject.isTextValid("123"), .valid) - XCTAssertEqual(subject.isTextValid("1234"), .valid) - - XCTAssertEqual(subject.isTextValid("12345"), .invalid) - XCTAssertEqual(subject.isTextValid("123456"), .invalid) - } -} diff --git a/Tests/TextValidator_MinLengthLimitedTests.swift b/Tests/TextValidator_MinLengthLimitedTests.swift deleted file mode 100644 index 9b25719..0000000 --- a/Tests/TextValidator_MinLengthLimitedTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import SmartText -import XCTest - -final class TextValidator_MinLengthLimitedTests: XCTestCase { - func test_spec() { - let subject: TextValidator = .minLengthLimited(4) - - XCTAssertEqual(subject, .minLengthLimited(4)) - XCTAssertNotEqual(subject, .identity) - - XCTAssertEqual(subject.isTextValid(""), .invalid) - XCTAssertEqual(subject.isTextValid("1"), .invalid) - XCTAssertEqual(subject.isTextValid("12"), .invalid) - XCTAssertEqual(subject.isTextValid("123"), .invalid) - - XCTAssertEqual(subject.isTextValid("1234"), .valid) - XCTAssertEqual(subject.isTextValid("12345"), .valid) - XCTAssertEqual(subject.isTextValid("123456"), .valid) - } -} diff --git a/Tests/TextValidator_NotEmptyTests.swift b/Tests/TextValidator_NotEmptyTests.swift deleted file mode 100644 index c89cc6c..0000000 --- a/Tests/TextValidator_NotEmptyTests.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SmartText -import XCTest - -final class TextValidator_NotEmptyTests: XCTestCase { - func test_spec() { - let subject: TextValidator = .notEmpty(errorText: "error") - - XCTAssertEqual(subject, .notEmpty(errorText: "error")) - XCTAssertNotEqual(subject, .identity) - - XCTAssertEqual(subject.isTextValid(""), .invalidWithErrorText("error")) - XCTAssertEqual(subject.isTextValid("some text"), .valid) - } -} diff --git a/Tests/TextValidator_NumbersOnlyTests.swift b/Tests/TextValidator_NumbersOnlyTests.swift deleted file mode 100644 index 9f64931..0000000 --- a/Tests/TextValidator_NumbersOnlyTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -import SmartText -import XCTest - -final class TextValidator_NumbersOnlyTests: XCTestCase { - func test_spec() { - let subject: TextValidator = .numbersOnly() - - XCTAssertEqual(subject, .numbersOnly()) - XCTAssertNotEqual(subject, .identity) - - XCTAssertEqual(subject.isTextValid(""), .valid) - XCTAssertEqual(subject.isTextValid("123"), .valid) - - XCTAssertEqual(subject.isTextValid("abc123"), .invalid) - XCTAssertEqual(subject.isTextValid("123abc"), .invalid) - } -} diff --git a/Tests/Validator/TextValidator+TestHelper.swift b/Tests/Validator/TextValidator+TestHelper.swift new file mode 100644 index 0000000..8bfc5aa --- /dev/null +++ b/Tests/Validator/TextValidator+TestHelper.swift @@ -0,0 +1,13 @@ +import Foundation +import SmartText +import SpryKit + +// MARK: - TextValidator + SpryFriendlyStringConvertible + +extension TextValidator: SpryFriendlyStringConvertible { + public var friendlyDescription: String { + let propertyReflector = PropertyReflector.scan(self) + let formatters: [TextValidatable] = propertyReflector.property(named: "validators") ?? [] + return "TextValidator: " + formatters.compactMap { String(describing: $0) }.joined(separator: ", ") + } +} diff --git a/Tests/Validator/TextValidatorTests.swift b/Tests/Validator/TextValidatorTests.swift new file mode 100644 index 0000000..82fb649 --- /dev/null +++ b/Tests/Validator/TextValidatorTests.swift @@ -0,0 +1,43 @@ +import SmartText +import SpryKit +import XCTest + +final class TextValidatorTests: XCTestCase { + private enum Constant { + static let sharedName = "TextValidator_shared_behavior" + static let sharedValidatorsKey = "validatables" + static let sharedError1Key = "errorText1" + static let sharedError2Key = "errorText2" + } + + private struct TestValidator: TextValidatable { + let uniqueID: String + + func validate(_ value: String) -> TextValidationResult { + return uniqueID != value ? .valid : .invalid + } + } + + private func applyTest(subject: TextValidator, isValid: Bool, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(subject.validate("1"), .invalid, file: file, line: line) + XCTAssertEqual(subject.validate("2"), isValid ? .valid : .invalid, file: file, line: line) + XCTAssertEqual(subject.validate("12"), .valid, file: file, line: line) + } + + func test_spec() { + let mock1: TextValidatable = TestValidator(uniqueID: "1") + let mock2: TextValidatable = TestValidator(uniqueID: "2") + + let mockV1: TextValidator = mock1.toValidator() + let mockV2: TextValidator = mock2.toValidator() + + applyTest(subject: .init(arrayLiteral: mockV1, mockV2), isValid: false) + applyTest(subject: [mockV1, mockV2], isValid: false) + applyTest(subject: .init([mock1, mock2]), isValid: false) + applyTest(subject: .init(mock1), isValid: true) + applyTest(subject: .init(validators: [TextValidator(mock1), TextValidator(mock2)]), isValid: false) + applyTest(subject: TextValidator(mock1) + TextValidator(mock2), isValid: false) + applyTest(subject: TextValidator(mock1) + mock2, isValid: false) + applyTest(subject: mock1 + TextValidator(mock2), isValid: false) + } +} diff --git a/Tests/Validator/TextValidator_CustomTests.swift b/Tests/Validator/TextValidator_CustomTests.swift new file mode 100644 index 0000000..b356c34 --- /dev/null +++ b/Tests/Validator/TextValidator_CustomTests.swift @@ -0,0 +1,24 @@ +import Foundation +import SmartText +import XCTest + +final class TextValidator_CustomTests: XCTestCase { + func test_spec() { + let subject: TextValidator = .custom { str, _ in + if str.isEmpty || str.hasPrefix("123") { + return .valid + } + return .invalid + } + + XCTAssertEqual(subject.validate(""), .valid) + XCTAssertEqual(subject.validate("123"), .valid) + XCTAssertEqual(subject.validate("123qwwe"), .valid) + + XCTAssertEqual(subject.validate("0"), .invalid) + XCTAssertEqual(subject.validate("sd0fsd 🤓"), .invalid) + XCTAssertEqual(subject.validate("asd"), .invalid) + XCTAssertEqual(subject.validate("asd123"), .invalid) + XCTAssertEqual(subject.validate("1223asd"), .invalid) + } +} diff --git a/Tests/Validator/TextValidator_EmailTests.swift b/Tests/Validator/TextValidator_EmailTests.swift new file mode 100644 index 0000000..566efc8 --- /dev/null +++ b/Tests/Validator/TextValidator_EmailTests.swift @@ -0,0 +1,67 @@ +import SmartText +import XCTest + +final class TextValidator_EmailTests: XCTestCase { + func test_spec() { + let subject: TextValidator = .email() + + for email in Emails.invalidEmails { + XCTAssertEqual(subject.validate(email).map(\.isValid), [false], email) + + let uppercasedEmail = email.uppercased() + XCTAssertEqual(subject.validate(uppercasedEmail).map(\.isValid), [false], email) + + let lowercasedEmail = email.lowercased() + XCTAssertEqual(subject.validate(lowercasedEmail).map(\.isValid), [false], email) + } + + for email in Emails.validEmails { + XCTAssertEqual(subject.validate(email), .valid, email) + + let uppercasedEmail = email.uppercased() + XCTAssertEqual(subject.validate(uppercasedEmail), .valid, email) + + let lowercasedEmail = email.lowercased() + XCTAssertEqual(subject.validate(lowercasedEmail), .valid, email) + } + } + + func test_ranges() throws { + try run_validation_test(.email(), "", sub: []) + + try run_validation_test(0, sub: []) + try run_validation_test(1, sub: ["#", "%^%#$@#$@#"]) + try run_validation_test(2, sub: []) + try run_validation_test(3, sub: [" ", " <", ">"]) + try run_validation_test(4, sub: []) + try run_validation_test(5, sub: ["@"]) + try run_validation_test(6, sub: []) + try run_validation_test(7, sub: [".@"]) + try run_validation_test(8, sub: [".."]) + try run_validation_test(9, sub: ["あいうえお"]) + try run_validation_test(10, sub: [" (", " ", ")"]) + try run_validation_test(11, sub: []) + try run_validation_test(12, sub: ["@-"]) + try run_validation_test(13, sub: []) + try run_validation_test(14, sub: ["..."]) + try run_validation_test(15, sub: [".."]) + try run_validation_test(16, sub: ["-."]) + try run_validation_test(17, sub: ["@."]) + try run_validation_test(18, sub: [".@."]) + try run_validation_test(19, sub: [".@"]) + try run_validation_test(20, sub: []) + try run_validation_test(21, sub: ["@@"]) + try run_validation_test(22, sub: ["!", "@@"]) + try run_validation_test(23, sub: ["+", "@@"]) + try run_validation_test(24, sub: ["!+", "@@"]) + + XCTAssertEqual(Emails.invalidEmails.count, 25) + } + + private func run_validation_test(_ idx: Int, + sub: [String], + file: StaticString = #filePath, + line: UInt = #line) throws { + try run_validation_test(.email(), Emails.invalidEmails[idx], sub: sub, file: file, line: line) + } +} diff --git a/Tests/Validator/TextValidator_IdentityTests.swift b/Tests/Validator/TextValidator_IdentityTests.swift new file mode 100644 index 0000000..e822271 --- /dev/null +++ b/Tests/Validator/TextValidator_IdentityTests.swift @@ -0,0 +1,13 @@ +import Foundation +import SmartText +import XCTest + +final class TextValidator_IdentityTests: XCTestCase { + func test_spec() { + let subject: TextValidator = .identity + + XCTAssertEqual(subject.validate(""), .valid) + XCTAssertEqual(subject.validate("sdfsd 🤓"), .valid) + XCTAssertEqual(subject.validate(UUID().uuidString), .valid) + } +} diff --git a/Tests/Validator/TextValidator_IncludesLowercaseTests.swift b/Tests/Validator/TextValidator_IncludesLowercaseTests.swift new file mode 100644 index 0000000..69327fe --- /dev/null +++ b/Tests/Validator/TextValidator_IncludesLowercaseTests.swift @@ -0,0 +1,17 @@ +import SmartText +import XCTest + +final class TextValidator_IncludesLowercaseTests: XCTestCase { + func test_spec() { + let subject: TextValidator = .includesLowercase() + + XCTAssertEqual(subject.validate(""), .invalid) + XCTAssertEqual(subject.validate("abc"), .valid) + XCTAssertEqual(subject.validate("ABC"), .invalid) + + XCTAssertEqual(subject.validate("aB"), .valid) + XCTAssertEqual(subject.validate("aBc"), .valid) + XCTAssertEqual(subject.validate("AbC"), .valid) + XCTAssertEqual(subject.validate("A123abc"), .valid) + } +} diff --git a/Tests/Validator/TextValidator_IncludesUppercaseTests.swift b/Tests/Validator/TextValidator_IncludesUppercaseTests.swift new file mode 100644 index 0000000..5406e04 --- /dev/null +++ b/Tests/Validator/TextValidator_IncludesUppercaseTests.swift @@ -0,0 +1,17 @@ +import SmartText +import XCTest + +final class TextValidator_IncludesUppercaseTests: XCTestCase { + func test_spec() { + let subject: TextValidator = .includesUppercase() + + XCTAssertEqual(subject.validate(""), .invalid) + XCTAssertEqual(subject.validate("abc"), .invalid) + XCTAssertEqual(subject.validate("ABC"), .valid) + + XCTAssertEqual(subject.validate("aB"), .valid) + XCTAssertEqual(subject.validate("aBc"), .valid) + XCTAssertEqual(subject.validate("AbC"), .valid) + XCTAssertEqual(subject.validate("A123abc"), .valid) + } +} diff --git a/Tests/Validator/TextValidator_LengthLimitedTests.swift b/Tests/Validator/TextValidator_LengthLimitedTests.swift new file mode 100644 index 0000000..ed71cde --- /dev/null +++ b/Tests/Validator/TextValidator_LengthLimitedTests.swift @@ -0,0 +1,30 @@ +import SmartText +import XCTest + +final class TextValidator_LengthLimitedTests: XCTestCase { + func test_spec() throws { + let subject: TextValidator = .lengthLimited(4, canBeEmpty: false) + + XCTAssertEqual(subject.validate(""), .invalid) + XCTAssertEqual(subject.validate("1"), .invalid) + XCTAssertEqual(subject.validate("12"), .invalid) + XCTAssertEqual(subject.validate("123"), .invalid) + XCTAssertEqual(subject.validate("1234"), .valid) + + try run_validation_test(subject, "12345", sub: ["5"]) + try run_validation_test(subject, "123456", sub: ["56"]) + } + + func test_spec_canBeEmpty() throws { + let subject: TextValidator = .lengthLimited(4, canBeEmpty: true) + + XCTAssertEqual(subject.validate(""), .valid) + XCTAssertEqual(subject.validate("1"), .invalid) + XCTAssertEqual(subject.validate("12"), .invalid) + XCTAssertEqual(subject.validate("123"), .invalid) + XCTAssertEqual(subject.validate("1234"), .valid) + + try run_validation_test(subject, "12345", sub: ["5"]) + try run_validation_test(subject, "123456", sub: ["56"]) + } +} diff --git a/Tests/Validator/TextValidator_MaxLengthLimitedTests.swift b/Tests/Validator/TextValidator_MaxLengthLimitedTests.swift new file mode 100644 index 0000000..7a77364 --- /dev/null +++ b/Tests/Validator/TextValidator_MaxLengthLimitedTests.swift @@ -0,0 +1,17 @@ +import SmartText +import XCTest + +final class TextValidator_MaxLengthLimitedTests: XCTestCase { + let subject: TextValidator = .maxLengthLimited(4) + + func test_spec() throws { + XCTAssertEqual(subject.validate(""), .valid) + XCTAssertEqual(subject.validate("1"), .valid) + XCTAssertEqual(subject.validate("12"), .valid) + XCTAssertEqual(subject.validate("123"), .valid) + XCTAssertEqual(subject.validate("1234"), .valid) + + try run_validation_test(subject, "12345", sub: ["5"]) + try run_validation_test(subject, "123456", sub: ["56"]) + } +} diff --git a/Tests/Validator/TextValidator_MinLengthLimitedTests.swift b/Tests/Validator/TextValidator_MinLengthLimitedTests.swift new file mode 100644 index 0000000..e3f7e45 --- /dev/null +++ b/Tests/Validator/TextValidator_MinLengthLimitedTests.swift @@ -0,0 +1,17 @@ +import SmartText +import XCTest + +final class TextValidator_MinLengthLimitedTests: XCTestCase { + func test_spec() { + let subject: TextValidator = .minLengthLimited(4) + + XCTAssertEqual(subject.validate(""), .invalid) + XCTAssertEqual(subject.validate("1"), .invalid) + XCTAssertEqual(subject.validate("12"), .invalid) + XCTAssertEqual(subject.validate("123"), .invalid) + + XCTAssertEqual(subject.validate("1234"), .valid) + XCTAssertEqual(subject.validate("12345"), .valid) + XCTAssertEqual(subject.validate("123456"), .valid) + } +} diff --git a/Tests/Validator/TextValidator_NotEmptyTests.swift b/Tests/Validator/TextValidator_NotEmptyTests.swift new file mode 100644 index 0000000..b1f00a4 --- /dev/null +++ b/Tests/Validator/TextValidator_NotEmptyTests.swift @@ -0,0 +1,11 @@ +import SmartText +import XCTest + +final class TextValidator_NotEmptyTests: XCTestCase { + func test_spec() { + let subject: TextValidator = .notEmpty(errorText: "error") + + XCTAssertEqual(subject.validate(""), [.init(errorText: "error", isValid: false)]) + XCTAssertEqual(subject.validate("some text"), .valid) + } +} diff --git a/Tests/Validator/TextValidator_NumbersOnlyTests.swift b/Tests/Validator/TextValidator_NumbersOnlyTests.swift new file mode 100644 index 0000000..2012ecd --- /dev/null +++ b/Tests/Validator/TextValidator_NumbersOnlyTests.swift @@ -0,0 +1,14 @@ +import SmartText +import XCTest + +final class TextValidator_NumbersOnlyTests: XCTestCase { + func test_spec() throws { + let subject: TextValidator = .numbersOnly() + + XCTAssertEqual(subject.validate(""), .valid) + XCTAssertEqual(subject.validate("123"), .valid) + + try run_validation_test(subject, "cba123", sub: ["cba"]) + try run_validation_test(subject, "123abcd", sub: ["abcd"]) + } +} diff --git a/Tests/XCTestCase+TestHelper.swift b/Tests/XCTestCase+TestHelper.swift new file mode 100644 index 0000000..c745312 --- /dev/null +++ b/Tests/XCTestCase+TestHelper.swift @@ -0,0 +1,16 @@ +import Foundation +import SmartText +import XCTest + +extension XCTestCase { + func run_validation_test(_ subject: TextValidator, + _ str: String, + sub: [String], + file: StaticString = #filePath, + line: UInt = #line) throws { + let resultArr = subject.validate(str) + XCTAssertEqual(resultArr.count, 1, file: file, line: line) + XCTAssertEqual(resultArr.map(\.isValid), [false], file: file, line: line) + XCTAssertEqual(resultArr.flatMap(\.invalidRanges).map { String(str[$0]) }, sub, file: file, line: line) + } +}