Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement example for validation #25

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Implement examples for validation
Kuniwak committed Sep 25, 2018
commit b3eb83e4219ed79db40a516fa4bc906d326cf17b
2 changes: 2 additions & 0 deletions .idea/TestableDesignExample.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/runConfigurations/TestableDesignExample.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions .idea/runConfigurations/TestableDesignExampleTests.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .idea/runConfigurations/TestableDesignExampleUITests.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions .idea/xcode.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

108 changes: 106 additions & 2 deletions TestableDesignExample.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
version = "1.3">
<BuildAction>
<BuildActionEntries>
<BuildActionEntry
buildForRunning = "YES"
buildForTesting = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2475E649AECAE4D1FBC505BA"
BuildableName = "TestableDesignExampleTests.xctest"
BlueprintName = "TestableDesignExampleTests"
ReferencedContainer = "container:TestableDesignExample.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug">
<Testables>
<TestableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2475E649AECAE4D1FBC505BA"
BuildableName = "TestableDesignExampleTests.xctest"
BlueprintName = "TestableDesignExampleTests"
ReferencedContainer = "container:TestableDesignExample.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
useCustomWorkingDirectory = "NO"
allowLocationSimulation = "YES">
<LocationScenarioReference
identifier = "com.apple.dt.IDEFoundation.CurrentLocationScenarioIdentifier"
referenceType = "1">
</LocationScenarioReference>
</LaunchAction>
</Scheme>
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation


// NOTE: In general, CharacterSet is not equivalent to Set<Character>, but equivalent to Set<Unicode.Scalar>.
// SEE: https://github.com/apple/swift/blob/swift-4.0-RELEASE/docs/StringManifesto.md#character-and-characterset
let asciiLowerAlpha = Set("abcdefghijklmnopqrstuvwxyz")
let asciiUpperAlpha = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
let asciiAlpha = asciiLowerAlpha.union(asciiUpperAlpha)
let asciiDigit = Set("0123456789")
let asciiAlphaNumeric = asciiAlpha.union(asciiDigit)
let asciiSymbol = Set(" !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~")
let asciiPrintable = asciiAlphaNumeric.union(asciiSymbol)



func characters(in text: String, without characters: Set<Character>) -> Set<Character> {
var characterSet = Set(text)
characterSet.subtract(characters)
return characterSet
}



