init iOS module

This commit is contained in:
pengfei.zhou
2019-07-25 19:26:33 +08:00
parent 40416ff3fd
commit f86e7623a2
211 changed files with 20246 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import XCTest
/// Helper class providing access to the currently executing XCTestCase instance, if any
@objc public final class CurrentTestCaseTracker: NSObject, XCTestObservation {
@objc public static let shared = CurrentTestCaseTracker()
private(set) var currentTestCase: XCTestCase?
@objc public func testCaseWillStart(_ testCase: XCTestCase) {
currentTestCase = testCase
}
@objc public func testCaseDidFinish(_ testCase: XCTestCase) {
currentTestCase = nil
}
}
extension XCTestCase {
var sanitizedName: String? {
let fullName = self.name
let characterSet = CharacterSet(charactersIn: "[]+-")
#if swift(>=4)
let name = fullName.components(separatedBy: characterSet).joined()
#else
let name = (fullName ?? "").components(separatedBy: characterSet).joined()
#endif
if let quickClass = NSClassFromString("QuickSpec"), self.isKind(of: quickClass) {
let className = String(describing: type(of: self))
if let range = name.range(of: className), range.lowerBound == name.startIndex {
return name.replacingCharacters(in: range, with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
return name
}
}

View File

@@ -0,0 +1,217 @@
import Foundation
import Nimble
import QuartzCore
import UIKit
public enum ResizeMode {
case frame
case constrains
case block(resizeBlock: (UIView, CGSize) -> Void)
case custom(viewResizer: ViewResizer)
func viewResizer() -> ViewResizer {
switch self {
case .frame:
return FrameViewResizer()
case .constrains:
return ConstraintViewResizer()
case .block(resizeBlock: let block):
return BlockViewResizer(block: block)
case .custom(viewResizer: let resizer):
return resizer
}
}
}
public protocol ViewResizer {
func resize(view: UIView, for size: CGSize)
}
struct FrameViewResizer: ViewResizer {
func resize(view: UIView, for size: CGSize) {
view.frame = CGRect(origin: .zero, size: size)
view.layoutIfNeeded()
}
}
struct BlockViewResizer: ViewResizer {
let resizeBlock: (UIView, CGSize) -> Void
init(block: @escaping (UIView, CGSize) -> Void) {
self.resizeBlock = block
}
func resize(view: UIView, for size: CGSize) {
self.resizeBlock(view, size)
}
}
class ConstraintViewResizer: ViewResizer {
typealias SizeConstrainsWrapper = (heightConstrain: NSLayoutConstraint, widthConstrain: NSLayoutConstraint)
func resize(view: UIView, for size: CGSize) {
let sizesConstrains = findConstrains(of: view)
sizesConstrains.heightConstrain.constant = size.height
sizesConstrains.widthConstrain.constant = size.width
NSLayoutConstraint.activate([sizesConstrains.heightConstrain,
sizesConstrains.widthConstrain])
view.layoutIfNeeded()
//iOS 9+ BUG: Before the first draw, iOS will not calculate the layout,
// it add a _UITemporaryLayoutWidth equals to its bounds and create a conflict.
// So to it do all the layout we create a Window and add it as subview
if view.bounds.width != size.width || view.bounds.width != size.width {
let window = UIWindow(frame: CGRect(origin: .zero, size: size))
let viewController = UIViewController()
viewController.view = UIView()
viewController.view.addSubview(view)
window.rootViewController = viewController
window.makeKeyAndVisible()
view.setNeedsLayout()
view.layoutIfNeeded()
}
}
func findConstrains(of view: UIView) -> SizeConstrainsWrapper {
var height: NSLayoutConstraint!
var width: NSLayoutConstraint!
let heightLayout = NSLayoutAttribute.height
let widthLayout = NSLayoutAttribute.width
let equalRelation = NSLayoutRelation.equal
for constrain in view.constraints {
if constrain.firstAttribute == heightLayout &&
constrain.relation == equalRelation && constrain.secondItem == nil {
height = constrain
}
if constrain.firstAttribute == widthLayout &&
constrain.relation == equalRelation && constrain.secondItem == nil {
width = constrain
}
}
if height == nil {
height = NSLayoutConstraint(item: view, attribute: heightLayout, relatedBy: equalRelation, toItem: nil,
attribute: heightLayout, multiplier: 1, constant: 0)
view.addConstraint(height)
}
if width == nil {
width = NSLayoutConstraint(item: view, attribute: widthLayout, relatedBy: equalRelation, toItem: nil,
attribute: widthLayout, multiplier: 1, constant: 0)
view.addConstraint(width)
}
return (height, width)
}
}
public struct DynamicSizeSnapshot {
let name: String?
let record: Bool
let sizes: [String: CGSize]
let resizeMode: ResizeMode
init(name: String?, record: Bool, sizes: [String: CGSize], resizeMode: ResizeMode) {
self.name = name
self.record = record
self.sizes = sizes
self.resizeMode = resizeMode
}
}
public func snapshot(_ name: String? = nil, sizes: [String: CGSize],
resizeMode: ResizeMode = .frame) -> DynamicSizeSnapshot {
return DynamicSizeSnapshot(name: name, record: false, sizes: sizes, resizeMode: resizeMode)
}
public func haveValidDynamicSizeSnapshot(named name: String? = nil, sizes: [String: CGSize],
isDeviceAgnostic: Bool = false, usesDrawRect: Bool = false,
tolerance: CGFloat? = nil,
resizeMode: ResizeMode = .frame) -> Predicate<Snapshotable> {
return Predicate.fromDeprecatedClosure { actualExpression, failureMessage in
return performDynamicSizeSnapshotTest(name, sizes: sizes, isDeviceAgnostic: isDeviceAgnostic,
usesDrawRect: usesDrawRect, actualExpression: actualExpression,
failureMessage: failureMessage, tolerance: tolerance,
isRecord: false, resizeMode: resizeMode)
}
}
// swiftlint:disable:next function_parameter_count
func performDynamicSizeSnapshotTest(_ name: String?, sizes: [String: CGSize], isDeviceAgnostic: Bool = false,
usesDrawRect: Bool = false, actualExpression: Expression<Snapshotable>,
failureMessage: FailureMessage, tolerance: CGFloat? = nil, isRecord: Bool,
resizeMode: ResizeMode) -> Bool {
// swiftlint:disable:next force_try force_unwrapping
let instance = try! actualExpression.evaluate()!
let testFileLocation = actualExpression.location.file
let referenceImageDirectory = getDefaultReferenceDirectory(testFileLocation)
let snapshotName = sanitizedTestName(name)
let tolerance = tolerance ?? getTolerance()
let resizer = resizeMode.viewResizer()
let result = sizes.map { (sizeName, size) -> Bool in
// swiftlint:disable:next force_unwrapping
let view = instance.snapshotObject!
resizer.resize(view: view, for: size)
return FBSnapshotTest.compareSnapshot(instance, isDeviceAgnostic: isDeviceAgnostic, usesDrawRect: usesDrawRect,
snapshot: "\(snapshotName) - \(sizeName)", record: isRecord,
referenceDirectory: referenceImageDirectory, tolerance: tolerance,
filename: actualExpression.location.file)
}
if isRecord {
if result.filter({ !$0 }).isEmpty {
let name = name ?? snapshotName
failureMessage.actualValue = "snapshot \(name) successfully recorded, replace recordSnapshot with a check"
} else {
failureMessage.actualValue = "expected to record a snapshot in \(String(describing: name))"
}
return false
} else {
if !result.filter({ !$0 }).isEmpty {
clearFailureMessage(failureMessage)
failureMessage.actualValue = "expected a matching snapshot in \(snapshotName)"
return false
}
return true
}
}
public func recordSnapshot(_ name: String? = nil, sizes: [String: CGSize],
resizeMode: ResizeMode = .frame) -> DynamicSizeSnapshot {
return DynamicSizeSnapshot(name: name, record: true, sizes: sizes, resizeMode: resizeMode)
}
public func recordDynamicSizeSnapshot(named name: String? = nil, sizes: [String: CGSize],
isDeviceAgnostic: Bool = false, usesDrawRect: Bool = false,
resizeMode: ResizeMode = .frame) -> Predicate<Snapshotable> {
return Predicate.fromDeprecatedClosure { actualExpression, failureMessage in
return performDynamicSizeSnapshotTest(name, sizes: sizes, isDeviceAgnostic: isDeviceAgnostic,
usesDrawRect: usesDrawRect, actualExpression: actualExpression,
failureMessage: failureMessage, isRecord: true, resizeMode: resizeMode)
}
}
public func == (lhs: Expectation<Snapshotable>, rhs: DynamicSizeSnapshot) {
if rhs.record {
lhs.to(recordDynamicSizeSnapshot(named: rhs.name, sizes: rhs.sizes, resizeMode: rhs.resizeMode))
} else {
lhs.to(haveValidDynamicSizeSnapshot(named: rhs.name, sizes: rhs.sizes, resizeMode: rhs.resizeMode))
}
}

View File

@@ -0,0 +1,120 @@
import Nimble
import UIKit
public func allContentSizeCategories() -> [UIContentSizeCategory] {
return [
.extraSmall, .small, .medium,
.large, .extraLarge, .extraExtraLarge,
.extraExtraExtraLarge, .accessibilityMedium,
.accessibilityLarge, .accessibilityExtraLarge,
.accessibilityExtraExtraLarge, .accessibilityExtraExtraExtraLarge
]
}
func shortCategoryName(_ category: UIContentSizeCategory) -> String {
return category.rawValue.replacingOccurrences(of: "UICTContentSizeCategory", with: "")
}
func combinePredicates<T>(_ predicates: [Predicate<T>], ignoreFailures: Bool = false,
deferred: (() -> Void)? = nil) -> Predicate<T> {
return Predicate.fromDeprecatedClosure { actualExpression, failureMessage in
defer {
deferred?()
}
return try predicates.reduce(true) { acc, matcher -> Bool in
guard acc || ignoreFailures else {
return false
}
let result = try matcher.matches(actualExpression, failureMessage: failureMessage)
return result && acc
}
}
}
public func haveValidDynamicTypeSnapshot(named name: String? = nil, usesDrawRect: Bool = false,
tolerance: CGFloat? = nil,
sizes: [UIContentSizeCategory] = allContentSizeCategories(),
isDeviceAgnostic: Bool = false) -> Predicate<Snapshotable> {
let mock = NBSMockedApplication()
let predicates: [Predicate<Snapshotable>] = sizes.map { category in
let sanitizedName = sanitizedTestName(name)
let nameWithCategory = "\(sanitizedName)_\(shortCategoryName(category))"
return Predicate.fromDeprecatedClosure { actualExpression, failureMessage in
mock.mockPreferredContentSizeCategory(category)
updateTraitCollection(on: actualExpression)
let predicate: Predicate<Snapshotable>
if isDeviceAgnostic {
predicate = haveValidDeviceAgnosticSnapshot(named: nameWithCategory,
usesDrawRect: usesDrawRect, tolerance: tolerance)
} else {
predicate = haveValidSnapshot(named: nameWithCategory, usesDrawRect: usesDrawRect, tolerance: tolerance)
}
return try predicate.matches(actualExpression, failureMessage: failureMessage)
}
}
return combinePredicates(predicates) {
mock.stopMockingPreferredContentSizeCategory()
}
}
public func recordDynamicTypeSnapshot(named name: String? = nil, usesDrawRect: Bool = false,
sizes: [UIContentSizeCategory] = allContentSizeCategories(),
isDeviceAgnostic: Bool = false) -> Predicate<Snapshotable> {
let mock = NBSMockedApplication()
let predicates: [Predicate<Snapshotable>] = sizes.map { category in
let sanitizedName = sanitizedTestName(name)
let nameWithCategory = "\(sanitizedName)_\(shortCategoryName(category))"
return Predicate.fromDeprecatedClosure { actualExpression, failureMessage in
mock.mockPreferredContentSizeCategory(category)
updateTraitCollection(on: actualExpression)
let predicate: Predicate<Snapshotable>
if isDeviceAgnostic {
predicate = recordDeviceAgnosticSnapshot(named: nameWithCategory, usesDrawRect: usesDrawRect)
} else {
predicate = recordSnapshot(named: nameWithCategory, usesDrawRect: usesDrawRect)
}
return try predicate.matches(actualExpression, failureMessage: failureMessage)
}
}
return combinePredicates(predicates, ignoreFailures: true) {
mock.stopMockingPreferredContentSizeCategory()
}
}
private func updateTraitCollection(on expression: Expression<Snapshotable>) {
// swiftlint:disable:next force_try force_unwrapping
let instance = try! expression.evaluate()!
updateTraitCollection(on: instance)
}
private func updateTraitCollection(on element: Snapshotable) {
if let environment = element as? UITraitEnvironment {
if let vc = environment as? UIViewController {
vc.beginAppearanceTransition(true, animated: false)
vc.endAppearanceTransition()
}
environment.traitCollectionDidChange(nil)
if let view = environment as? UIView {
view.subviews.forEach(updateTraitCollection(on:))
} else if let vc = environment as? UIViewController {
vc.childViewControllers.forEach(updateTraitCollection(on:))
if vc.isViewLoaded {
updateTraitCollection(on: vc.view)
}
}
}
}

View File

@@ -0,0 +1,13 @@
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface NBSMockedApplication : NSObject
- (void)mockPreferredContentSizeCategory:(UIContentSizeCategory)category;
- (void)stopMockingPreferredContentSizeCategory;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,156 @@
#import "NBSMockedApplication.h"
#import <objc/runtime.h>
@interface NBSMockedApplication ()
@property (nonatomic) BOOL isSwizzled;
@end
@interface UIFont (Swizzling)
+ (void)nbs_swizzle;
@end
@interface UIApplication (Swizzling)
+ (void)nbs_swizzle;
@property (nonatomic) UIContentSizeCategory nbs_preferredContentSizeCategory;
@end
@interface UITraitCollection (Swizzling)
+ (void)nbs_swizzle;
@end
@implementation NBSMockedApplication
/* On iOS 9, +[UIFont preferredFontForTextStyle:] uses -[UIApplication preferredContentSizeCategory]
to get the content size category. However, this changed on iOS 10. While I haven't found what UIFont uses to get
the current category, swizzling preferredFontForTextStyle: to use +[UIFont preferredFontForTextStyle: compatibleWithTraitCollection:]
(only available on iOS >= 10), passing an UITraitCollection with the desired contentSizeCategory.
*/
- (void)mockPreferredContentSizeCategory:(UIContentSizeCategory)category {
UIApplication.sharedApplication.nbs_preferredContentSizeCategory = category;
if (!self.isSwizzled) {
[UIApplication nbs_swizzle];
[UIFont nbs_swizzle];
[UITraitCollection nbs_swizzle];
self.isSwizzled = YES;
}
[[NSNotificationCenter defaultCenter] postNotificationName:UIContentSizeCategoryDidChangeNotification
object:[UIApplication sharedApplication]
userInfo:@{UIContentSizeCategoryNewValueKey: category}];
}
- (void)stopMockingPreferredContentSizeCategory {
if (self.isSwizzled) {
[UIApplication nbs_swizzle];
[UIFont nbs_swizzle];
[UITraitCollection nbs_swizzle];
self.isSwizzled = NO;
}
}
- (void)dealloc {
[self stopMockingPreferredContentSizeCategory];
}
@end
@implementation UIFont (Swizzling)
+ (UIFont *)nbs_preferredFontForTextStyle:(UIFontTextStyle)style {
UIContentSizeCategory category = UIApplication.sharedApplication.preferredContentSizeCategory;
UITraitCollection *categoryTrait = [UITraitCollection traitCollectionWithPreferredContentSizeCategory:category];
return [UIFont preferredFontForTextStyle:style compatibleWithTraitCollection:categoryTrait];
}
+ (void)nbs_swizzle {
if (![UITraitCollection instancesRespondToSelector:@selector(preferredContentSizeCategory)]) {
return;
}
SEL selector = @selector(preferredFontForTextStyle:);
SEL replacedSelector = @selector(nbs_preferredFontForTextStyle:);
Method originalMethod = class_getClassMethod(self, selector);
Method extendedMethod = class_getClassMethod(self, replacedSelector);
method_exchangeImplementations(originalMethod, extendedMethod);
}
@end
@implementation UIApplication (Swizzling)
- (UIContentSizeCategory)nbs_preferredContentSizeCategory {
return objc_getAssociatedObject(self, @selector(nbs_preferredContentSizeCategory));
}
- (void)setNbs_preferredContentSizeCategory:(UIContentSizeCategory)category {
objc_setAssociatedObject(self, @selector(nbs_preferredContentSizeCategory),
category, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
+ (void)nbs_swizzle {
SEL selector = @selector(preferredContentSizeCategory);
SEL replacedSelector = @selector(nbs_preferredContentSizeCategory);
Method originalMethod = class_getInstanceMethod(self, selector);
Method extendedMethod = class_getInstanceMethod(self, replacedSelector);
method_exchangeImplementations(originalMethod, extendedMethod);
}
@end
@implementation UITraitCollection (Swizzling)
- (UIContentSizeCategory)nbs_preferredContentSizeCategory {
return UIApplication.sharedApplication.preferredContentSizeCategory;
}
- (BOOL)nbs__changedContentSizeCategoryFromTraitCollection:(id)arg {
return YES;
}
+ (void)nbs_swizzle {
[self nbs_swizzlePreferredContentSizeCategory];
[self nbs_swizzleChangedContentSizeCategoryFromTraitCollection];
}
+ (void)nbs_swizzlePreferredContentSizeCategory {
SEL selector = @selector(preferredContentSizeCategory);
if (![self instancesRespondToSelector:selector]) {
return;
}
SEL replacedSelector = @selector(nbs_preferredContentSizeCategory);
Method originalMethod = class_getInstanceMethod(self, selector);
Method extendedMethod = class_getInstanceMethod(self, replacedSelector);
method_exchangeImplementations(originalMethod, extendedMethod);
}
+ (void)nbs_swizzleChangedContentSizeCategoryFromTraitCollection {
SEL selector = sel_registerName("_changedContentSizeCategoryFromTraitCollection:");
if (![self instancesRespondToSelector:selector]) {
return;
}
SEL replacedSelector = @selector(nbs__changedContentSizeCategoryFromTraitCollection:);
Method originalMethod = class_getInstanceMethod(self, selector);
Method extendedMethod = class_getInstanceMethod(self, replacedSelector);
method_exchangeImplementations(originalMethod, extendedMethod);
}
@end

View File

@@ -0,0 +1,45 @@
import Nimble
// MARK: - Nicer syntax using == operator
public struct DynamicTypeSnapshot {
let name: String?
let record: Bool
let sizes: [UIContentSizeCategory]
let deviceAgnostic: Bool
init(name: String?, record: Bool, sizes: [UIContentSizeCategory], deviceAgnostic: Bool) {
self.name = name
self.record = record
self.sizes = sizes
self.deviceAgnostic = deviceAgnostic
}
}
public func dynamicTypeSnapshot(_ name: String? = nil, sizes: [UIContentSizeCategory] = allContentSizeCategories(),
deviceAgnostic: Bool = false) -> DynamicTypeSnapshot {
return DynamicTypeSnapshot(name: name, record: false, sizes: sizes, deviceAgnostic: deviceAgnostic)
}
public func recordDynamicTypeSnapshot(_ name: String? = nil,
sizes: [UIContentSizeCategory] = allContentSizeCategories(),
deviceAgnostic: Bool = false) -> DynamicTypeSnapshot {
return DynamicTypeSnapshot(name: name, record: true, sizes: sizes, deviceAgnostic: deviceAgnostic)
}
public func == (lhs: Expectation<Snapshotable>, rhs: DynamicTypeSnapshot) {
if let name = rhs.name {
if rhs.record {
lhs.to(recordDynamicTypeSnapshot(named: name, sizes: rhs.sizes, isDeviceAgnostic: rhs.deviceAgnostic))
} else {
lhs.to(haveValidDynamicTypeSnapshot(named: name, sizes: rhs.sizes, isDeviceAgnostic: rhs.deviceAgnostic))
}
} else {
if rhs.record {
lhs.to(recordDynamicTypeSnapshot(sizes: rhs.sizes, isDeviceAgnostic: rhs.deviceAgnostic))
} else {
lhs.to(haveValidDynamicTypeSnapshot(sizes: rhs.sizes, isDeviceAgnostic: rhs.deviceAgnostic))
}
}
}

View File

@@ -0,0 +1,239 @@
import FBSnapshotTestCase
import Foundation
import Nimble
import QuartzCore
import UIKit
@objc public protocol Snapshotable {
var snapshotObject: UIView? { get }
}
extension UIViewController : Snapshotable {
public var snapshotObject: UIView? {
self.beginAppearanceTransition(true, animated: false)
self.endAppearanceTransition()
return view
}
}
extension UIView : Snapshotable {
public var snapshotObject: UIView? {
return self
}
}
@objc public class FBSnapshotTest: NSObject {
var referenceImagesDirectory: String?
var tolerance: CGFloat = 0
static let sharedInstance = FBSnapshotTest()
public class func setReferenceImagesDirectory(_ directory: String?) {
sharedInstance.referenceImagesDirectory = directory
}
// swiftlint:disable:next function_parameter_count
class func compareSnapshot(_ instance: Snapshotable, isDeviceAgnostic: Bool = false,
usesDrawRect: Bool = false, snapshot: String, record: Bool,
referenceDirectory: String, tolerance: CGFloat,
filename: String) -> Bool {
let testName = parseFilename(filename: filename)
let snapshotController: FBSnapshotTestController = FBSnapshotTestController(testName: testName)
snapshotController.isDeviceAgnostic = isDeviceAgnostic
snapshotController.recordMode = record
snapshotController.referenceImagesDirectory = referenceDirectory
snapshotController.usesDrawViewHierarchyInRect = usesDrawRect
let reason = "Missing value for referenceImagesDirectory - " +
"Call FBSnapshotTest.setReferenceImagesDirectory(FB_REFERENCE_IMAGE_DIR)"
assert(snapshotController.referenceImagesDirectory != nil, reason)
do {
try snapshotController.compareSnapshot(ofViewOrLayer: instance.snapshotObject,
selector: Selector(snapshot), identifier: nil, tolerance: tolerance)
} catch {
return false
}
return true
}
}
// Note that these must be lower case.
private var testFolderSuffixes = ["tests", "specs"]
public func setNimbleTestFolder(_ testFolder: String) {
testFolderSuffixes = [testFolder.lowercased()]
}
public func setNimbleTolerance(_ tolerance: CGFloat) {
FBSnapshotTest.sharedInstance.tolerance = tolerance
}
func getDefaultReferenceDirectory(_ sourceFileName: String) -> String {
if let globalReference = FBSnapshotTest.sharedInstance.referenceImagesDirectory {
return globalReference
}
// Search the test file's path to find the first folder with a test suffix,
// then append "/ReferenceImages" and use that.
// Grab the file's path
let pathComponents = (sourceFileName as NSString).pathComponents as NSArray
// Find the directory in the path that ends with a test suffix.
let testPath = pathComponents.first { component -> Bool in
return !testFolderSuffixes.filter {
(component as AnyObject).lowercased.hasSuffix($0)
}.isEmpty
}
guard let testDirectory = testPath else {
fatalError("Could not infer reference image folder You should provide a reference dir using " +
"FBSnapshotTest.setReferenceImagesDirectory(FB_REFERENCE_IMAGE_DIR)")
}
// Recombine the path components and append our own image directory.
let currentIndex = pathComponents.index(of: testDirectory) + 1
let folderPathComponents = pathComponents.subarray(with: NSRange(location: 0, length: currentIndex)) as NSArray
let folderPath = folderPathComponents.componentsJoined(by: "/")
return folderPath + "/ReferenceImages"
}
private func parseFilename(filename: String) -> String {
let nsName = filename as NSString
let type = ".\(nsName.pathExtension)"
let sanitizedName = nsName.lastPathComponent.replacingOccurrences(of: type, with: "")
return sanitizedName
}
func sanitizedTestName(_ name: String?) -> String {
guard let testName = currentTestName() else {
fatalError("Test matchers must be called from inside a test block")
}
var filename = name ?? testName
filename = filename.replacingOccurrences(of: "root example group, ", with: "")
let characterSet = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_")
let components = filename.components(separatedBy: characterSet.inverted)
return components.joined(separator: "_")
}
func getTolerance() -> CGFloat {
return FBSnapshotTest.sharedInstance.tolerance
}
func clearFailureMessage(_ failureMessage: FailureMessage) {
failureMessage.actualValue = nil
failureMessage.expected = ""
failureMessage.postfixMessage = ""
failureMessage.to = ""
}
private func performSnapshotTest(_ name: String?, isDeviceAgnostic: Bool = false, usesDrawRect: Bool = false,
actualExpression: Expression<Snapshotable>, failureMessage: FailureMessage,
tolerance: CGFloat?) -> Bool {
// swiftlint:disable:next force_try force_unwrapping
let instance = try! actualExpression.evaluate()!
let testFileLocation = actualExpression.location.file
let referenceImageDirectory = getDefaultReferenceDirectory(testFileLocation)
let snapshotName = sanitizedTestName(name)
let tolerance = tolerance ?? getTolerance()
let result = FBSnapshotTest.compareSnapshot(instance, isDeviceAgnostic: isDeviceAgnostic,
usesDrawRect: usesDrawRect, snapshot: snapshotName, record: false,
referenceDirectory: referenceImageDirectory, tolerance: tolerance,
filename: actualExpression.location.file)
if !result {
clearFailureMessage(failureMessage)
failureMessage.expected = "expected a matching snapshot in \(snapshotName)"
}
return result
}
private func recordSnapshot(_ name: String?, isDeviceAgnostic: Bool = false, usesDrawRect: Bool = false,
actualExpression: Expression<Snapshotable>, failureMessage: FailureMessage) -> Bool {
// swiftlint:disable:next force_try force_unwrapping
let instance = try! actualExpression.evaluate()!
let testFileLocation = actualExpression.location.file
let referenceImageDirectory = getDefaultReferenceDirectory(testFileLocation)
let snapshotName = sanitizedTestName(name)
let tolerance = getTolerance()
clearFailureMessage(failureMessage)
if FBSnapshotTest.compareSnapshot(instance, isDeviceAgnostic: isDeviceAgnostic, usesDrawRect: usesDrawRect,
snapshot: snapshotName, record: true, referenceDirectory: referenceImageDirectory,
tolerance: tolerance, filename: actualExpression.location.file) {
let name = name ?? snapshotName
failureMessage.expected = "snapshot \(name) successfully recorded, replace recordSnapshot with a check"
} else {
let expectedMessage: String
if let name = name {
expectedMessage = "expected to record a snapshot in \(name)"
} else {
expectedMessage = "expected to record a snapshot"
}
failureMessage.expected = expectedMessage
}
return false
}
private func currentTestName() -> String? {
return CurrentTestCaseTracker.shared.currentTestCase?.sanitizedName
}
internal var switchChecksWithRecords = false
public func haveValidSnapshot(named name: String? = nil, usesDrawRect: Bool = false,
tolerance: CGFloat? = nil) -> Predicate<Snapshotable> {
return Predicate.fromDeprecatedClosure { actualExpression, failureMessage in
if switchChecksWithRecords {
return recordSnapshot(name, usesDrawRect: usesDrawRect, actualExpression: actualExpression,
failureMessage: failureMessage)
}
return performSnapshotTest(name, usesDrawRect: usesDrawRect, actualExpression: actualExpression,
failureMessage: failureMessage, tolerance: tolerance)
}
}
public func haveValidDeviceAgnosticSnapshot(named name: String? = nil, usesDrawRect: Bool = false,
tolerance: CGFloat? = nil) -> Predicate<Snapshotable> {
return Predicate.fromDeprecatedClosure { actualExpression, failureMessage in
if switchChecksWithRecords {
return recordSnapshot(name, isDeviceAgnostic: true, usesDrawRect: usesDrawRect,
actualExpression: actualExpression, failureMessage: failureMessage)
}
return performSnapshotTest(name, isDeviceAgnostic: true, usesDrawRect: usesDrawRect,
actualExpression: actualExpression,
failureMessage: failureMessage, tolerance: tolerance)
}
}
public func recordSnapshot(named name: String? = nil, usesDrawRect: Bool = false) -> Predicate<Snapshotable> {
return Predicate.fromDeprecatedClosure { actualExpression, failureMessage in
return recordSnapshot(name, usesDrawRect: usesDrawRect,
actualExpression: actualExpression, failureMessage: failureMessage)
}
}
public func recordDeviceAgnosticSnapshot(named name: String? = nil,
usesDrawRect: Bool = false) -> Predicate<Snapshotable> {
return Predicate.fromDeprecatedClosure { actualExpression, failureMessage in
return recordSnapshot(name, isDeviceAgnostic: true, usesDrawRect: usesDrawRect,
actualExpression: actualExpression, failureMessage: failureMessage)
}
}

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Artsy, Ash Furrow
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,54 @@
import Nimble
// MARK: - Nicer syntax using == operator
public struct Snapshot {
let name: String?
let record: Bool
let usesDrawRect: Bool
init(name: String?, record: Bool, usesDrawRect: Bool) {
self.name = name
self.record = record
self.usesDrawRect = usesDrawRect
}
}
public func snapshot(_ name: String? = nil,
usesDrawRect: Bool = false) -> Snapshot {
return Snapshot(name: name, record: false, usesDrawRect: usesDrawRect)
}
public func recordSnapshot(_ name: String? = nil,
usesDrawRect: Bool = false) -> Snapshot {
return Snapshot(name: name, record: true, usesDrawRect: usesDrawRect)
}
public func == (lhs: Expectation<Snapshotable>, rhs: Snapshot) {
if let name = rhs.name {
if rhs.record {
lhs.to(recordSnapshot(named: name, usesDrawRect: rhs.usesDrawRect))
} else {
lhs.to(haveValidSnapshot(named: name, usesDrawRect: rhs.usesDrawRect))
}
} else {
if rhs.record {
lhs.to(recordSnapshot(usesDrawRect: rhs.usesDrawRect))
} else {
lhs.to(haveValidSnapshot(usesDrawRect: rhs.usesDrawRect))
}
}
}
// MARK: - Nicer syntax using emoji
// swiftlint:disable:next identifier_name
public func 📷(_ snapshottable: Snapshotable, file: FileString = #file, line: UInt = #line) {
expect(snapshottable, file: file, line: line).to(recordSnapshot())
}
// swiftlint:disable:next identifier_name
public func 📷(_ snapshottable: Snapshotable, named name: String, file: FileString = #file, line: UInt = #line) {
expect(snapshottable, file: file, line: line).to(recordSnapshot(named: name))
}

View File

@@ -0,0 +1,180 @@
[![CircleCI](https://circleci.com/gh/ashfurrow/Nimble-Snapshots/tree/master.svg?style=svg)](https://circleci.com/gh/ashfurrow/Nimble-Snapshots/tree/master)
=============================
[Nimble](https://github.com/Quick/Nimble) matchers for [FBSnapshotTestCase](https://github.com/facebook/ios-snapshot-test-case).
Highly derivative of [Expecta Matchers for FBSnapshotTestCase](https://github.com/dblock/ios-snapshot-test-case-expecta).
<p align="center">
<img src="http://gifs.ashfurrow.com/click.gif" />
</p>
Installing
----------
## CocoaPods
You need to be using CocoaPods 0.36 Beta 1 or higher. Your `Podfile` should look
something like the following.
```rb
platform :ios, '8.0'
source 'https://github.com/CocoaPods/Specs.git'
# Whichever pods you need for your app go here.
target 'YOUR_APP_NAME_HERE_Tests', :exclusive => true do
pod 'Nimble-Snapshots'
pod 'Quick' # if you want to use it with Quick
end
```
Then run:
```
$ pod install
```
## Carthage
You need to be using Carthage 0.18 or higher. Your `Cartfile` (or `Cartfile.private`) should look
something like the following.
```rb
github "Quick/Quick" ~> 1.0
github "Quick/Nimble" ~> 7.0
github "facebook/ios-snapshot-test-case" "2.1.4"
github "ashfurrow/Nimble-Snapshots"
```
Then run:
```
$ carthage bootstrap --platform iOS --toolchain com.apple.dt.toolchain.Swift_3_0
```
Use
---
Your tests will look something like the following.
```swift
import Quick
import Nimble
import Nimble_Snapshots
import UIKit
class MySpec: QuickSpec {
override func spec() {
describe("in some context") {
it("has valid snapshot") {
let view = ... // some view you want to test
expect(view).to( haveValidSnapshot() )
}
}
}
}
```
There are some options for testing the validity of snapshots. Snapshots can be
given a name:
```swift
expect(view).to( haveValidSnapshot(named: "some custom name") )
```
We also have a prettier syntax for custom-named snapshots:
```swift
expect(view) == snapshot("some custom name")
```
To record snapshots, just replace `haveValidSnapshot()` with `recordSnapshot()`
and `haveValidSnapshot(named:)` with `recordSnapshot(named:)`. We also have a
handy emoji operator.
```swift
📷(view)
📷(view, "some custom name")
```
By default, this pod will put the reference images inside a `ReferenceImages`
directory; we try to put this in a place that makes sense (inside your unit
tests directory). If we can't figure it out, or if you want to use your own
directory instead, call `setNimbleTestFolder()` with the name of the directory
in your unit test's path that we should use. For example, if the tests are in
`App/AppTesting/`, you can call it with `AppTesting`.
If you have any questions or run into any trouble, feel free to open an issue
on this repo.
## Dynamic Type
Testing Dynamic Type manually is boring and no one seems to remember doing it
when implementing a view/screen, so you can have snapshot tests according to
content size categories.
In order to use Dynamic Type testing, make sure to provide a valid `Host Application` in your testing target.
Then you can use the `haveValidDynamicTypeSnapshot` and
`recordDynamicTypeSnapshot` matchers:
```swift
// expect(view).to(recordDynamicTypeSnapshot()
expect(view).to(haveValidDynamicTypeSnapshot())
// You can also just test some sizes:
expect(view).to(haveValidDynamicTypeSnapshot(sizes: [UIContentSizeCategoryExtraLarge]))
// If you prefer the == syntax, we got you covered too:
expect(view) == dynamicTypeSnapshot()
expect(view) == dynamicTypeSnapshot(sizes: [UIContentSizeCategoryExtraLarge])
```
Note that this will post an `UIContentSizeCategoryDidChangeNotification`,
so your views/view controllers need to observe that and update themselves.
For more info on usage, check out the
[dynamic type tests](Bootstrap/BootstrapTests/DynamicTypeTests.swift).
## Dynamic Size
Testing the same view with many sizes is easy but error prone. It easy to fix one test
on change and forget the others. For this we create a easy way to tests all sizes at same time.
You can use the new `haveValidDynamicSizeSnapshot` and `recordDynamicSizeSnapshot`
matchers to test multiple sizes at once:
```swift
let sizes = ["SmallSize": CGSize(width: 44, height: 44),
"MediumSize": CGSize(width: 88, height: 88),
"LargeSize": CGSize(width: 132, height: 132)]
// expect(view).to(recordDynamicSizeSnapshot(sizes: sizes))
expect(view).to(haveValidDynamicSizeSnapshot(sizes: sizes))
// You can also just test some sizes:
expect(view).to(haveValidDynamicSizeSnapshot(sizes: sizes))
// If you prefer the == syntax, we got you covered too:
expect(view) == dynamicSizeSnapshot(sizes: sizes)
expect(view) == dynamicSizeSnapshot(sizes: sizes)
```
By default, the size will be set on the view using the frame property. To change this behavior
you can use the `ResizeMode` enum:
```swift
public enum ResizeMode {
case frame
case constrains
case block(resizeBlock: (UIView, CGSize) -> Void)
case custom(viewResizer: ViewResizer)
}
```
To use the enum you can `expect(view) == dynamicSizeSnapshot(sizes: sizes, resizeMode: newResizeMode)`.
For custom behavior you can use `ResizeMode.block`. The block will be call on every resize. Or you can
implement the `ViewResizer` protocol and resize yourself.
The custom behavior can be used to record the views too.
For more info on usage, check the [dynamic sizes tests](Bootstrap/BootstrapTests/DynamicSizeTests.swift).

View File

@@ -0,0 +1,5 @@
#import <XCTest/XCTest.h>
@interface XCTestObservationCenter (CurrentTestCaseTracker)
@end

View File

@@ -0,0 +1,10 @@
#import "XCTestObservationCenter+CurrentTestCaseTracker.h"
#import "Nimble_Snapshots/Nimble_Snapshots-Swift.h"
@implementation XCTestObservationCenter (CurrentTestCaseTracker)
+ (void)load {
[[self sharedTestObservationCenter] addTestObserver:[CurrentTestCaseTracker shared]];
}
@end