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

Allow for subpixel deviations + improved unoptimized loop execution in image comparison #571

Closed
wants to merge 9 commits into from
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,17 @@ assertSnapshot(matching: vc, as: .image(on: .iPadMini(.portrait)))
assertSnapshot(matching: vc, as: .recursiveDescription(on: .iPadMini(.portrait)))
```

> ⚠️ Warning: Snapshots must be compared using a simulator with the same OS, device gamut, and scale as the simulator that originally took the reference to avoid discrepancies between images.
> ⚠️ Warning: Snapshots may differ slightly unless compared on the same OS,
> device gamut, and scale as the simulator that originally took the reference.
> If this cannot be avoided, acceptance in differences can be configured by
> setting the `subpixelThreshold`-parameter.
>
> Example:
> ```swift
> // Allow each subpixel to deviate up to 5 byte-values
> assertSnapshot(matching: vc, as: .image(on: .iPhoneX, subpixelThreshold: 5))
> ```
>

Better yet, SnapshotTesting isn't limited to views and view controllers! There are [a number of available snapshot strategies](Documentation/Available-Snapshot-Strategies.md) to choose from.

Expand Down
40 changes: 32 additions & 8 deletions SnapshotTesting.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@
F3883B573DF4CAFADE5968A9 /* testUpdateSeveralSnapshotsWithLessLines.1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32BC721A155DB7D81BE69683 /* testUpdateSeveralSnapshotsWithLessLines.1.swift */; };
F473E43FAB7DD0C5F4D02437 /* testUpdateSeveralSnapshotsWithMoreLines.1.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECEC001D7DB94ECB4F5A2CF9 /* testUpdateSeveralSnapshotsWithMoreLines.1.swift */; };
F4CB3EC3E5D30B217B4D9699 /* Wait.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10213E0E8B550596F75463C3 /* Wait.swift */; };
F5B874F327B3FA5F005D3517 /* UInt8+diff.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B874F227B3FA5F005D3517 /* UInt8+diff.swift */; };
F5B874F427B3FA5F005D3517 /* UInt8+diff.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B874F227B3FA5F005D3517 /* UInt8+diff.swift */; };
F5B874F527B3FA5F005D3517 /* UInt8+diff.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B874F227B3FA5F005D3517 /* UInt8+diff.swift */; };
F66FB66FCA7B884DA64ADDBE /* Diff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EEE36D7D9E53E70DABA3996 /* Diff.swift */; };
FBDFF661DB08CFB75DFB12C3 /* SnapshotTesting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F9883B60A8B403A39BCF888 /* SnapshotTesting.framework */; };
FE365D5C0F83CE8459CF77DC /* testUpdateSnapshotWithMoreLines.1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BC8BF7E37686D31F59B9F6 /* testUpdateSnapshotWithMoreLines.1.swift */; };
Expand Down Expand Up @@ -333,6 +336,7 @@
EE91B5904B3F67A3A01610C8 /* testCreateSnapshotWithShorterExtendedDelimiter1.1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = testCreateSnapshotWithShorterExtendedDelimiter1.1.swift; sourceTree = "<group>"; };
F479E2BD835B9641B85EB51E /* Internal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Internal.swift; sourceTree = "<group>"; };
F4D7D0D81B7E35C6284A3E65 /* testUpdateSnapshotWithExtendedDelimiter1.1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = testUpdateSnapshotWithExtendedDelimiter1.1.swift; sourceTree = "<group>"; };
F5B874F227B3FA5F005D3517 /* UInt8+diff.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UInt8+diff.swift"; sourceTree = "<group>"; };
F6D3BC50BC4692DC6D9FAE0F /* InlineSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineSnapshotTests.swift; sourceTree = "<group>"; };
F8F831EAAFB97204ECD0B879 /* Any.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Any.swift; sourceTree = "<group>"; };
FD800FD3282956340AD7706A /* AssertInlineSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssertInlineSnapshot.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -451,6 +455,7 @@
5768A55D77F19D897206FF9F /* Extensions */ = {
isa = PBXGroup;
children = (
F5B874F227B3FA5F005D3517 /* UInt8+diff.swift */,
10213E0E8B550596F75463C3 /* Wait.swift */,
);
path = Extensions;
Expand Down Expand Up @@ -646,8 +651,6 @@
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1020;
TargetAttributes = {
};
};
buildConfigurationList = D9A4BF45876C849A308801A2 /* Build configuration list for PBXProject "SnapshotTesting" */;
compatibilityVersion = "Xcode 10.0";
Expand Down Expand Up @@ -703,6 +706,7 @@
buildActionMask = 2147483647;
files = (
87D3745DBD172E3E6427190F /* Any.swift in Sources */,
F5B874F427B3FA5F005D3517 /* UInt8+diff.swift in Sources */,
6CAB0714112F5AAE5D0FBD23 /* AssertInlineSnapshot.swift in Sources */,
52B7C3800F08E80D7BA8600D /* AssertSnapshot.swift in Sources */,
1420B1BBC10CFF4D03C8AA67 /* Async.swift in Sources */,
Expand Down Expand Up @@ -743,6 +747,7 @@
buildActionMask = 2147483647;
files = (
E2E1BA4E82EBF5D7E7533485 /* Any.swift in Sources */,
F5B874F327B3FA5F005D3517 /* UInt8+diff.swift in Sources */,
90555C6EB17BD465E901043A /* AssertInlineSnapshot.swift in Sources */,
AE77649A377D5328DC91D19B /* AssertSnapshot.swift in Sources */,
29602B6DD2A43E1C13DF1D42 /* Async.swift in Sources */,
Expand Down Expand Up @@ -855,6 +860,7 @@
buildActionMask = 2147483647;
files = (
C48A4BD0534BF1817AF91098 /* Any.swift in Sources */,
F5B874F527B3FA5F005D3517 /* UInt8+diff.swift in Sources */,
56551E9E2E7A3DCF41C966F1 /* AssertInlineSnapshot.swift in Sources */,
6C48EAD7C6D1441EE12F907B /* AssertSnapshot.swift in Sources */,
45FAA85BB7198113155FD27F /* Async.swift in Sources */,
Expand Down Expand Up @@ -1017,7 +1023,10 @@
DYLIB_INSTALL_NAME_BASE = "@rpath";
ENABLE_BITCODE = NO;
ENABLE_TESTING_SEARCH_PATHS = YES;
FRAMEWORK_SEARCH_PATHS = "$(inherited) $(PLATFORM_DIR)/Developer/Library/Frameworks";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PLATFORM_DIR)/Developer/Library/Frameworks",
);
INFOPLIST_FILE = Sources/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
Expand Down Expand Up @@ -1065,7 +1074,10 @@
DYLIB_INSTALL_NAME_BASE = "@rpath";
ENABLE_BITCODE = NO;
ENABLE_TESTING_SEARCH_PATHS = YES;
FRAMEWORK_SEARCH_PATHS = "$(inherited) $(PLATFORM_DIR)/Developer/Library/Frameworks";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PLATFORM_DIR)/Developer/Library/Frameworks",
);
INFOPLIST_FILE = Sources/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
Expand Down Expand Up @@ -1146,7 +1158,10 @@
DYLIB_INSTALL_NAME_BASE = "@rpath";
ENABLE_BITCODE = NO;
ENABLE_TESTING_SEARCH_PATHS = YES;
FRAMEWORK_SEARCH_PATHS = "$(inherited) $(PLATFORM_DIR)/Developer/Library/Frameworks";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PLATFORM_DIR)/Developer/Library/Frameworks",
);
INFOPLIST_FILE = Sources/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
Expand Down Expand Up @@ -1175,7 +1190,10 @@
DYLIB_INSTALL_NAME_BASE = "@rpath";
ENABLE_BITCODE = NO;
ENABLE_TESTING_SEARCH_PATHS = YES;
FRAMEWORK_SEARCH_PATHS = "$(inherited) $(PLATFORM_DIR)/Developer/Library/Frameworks";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PLATFORM_DIR)/Developer/Library/Frameworks",
);
INFOPLIST_FILE = Sources/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
Expand Down Expand Up @@ -1204,7 +1222,10 @@
DYLIB_INSTALL_NAME_BASE = "@rpath";
ENABLE_BITCODE = NO;
ENABLE_TESTING_SEARCH_PATHS = YES;
FRAMEWORK_SEARCH_PATHS = "$(inherited) $(PLATFORM_DIR)/Developer/Library/Frameworks";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PLATFORM_DIR)/Developer/Library/Frameworks",
);
INFOPLIST_FILE = Sources/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
Expand Down Expand Up @@ -1313,7 +1334,10 @@
DYLIB_INSTALL_NAME_BASE = "@rpath";
ENABLE_BITCODE = NO;
ENABLE_TESTING_SEARCH_PATHS = YES;
FRAMEWORK_SEARCH_PATHS = "$(inherited) $(PLATFORM_DIR)/Developer/Library/Frameworks";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PLATFORM_DIR)/Developer/Library/Frameworks",
);
INFOPLIST_FILE = Sources/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = (
Expand Down
11 changes: 11 additions & 0 deletions Sources/SnapshotTesting/Extensions/UInt8+diff.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

extension UInt8 {
func diff(between other: UInt8) -> UInt8 {
if other > self {
return other - self
} else {
return self - other
}
}
}
12 changes: 7 additions & 5 deletions Sources/SnapshotTesting/Snapshotting/CALayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import Cocoa
extension Snapshotting where Value == CALayer, Format == NSImage {
/// A snapshot strategy for comparing layers based on pixel equality.
public static var image: Snapshotting {
return .image(precision: 1)
return .image(precision: 1, subpixelThreshold: 0)
}

/// A snapshot strategy for comparing layers based on pixel equality.
///
/// - Parameter precision: The percentage of pixels that must match.
public static func image(precision: Float) -> Snapshotting {
return SimplySnapshotting.image(precision: precision).pullback { layer in
/// - Parameter subpixelThreshold: The byte-value threshold at which two subpixels are considered different.
public static func image(precision: Float, subpixelThreshold: UInt8) -> Snapshotting {
return SimplySnapshotting.image(precision: precision, subpixelThreshold: subpixelThreshold).pullback { layer in
let image = NSImage(size: layer.bounds.size)
image.lockFocus()
let context = NSGraphicsContext.current!.cgContext
Expand All @@ -35,9 +36,10 @@ extension Snapshotting where Value == CALayer, Format == UIImage {
/// A snapshot strategy for comparing layers based on pixel equality.
///
/// - Parameter precision: The percentage of pixels that must match.
public static func image(precision: Float = 1, traits: UITraitCollection = .init())
/// - Parameter subpixelThreshold: The byte-value threshold at which two subpixels are considered different.
public static func image(precision: Float = 1, subpixelThreshold: UInt8 = 0, traits: UITraitCollection = .init())
-> Snapshotting {
return SimplySnapshotting.image(precision: precision, scale: traits.displayScale).pullback { layer in
return SimplySnapshotting.image(precision: precision, subpixelThreshold: subpixelThreshold, scale: traits.displayScale).pullback { layer in
renderer(bounds: layer.bounds, for: traits).image { ctx in
layer.setNeedsLayout()
layer.layoutIfNeeded()
Expand Down
11 changes: 7 additions & 4 deletions Sources/SnapshotTesting/Snapshotting/CGPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ extension Snapshotting where Value == CGPath, Format == NSImage {
/// A snapshot strategy for comparing bezier paths based on pixel equality.
///
/// - Parameter precision: The percentage of pixels that must match.
public static func image(precision: Float = 1, drawingMode: CGPathDrawingMode = .eoFill) -> Snapshotting {
return SimplySnapshotting.image(precision: precision).pullback { path in
/// - Parameter subpixelThreshold: The byte-value threshold at which two subpixels are considered different.
public static func image(precision: Float = 1, subpixelThreshold: UInt8 = 0, drawingMode: CGPathDrawingMode = .eoFill) -> Snapshotting {
return SimplySnapshotting.image(precision: precision, subpixelThreshold: subpixelThreshold).pullback { path in
let bounds = path.boundingBoxOfPath
var transform = CGAffineTransform(translationX: -bounds.origin.x, y: -bounds.origin.y)
let path = path.copy(using: &transform)!
Expand Down Expand Up @@ -39,8 +40,10 @@ extension Snapshotting where Value == CGPath, Format == UIImage {
/// A snapshot strategy for comparing bezier paths based on pixel equality.
///
/// - Parameter precision: The percentage of pixels that must match.
public static func image(precision: Float = 1, scale: CGFloat = 1, drawingMode: CGPathDrawingMode = .eoFill) -> Snapshotting {
return SimplySnapshotting.image(precision: precision, scale: scale).pullback { path in
/// - Parameter subpixelThreshold: The byte-value threshold at which two subpixels are considered different.

public static func image(precision: Float = 1, subpixelThreshold: UInt8 = 0, scale: CGFloat = 1, drawingMode: CGPathDrawingMode = .eoFill) -> Snapshotting {
return SimplySnapshotting.image(precision: precision, subpixelThreshold: subpixelThreshold, scale: scale).pullback { path in
let bounds = path.boundingBoxOfPath
let format: UIGraphicsImageRendererFormat
if #available(iOS 11.0, tvOS 11.0, *) {
Expand Down
6 changes: 3 additions & 3 deletions Sources/SnapshotTesting/Snapshotting/NSBezierPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ extension Snapshotting where Value == NSBezierPath, Format == NSImage {
/// A snapshot strategy for comparing bezier paths based on pixel equality.
///
/// - Parameter precision: The percentage of pixels that must match.
public static func image(precision: Float = 1) -> Snapshotting {
return SimplySnapshotting.image(precision: precision).pullback { path in
/// - Parameter subpixelThreshold: The byte-value threshold at which two subpixels are considered different.
public static func image(precision: Float = 1, subpixelThreshold: UInt8 = 0) -> Snapshotting {
return SimplySnapshotting.image(precision: precision, subpixelThreshold: subpixelThreshold).pullback { path in
// Move path info frame:
let bounds = path.bounds
let transform = AffineTransform(translationByX: -bounds.origin.x, byY: -bounds.origin.y)
Expand Down Expand Up @@ -90,4 +91,3 @@ private let defaultNumberFormatter: NumberFormatter = {
return numberFormatter
}()
#endif

31 changes: 19 additions & 12 deletions Sources/SnapshotTesting/Snapshotting/NSImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ import XCTest

extension Diffing where Value == NSImage {
/// A pixel-diffing strategy for NSImage's which requires a 100% match.
public static let image = Diffing.image(precision: 1)
public static let image: Diffing = Diffing.image(precision: 1, subpixelThreshold: 0)

/// A pixel-diffing strategy for NSImage that allows customizing how precise the matching must be.
///
/// - Parameter precision: A value between 0 and 1, where 1 means the images must match 100% of their pixels.
/// - Parameter subpixelThreshold: The byte-value threshold at which two subpixels are considered different.
/// - Returns: A new diffing strategy.
public static func image(precision: Float) -> Diffing {
public static func image(precision: Float, subpixelThreshold: UInt8) -> Diffing {
return .init(
toData: { NSImagePNGRepresentation($0)! },
fromData: { NSImage(data: $0)! }
) { old, new in
guard !compare(old, new, precision: precision) else { return nil }
guard !compare(old, new, precision: precision, subpixelThreshold: subpixelThreshold) else { return nil }
let difference = SnapshotTesting.diff(old, new)
let message = new.size == old.size
? "Newly-taken snapshot does not match reference."
Expand All @@ -31,16 +32,17 @@ extension Diffing where Value == NSImage {
extension Snapshotting where Value == NSImage, Format == NSImage {
/// A snapshot strategy for comparing images based on pixel equality.
public static var image: Snapshotting {
return .image(precision: 1)
return .image(precision: 1, subpixelThreshold: 0)
}

/// A snapshot strategy for comparing images based on pixel equality.
///
/// - Parameter precision: The percentage of pixels that must match.
public static func image(precision: Float) -> Snapshotting {
/// - Parameter subpixelThreshold: The byte-value threshold at which two subpixels are considered different.
public static func image(precision: Float, subpixelThreshold: UInt8) -> Snapshotting {
return .init(
pathExtension: "png",
diffing: .image(precision: precision)
diffing: .image(precision: precision, subpixelThreshold: subpixelThreshold)
)
}
}
Expand All @@ -52,7 +54,7 @@ private func NSImagePNGRepresentation(_ image: NSImage) -> Data? {
return rep.representation(using: .png, properties: [:])
}

private func compare(_ old: NSImage, _ new: NSImage, precision: Float) -> Bool {
private func compare(_ old: NSImage, _ new: NSImage, precision: Float, subpixelThreshold: UInt8) -> Bool {
guard let oldCgImage = old.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return false }
guard let newCgImage = new.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return false }
guard oldCgImage.width != 0 else { return false }
Expand All @@ -77,14 +79,19 @@ private func compare(_ old: NSImage, _ new: NSImage, precision: Float) -> Bool {
let newRep = NSBitmapImageRep(cgImage: newerCgImage)
var differentPixelCount = 0
let pixelCount = oldRep.pixelsWide * oldRep.pixelsHigh
let threshold = (1 - precision) * Float(pixelCount)
let threshold = Int((1 - precision) * Float(pixelCount))
let p1: UnsafeMutablePointer<UInt8> = oldRep.bitmapData!
let p2: UnsafeMutablePointer<UInt8> = newRep.bitmapData!
for offset in 0 ..< pixelCount * 4 {
if p1[offset] != p2[offset] {
differentPixelCount += 1

var offset = 0
while offset < pixelCount * 4 {
if p1[offset].diff(between: p2[offset]) > subpixelThreshold {
differentPixelCount += 1
if differentPixelCount > threshold {
return false
}
}
if Float(differentPixelCount) > threshold { return false }
offset += 1
Comment on lines +87 to +94

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason this was changed from a for-loop to a while-loop? My interpretation is that in both implementations the loop should either increment offset or return false.

Using a while loop and manually incrementing the offset makes this less maintainable since it's easy to introduce changes in the loop body that skip incrementing offset and would lead to an infinite loop.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind, just saw your PR description with the explanation for when compiled without optimization.

}
return true
}
Expand Down
5 changes: 3 additions & 2 deletions Sources/SnapshotTesting/Snapshotting/NSView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ extension Snapshotting where Value == NSView, Format == NSImage {
///
/// - Parameters:
/// - precision: The percentage of pixels that must match.
/// - subpixelThreshold: The byte-value threshold at which two subpixels are considered different.
/// - size: A view size override.
public static func image(precision: Float = 1, size: CGSize? = nil) -> Snapshotting {
return SimplySnapshotting.image(precision: precision).asyncPullback { view in
public static func image(precision: Float = 1, subpixelThreshold: UInt8 = 0, size: CGSize? = nil) -> Snapshotting {
return SimplySnapshotting.image(precision: precision, subpixelThreshold: subpixelThreshold).asyncPullback { view in
let initialSize = view.frame.size
if let size = size { view.frame.size = size }
guard view.frame.width > 0, view.frame.height > 0 else {
Expand Down
5 changes: 3 additions & 2 deletions Sources/SnapshotTesting/Snapshotting/NSViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ extension Snapshotting where Value == NSViewController, Format == NSImage {
///
/// - Parameters:
/// - precision: The percentage of pixels that must match.
/// - subpixelThreshold: The byte-value threshold at which two subpixels are considered different.
/// - size: A view size override.
public static func image(precision: Float = 1, size: CGSize? = nil) -> Snapshotting {
return Snapshotting<NSView, NSImage>.image(precision: precision, size: size).pullback { $0.view }
public static func image(precision: Float = 1, subpixelThreshold: UInt8 = 0, size: CGSize? = nil) -> Snapshotting {
return Snapshotting<NSView, NSImage>.image(precision: precision, subpixelThreshold: subpixelThreshold, size: size).pullback { $0.view }
}
}

Expand Down
Loading