func string(from characters: Set<Character>) -> String {
var result = ""

characters.sorted().forEach { character in
result.append(character)
}

return result
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
enum ValidationResult<V, E> {
case success(V)
case failure(because: E)


var isSuccess: Bool {
switch self {
case .success:
return true
case .failure:
return false
}
}


var value: V? {
switch self {
case .success(let value):
return value
case .failure:
return nil
}
}


var reason: E? {
switch self {
case .success:
return nil
case .failure(because: let reason):
return reason
}
}
}



extension ValidationResult: Equatable where V: Equatable, E: Equatable {
static func ==(lhs: ValidationResult<V, E>, rhs: ValidationResult<V, E>) -> Bool {
switch (lhs, rhs) {
case (.success(let l), .success(let r)):
return l == r
case (.failure(because: let l), .failure(because: let r)):
return l == r
default:
return false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import UIKit


class ExampleValidationComposer: UIViewController {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
extension ExampleAccount {
struct Draft {
let userName: String
let password: String


static func createEmpty() -> Draft {
return Draft(userName: "", password: "")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
struct ExampleAccount: Equatable {
let userName: UserName
let password: Password


struct UserName: Equatable {
let text: String
}


struct Password: Equatable {
let text: String
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import Foundation



extension ExampleAccount.Draft {
static func validate(draft: ExampleAccount.Draft) -> ValidationResult<ExampleAccount, InvalidReason> {
let userNameResult = ExampleAccount.UserName.validate(userName: draft.userName)
let passwordResult = ExampleAccount.Password.validate(password: draft.password, userName: draft.userName)

switch (userNameResult, passwordResult) {
case (.success(let userName), .success(let password)):
return .success(ExampleAccount(
userName: userName,
password: password
))

case (.failure(because: let userNameReason), .success):
return .failure(because: InvalidReason(
userName: userNameReason,
password: []
))

case (.success, .failure(because: let passwordReason)):
return .failure(because: InvalidReason(
userName: [],
password: passwordReason
))

case (.failure(because: let userNameReason), .failure(because: let passwordReason)):
return .failure(because: InvalidReason(
userName: userNameReason,
password: passwordReason
))
}
}


struct InvalidReason: Hashable {
let userName: Set<ExampleAccount.UserName.InvalidReason>
let password: Set<ExampleAccount.Password.InvalidReason>
}
}


extension ExampleAccount.UserName {
private static let acceptableCharacters = CharacterSet.letters


static func validate(userName: String) -> ValidationResult<ExampleAccount.UserName, Set<InvalidReason>> {
var reasons = Set<InvalidReason>()

if userName.count < 4 {
reasons.insert(.shorterThan4)
}

if userName.count > 30 {
reasons.insert(.longerThan30)
}

let invalidChars = characters(in: userName, without: asciiAlphaNumeric)
if !invalidChars.isEmpty {
reasons.insert(.hasUnavailableChars(found: invalidChars))
}

guard reasons.isEmpty else {
return .failure(because: reasons)
}

return .success(ExampleAccount.UserName(text: userName))
}


enum InvalidReason: Hashable, Comparable {
case shorterThan4
case longerThan30
case hasUnavailableChars(found: Set<Character>)


static func <(lhs: InvalidReason, rhs: InvalidReason) -> Bool {
switch (lhs, rhs) {
case (_, .shorterThan4):
return false
case (.shorterThan4, _):
return true
case (_, .longerThan30):
return false
case (.longerThan30, _):
return true
case (_, .hasUnavailableChars):
return false
case (.hasUnavailableChars, _):
return true
}
}
}
}


extension ExampleAccount.Password {
static func validate(password: String, userName: String) -> ValidationResult<ExampleAccount.Password, Set<InvalidReason>> {
var reasons = Set<InvalidReason>()

if password.count < 8 {
reasons.insert(.shorterThan8)
}

if password.count > 100 {
reasons.insert(.longerThan100)
}

if password == userName {
reasons.insert(.sameAsUserName)
}

let invalidChars = characters(in: password, without: asciiPrintable)
if !invalidChars.isEmpty {
reasons.insert(.hasUnavailableChars(found: invalidChars))
}

guard reasons.isEmpty else {
return .failure(because: reasons)
}

return .success(ExampleAccount.Password(text: password))
}


enum InvalidReason: Hashable, Comparable {
case shorterThan8
case longerThan100
case hasUnavailableChars(found: Set<Character>)
case sameAsUserName


static func <(lhs: InvalidReason, rhs: InvalidReason) -> Bool {
switch (lhs, rhs) {
case (_, .shorterThan8):
return false
case (.shorterThan8, _):
return true
case (_, .longerThan100):
return false
case (.longerThan100, _):
return true
case (_, .hasUnavailableChars):
return false
case (.hasUnavailableChars, _):
return true
case (_, .sameAsUserName):
return false
case (.sameAsUserName, _):
return true
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import XCTest
import MirrorDiffKit
@testable import TestableDesignExample



class ExampleAccountTests: XCTestCase {
func testValidate() {
typealias TestCase = (
input: ExampleAccount.Draft,
expected: ValidationResult<ExampleAccount, ExampleAccount.Draft.InvalidReason>
)

let testCases: [UInt: TestCase] = [
#line: (
input: .init(
userName: "userName",
password: "password"
),
expected: .success(ExampleAccount(
userName: .init(text: "userName"),
password: .init(text: "password")
))
),
#line: (
input: .init(
userName: "",
password: "password"
),
expected: .failure(
because: .init(userName: [.shorterThan4], password: [])
)
),
#line: (
input: .init(
userName: "userName",
password: ""
),
expected: .failure(
because: .init(userName: [], password: [.shorterThan8])
)
),
#line: (
input: .init(
userName: "u",
password: "p"
),
expected: .failure(
because: .init(
userName: [.shorterThan4],
password: [.shorterThan8]
)
)
),
#line: (
input: .init(
userName: "userName",
password: "userName"
),
expected: .failure(
because: .init(
userName: [],
password: [.sameAsUserName]
)
)
),
]

testCases.forEach { tuple in
let (line, (input: draft, expected: expected)) = tuple

let actual = ExampleAccount.Draft.validate(draft: draft)

XCTAssertEqual(expected, actual, diff(between: actual, and: expected), line: line)
}
}
}



class ExampleAccountUserNameTests: XCTestCase {
func testValidate() {
typealias TestCase = (
input: String,
expected: ValidationResult<ExampleAccount.UserName, Set<ExampleAccount.UserName.InvalidReason>>
)

let testCases: [UInt: TestCase] = [
#line: (
input: "",
expected: .failure(because: [.shorterThan4])
),
#line: (
input: String(repeating: "x", count: 3),
expected: .failure(because: [.shorterThan4])
),
#line: (
input: String(repeating: "x", count: 4),
expected: .success(.init(text: String(repeating: "x", count: 4)))
),
#line: (
input: String(repeating: "x", count: 30),
expected: .success(.init(text: String(repeating: "x", count: 30)))
),
#line: (
input: String(repeating: "x", count: 31),
expected: .failure(because: [.longerThan30])
),
#line: (
input: string(from: asciiDigit),
expected: .success(.init(text: string(from: asciiDigit)))
),
#line: (
input: string(from: asciiLowerAlpha),
expected: .success(.init(text: string(from: asciiLowerAlpha)))
),
#line: (
input: string(from: asciiUpperAlpha),
expected: .success(.init(text: string(from: asciiUpperAlpha)))
),
#line: (
input: "abcd1234ABCD",
expected: .success(.init(text: "abcd1234ABCD"))
),
#line: (
input: string(from: asciiSymbol),
expected: .failure(because: [
.longerThan30,
.hasUnavailableChars(found: asciiSymbol),
])
),
]

testCases.forEach { tuple in
let (line, (input: input, expected: expected)) = tuple

let actual = ExampleAccount.UserName.validate(userName: input)

XCTAssertEqual(expected, actual, diff(between: actual, and: expected), line: line)
}
}
}



class ExampleAccountPasswordTests: XCTestCase {
func testValidate() {
typealias TestCase = (
input: (password: String, userName: String),
expected: ValidationResult<ExampleAccount.Password, Set<ExampleAccount.Password.InvalidReason>>
)

let testCases: [UInt: TestCase] = [
#line: (
input: (password: "", userName: "userName"),
expected: .failure(because: [.shorterThan8])
),
#line: (
input: (password: String(repeating: "x", count: 7), userName: "userName"),
expected: .failure(because: [.shorterThan8])
),
#line: (
input: (password: String(repeating: "x", count: 8), userName: "userName"),
expected: .success(.init(text: String(repeating: "x", count: 8)))
),
#line: (
input: (password: String(repeating: "x", count: 100), userName: "userName"),
expected: .success(.init(text: String(repeating: "x", count: 100)))
),
#line: (
input: (password: String(repeating: "x", count: 101), userName: "userName"),
expected: .failure(because: [.longerThan100])
),
#line: (
input: (password: string(from: asciiPrintable), userName: "userName"),
expected: .success(.init(text: string(from: asciiPrintable)))
),
#line: (
input: (password: "userName", userName: "userName"),
expected: .failure(because: [.sameAsUserName])
),
]

testCases.forEach { tuple in
let (line, (input: (password: password, userName: userName), expected: expected)) = tuple

let actual = ExampleAccount.Password.validate(password: password, userName: userName)

XCTAssertEqual(expected, actual, diff(between: actual, and: expected), line: line)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
@testable import TestableDesignExample



enum ExampleAccountFactory {
static func create(
userName: ExampleAccount.UserName = UserNameFactory.create(),
password: ExampleAccount.Password = PasswordFactory.create()
) -> ExampleAccount {
return ExampleAccount(userName: userName, password: password)
}


enum UserNameFactory {
static func create(text: String = "userName") -> ExampleAccount.UserName {
return .init(text: text)
}
}


enum PasswordFactory {
static func create(text: String = "userName") -> ExampleAccount.Password {
return .init(text: text)
}
}


enum DraftFactory {
static func create(userName: String = "", password: String = "") -> ExampleAccount.Draft {
return ExampleAccount.Draft(userName: userName, password: password)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import RxCocoa



protocol ExampleValidationModelProtocol {
var currentState: ExampleValidationModelState { get }
var didChange: RxCocoa.Driver<ExampleValidationModelState> { get }

func update(by draft: ExampleAccount.Draft)
}



enum ExampleValidationModelState: Equatable {
case notValidatedYet
case validated(ValidationResult<ExampleAccount, ExampleAccount.Draft.InvalidReason>)
}



class ExampleValidationModel: ExampleValidationModelProtocol {
typealias Strategy = (ExampleAccount.Draft) -> ValidationResult<ExampleAccount, ExampleAccount.Draft.InvalidReason>


private let stateMachine: StateMachine<ExampleValidationModelState>
private let validate: Strategy


var currentState: ExampleValidationModelState {
return self.stateMachine.currentState
}


var didChange: Driver<ExampleValidationModelState> {
return self.stateMachine.didChange
}


init(startingWith initialState: ExampleValidationModelState, validatingBy strategy: @escaping Strategy) {
self.stateMachine = StateMachine(startingWith: initialState)
self.validate = strategy
}


func update(by draft: ExampleAccount.Draft) {
let result = self.validate(draft)
self.stateMachine.transit(to: .validated(result))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import XCTest
import MirrorDiffKit
@testable import TestableDesignExample



class ExampleValidationModelTests: XCTestCase {
func testNotValidatedYet() {
let model = ExampleValidationModel(
startingWith: .notValidatedYet,
validatingBy: self.createDummyStrategy()
)

let actual = model.currentState

let expected: ExampleValidationModelState = .notValidatedYet
XCTAssertEqual(actual, expected, diff(between: expected, and: actual))
}


func testSuccess() {
let account = ExampleAccountFactory.create()
let model = ExampleValidationModel(
startingWith: .notValidatedYet,
validatingBy: self.createSuccessfulStrategy(account: account)
)

let anyDraft = ExampleAccountFactory.DraftFactory.create()
model.update(by: anyDraft)

let actual = model.currentState
let expected: ExampleValidationModelState = .validated(.success(account))
XCTAssertEqual(actual, expected, diff(between: expected, and: actual))
}


func testFailure() {
let reason = self.createAnyDraftInvalidReasonSet()
let model = ExampleValidationModel(
startingWith: .notValidatedYet,
validatingBy: self.createFailedStrategy(reason: reason)
)

let anyDraft = ExampleAccountFactory.DraftFactory.create()
model.update(by: anyDraft)

let actual = model.currentState
let expected: ExampleValidationModelState = .validated(.failure(because: reason))
XCTAssertEqual(actual, expected, diff(between: expected, and: actual))
}


private func createDummyStrategy() -> ExampleValidationModel.Strategy {
return { _ in
fatalError("It should not affect to test results")
}
}


private func createSuccessfulStrategy(account: ExampleAccount) -> ExampleValidationModel.Strategy {
return { _ in
return .success(account)
}
}


private func createFailedStrategy(reason: ExampleAccount.Draft.InvalidReason) -> ExampleValidationModel.Strategy {
return { _ in
return .failure(because: reason)
}
}


private func createAnyDraftInvalidReasonSet() -> ExampleAccount.Draft.InvalidReason {
return ExampleAccount.Draft.InvalidReason(
userName: [.shorterThan4],
password: [.shorterThan8]
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import UIKit



class ExampleValidationScreenRootView: UIView {
@IBOutlet weak var nameTextField: UITextField!
@IBOutlet weak var passwordTextField: UITextField!
@IBOutlet weak var userNameHintLabel: UILabel!
@IBOutlet weak var passwordHintLabel: UILabel!


override init(frame: CGRect) {
super.init(frame: frame)
self.loadFromXib()
}


required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.loadFromXib()
}


private func loadFromXib() {
guard let view = R.nib.exampleValidationScreenRootView.firstView(owner: self) else {
return
}

view.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(view)

FilledLayout.fill(subview: view, into: self)
self.layoutIfNeeded()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14313.18" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14283.14"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ExampleValidationScreenRootView" customModule="TestableDesignExample" customModuleProvider="target">
<connections>
<outlet property="nameTextField" destination="Y5r-gr-OPr" id="3lC-Ul-rvc"/>
<outlet property="passwordHintLabel" destination="fYa-66-MCR" id="q8H-vT-pZb"/>
<outlet property="passwordTextField" destination="tUz-p2-VGw" id="48l-6u-hde"/>
<outlet property="userNameHintLabel" destination="OqQ-b6-Sa1" id="3jA-2U-Ayp"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="L54-9W-H0s">
<rect key="frame" x="20" y="207.5" width="335" height="272.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Example Validation" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="x45-BB-Mps">
<rect key="frame" x="0.0" y="0.0" width="335" height="27.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="23"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="vTD-91-wb0">
<rect key="frame" x="0.0" y="39.5" width="335" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="Y5r-gr-OPr" userLabel="Name field">
<rect key="frame" x="0.0" y="66.5" width="335" height="34"/>
<color key="backgroundColor" cocoaTouchSystemColor="groupTableViewBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" constant="34" id="1W5-ZR-2X9"/>
</constraints>
<nil key="textColor"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<textInputTraits key="textInputTraits"/>
</textField>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="OqQ-b6-Sa1" userLabel="Name hint">
<rect key="frame" x="0.0" y="108.5" width="335" height="22"/>
<constraints>
<constraint firstAttribute="height" constant="22" id="dVl-D2-YJN"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" red="1" green="0.14913141730000001" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Password" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="AGU-wy-eGC">
<rect key="frame" x="0.0" y="146.5" width="335" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="tUz-p2-VGw" userLabel="Password field">
<rect key="frame" x="0.0" y="175.5" width="335" height="34"/>
<color key="backgroundColor" cocoaTouchSystemColor="groupTableViewBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" constant="34" id="DGy-YA-cvZ"/>
</constraints>
<nil key="textColor"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<textInputTraits key="textInputTraits"/>
</textField>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="NOTE: This information do not send to anywhere!" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="YqM-5a-GRB" userLabel="Note">
<rect key="frame" x="0.0" y="255.5" width="335" height="17"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="fYa-66-MCR" userLabel="Password hint">
<rect key="frame" x="0.0" y="217.5" width="335" height="22"/>
<constraints>
<constraint firstAttribute="height" constant="22" id="Y6A-oV-rFj"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" red="1" green="0.14913141730000001" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="tUz-p2-VGw" secondAttribute="trailing" id="8gq-vp-0c6"/>
<constraint firstAttribute="trailing" secondItem="vTD-91-wb0" secondAttribute="trailing" id="9ZB-iX-KQq"/>
<constraint firstAttribute="trailing" secondItem="x45-BB-Mps" secondAttribute="trailing" id="An9-an-O6A"/>
<constraint firstItem="vTD-91-wb0" firstAttribute="leading" secondItem="L54-9W-H0s" secondAttribute="leading" id="CUt-fT-MLp"/>
<constraint firstItem="tUz-p2-VGw" firstAttribute="top" secondItem="AGU-wy-eGC" secondAttribute="bottom" constant="8" id="IF2-VP-k7v"/>
<constraint firstItem="x45-BB-Mps" firstAttribute="top" secondItem="L54-9W-H0s" secondAttribute="top" id="Iik-qN-oJ0"/>
<constraint firstItem="vTD-91-wb0" firstAttribute="top" secondItem="x45-BB-Mps" secondAttribute="bottom" constant="12" id="KdI-Y1-Nrr"/>
<constraint firstAttribute="bottom" secondItem="YqM-5a-GRB" secondAttribute="bottom" id="Ko9-ZP-eod"/>
<constraint firstAttribute="trailing" secondItem="Y5r-gr-OPr" secondAttribute="trailing" id="MgQ-Qn-k1X"/>
<constraint firstItem="Y5r-gr-OPr" firstAttribute="leading" secondItem="L54-9W-H0s" secondAttribute="leading" id="RQ9-7k-TdG"/>
<constraint firstItem="AGU-wy-eGC" firstAttribute="top" secondItem="OqQ-b6-Sa1" secondAttribute="bottom" constant="16" id="SZd-wc-IWc"/>
<constraint firstAttribute="trailing" secondItem="YqM-5a-GRB" secondAttribute="trailing" id="TLj-mp-piv"/>
<constraint firstItem="tUz-p2-VGw" firstAttribute="leading" secondItem="L54-9W-H0s" secondAttribute="leading" id="fKT-R9-1R6"/>
<constraint firstItem="OqQ-b6-Sa1" firstAttribute="leading" secondItem="L54-9W-H0s" secondAttribute="leading" id="fZ4-6G-mMH"/>
<constraint firstItem="YqM-5a-GRB" firstAttribute="leading" secondItem="L54-9W-H0s" secondAttribute="leading" id="fni-i2-mIN"/>
<constraint firstItem="fYa-66-MCR" firstAttribute="leading" secondItem="L54-9W-H0s" secondAttribute="leading" id="hWN-ko-yAh"/>
<constraint firstAttribute="trailing" secondItem="AGU-wy-eGC" secondAttribute="trailing" id="jP1-OY-dDQ"/>
<constraint firstItem="OqQ-b6-Sa1" firstAttribute="top" secondItem="Y5r-gr-OPr" secondAttribute="bottom" constant="8" id="lL1-98-ez0"/>
<constraint firstItem="fYa-66-MCR" firstAttribute="top" secondItem="tUz-p2-VGw" secondAttribute="bottom" constant="8" id="mCi-gp-8Uj"/>
<constraint firstAttribute="trailing" secondItem="fYa-66-MCR" secondAttribute="trailing" id="mdH-8J-Wbr"/>
<constraint firstItem="x45-BB-Mps" firstAttribute="leading" secondItem="L54-9W-H0s" secondAttribute="leading" id="pgm-Ls-UXf"/>
<constraint firstItem="YqM-5a-GRB" firstAttribute="top" secondItem="fYa-66-MCR" secondAttribute="bottom" constant="16" id="psn-WT-Dj0"/>
<constraint firstItem="Y5r-gr-OPr" firstAttribute="top" secondItem="vTD-91-wb0" secondAttribute="bottom" constant="6" id="qga-wa-7OZ"/>
<constraint firstAttribute="trailing" secondItem="OqQ-b6-Sa1" secondAttribute="trailing" id="vhF-uW-syT"/>
<constraint firstItem="AGU-wy-eGC" firstAttribute="leading" secondItem="L54-9W-H0s" secondAttribute="leading" id="wf6-HC-REz"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="L54-9W-H0s" firstAttribute="centerY" secondItem="vUN-kp-3ea" secondAttribute="centerY" id="WKk-FB-vfK"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="L54-9W-H0s" secondAttribute="trailing" constant="20" id="brt-tQ-7cK"/>
<constraint firstItem="L54-9W-H0s" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="20" id="dA9-Of-Te8"/>
</constraints>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<point key="canvasLocation" x="53.600000000000001" y="48.125937031484263"/>
</view>
</objects>
</document>
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import UIKit
import RxSwift
import RxCocoa



protocol ExampleValidationViewBindingProtocol {}



class ExampleValidationViewBinding: ExampleValidationViewBindingProtocol {
private let disposeBag = RxSwift.DisposeBag()
private let model: ExampleValidationModelProtocol
private let view: ExampleValidationScreenRootView


init(
observing model: ExampleValidationModelProtocol,
handling view: ExampleValidationScreenRootView
) {
self.model = model
self.view = view

self.model.didChange
.drive(onNext: { [weak self] state in
guard let this = self else { return }

switch state {
case .notValidatedYet:
this.view.userNameHintLabel.text = ""
this.view.userNameHintLabel.backgroundColor = ColorPalette.Form.Background.normal
this.view.passwordHintLabel.text = ""
this.view.passwordHintLabel.backgroundColor = ColorPalette.Form.Background.normal

case .validated(.success):
this.view.userNameHintLabel.text = ""
this.view.userNameHintLabel.backgroundColor = ColorPalette.Form.Background.ok
this.view.passwordHintLabel.text = ""
this.view.passwordHintLabel.backgroundColor = ColorPalette.Form.Background.ok

case .validated(.failure(because: let reason)):
if let userNameReason = reason.userName.sorted().first {
let userNameHint: String
switch userNameReason {
case .shorterThan4:
userNameHint = "Must be longer than 8"
case .longerThan30:
userNameHint = "Must be shorter than 100"
case .hasUnavailableChars(found: let characters):
userNameHint = "Unavailable characters: \(string(from: characters))"
}
this.view.userNameHintLabel.text = userNameHint
this.view.userNameHintLabel.backgroundColor = ColorPalette.Form.Background.ng
}
else {
this.view.userNameHintLabel.text = ""
this.view.userNameHintLabel.backgroundColor = ColorPalette.Form.Background.ok
}

if let passwordReason = reason.password.sorted().first {
let passwordHint: String
switch passwordReason {
case .shorterThan8:
passwordHint = "Must be longer than 8"
case .longerThan100:
passwordHint = "Must be shorter than 100"
case .hasUnavailableChars(found: let characters):
passwordHint = "Unavailable characters: \(string(from: characters))"
case .sameAsUserName:
passwordHint = "Must be difference the user name"
}
this.view.passwordHintLabel.text = passwordHint
this.view.passwordHintLabel.backgroundColor = ColorPalette.Form.Background.ng
}
else {
this.view.passwordHintLabel.text = ""
this.view.passwordHintLabel.backgroundColor = ColorPalette.Form.Background.ok
}
}
})
.disposed(by: self.disposeBag)
}
}
32 changes: 32 additions & 0 deletions TestableDesignExample/Resources/Color.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import UIKit



enum ColorPalette {
enum Form {
enum Background {
static let normal = UIColor(
hue: 0.666,
saturation: 0.020,
brightness: 0.960,
alpha: 0
)


static let ng = UIColor(
hue: 0.005,
saturation: 0.280,
brightness: 1,
alpha: 1
)


static let ok = UIColor(
hue: 0.311,
saturation: 0.280,
brightness: 1,
alpha: 1
)
}
}
}