Skip to content

Commit

Permalink
Experiment/scale 1 (#17)
Browse files Browse the repository at this point in the history
* use transparent background for diff
* add premultiply alpha before comparing
* premultiply alpha for diff, clear background for host
* use transparent background
* test compare of same data should have 0 difference
* use hierarchy and sRGB
* use scale 1.0
* allow appearance transition when adding and removing child controller
* move settingAlphaOne to package
* use extended range and P3 gamut
* set flatness, test no rendering in unit tests
* fix: unbalance calls to appearance for delaying for 0.01s
* use standard range to fix image comparison for sample view
* fix: favorite view renders with 5% accuracy on GitHub runner
  • Loading branch information
paulz authored Jun 6, 2022
1 parent 2580b44 commit 5cd4166
Show file tree
Hide file tree
Showing 15 changed files with 261 additions and 45 deletions.
10 changes: 10 additions & 0 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,14 @@ jobs:
project: Example/Example.xcodeproj
scheme: Example
destination: ${{ env.destination }}
result-bundle-path: test-results/example-tests
action: test
- name: Archive results # due to: https://github.com/actions/upload-artifact/issues/243
if: always()
run: zip -FSry results.zip test-results || true
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: results.zip
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1340"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SwiftUI_SnapshotTesting"
BuildableName = "SwiftUI_SnapshotTesting"
BlueprintName = "SwiftUI_SnapshotTesting"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "UnitTests"
BuildableName = "UnitTests"
BlueprintName = "UnitTests"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "ViewSnapshotTesting"
BuildableName = "ViewSnapshotTesting"
BlueprintName = "ViewSnapshotTesting"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "UnitTests"
BuildableName = "UnitTests"
BlueprintName = "UnitTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SwiftUI_SnapshotTesting"
BuildableName = "SwiftUI_SnapshotTesting"
BlueprintName = "SwiftUI_SnapshotTesting"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
7 changes: 4 additions & 3 deletions Example/ApplicationTests/SnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import XCTest
import SwiftUI_snapshot_test
import SwiftUI


class SnapshotTests: XCTestCase {
func testViews() throws {
verifySnapshot(FavoriteView_Previews.self)
verifySnapshot(ContentView())
verifySnapshot(Text("SwiftUI").foregroundColor(.red), "example")
verifySnapshot(FavoriteView_Previews.self, colorAccuracy: 0.05)
verifySnapshot(ContentView(), colorAccuracy: 0)
verifySnapshot(Text("SwiftUI").foregroundColor(.red), "example", colorAccuracy: 0)
}
}
Binary file modified Example/ApplicationTests/Snapshots/ContentView.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Example/ApplicationTests/Snapshots/FavoriteView.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Example/ApplicationTests/Snapshots/example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 54 additions & 12 deletions Sources/Private/UIViewExtensions.swift
Original file line number Diff line number Diff line change
@@ -1,25 +1,67 @@
import UIKit
import XCTest

extension UIView {
func renderFormat() -> UIGraphicsImageRendererFormat {
let format = UIGraphicsImageRendererFormat(for: .current)
format.opaque = true
extension UITraitCollection {
static let snapshots = UITraitCollection(traitsFrom: [
UITraitCollection(displayGamut: .P3),
UITraitCollection(displayScale: 1.0),
UITraitCollection(activeAppearance: .active),
UITraitCollection(userInterfaceLevel: .base),
UITraitCollection(legibilityWeight: .regular),
UITraitCollection(userInterfaceStyle: .light),
UITraitCollection(preferredContentSizeCategory: .medium),
])
}

extension UIGraphicsImageRendererFormat {
static let snapshots: UIGraphicsImageRendererFormat = {
let format = UIGraphicsImageRendererFormat(for: .snapshots)
format.opaque = false
format.preferredRange = .standard
return format
}
}()
}

extension UIView {
func renderer() -> UIGraphicsImageRenderer {
UIGraphicsImageRenderer(bounds: bounds, format: renderFormat())
UIGraphicsImageRenderer(bounds: bounds, format: .snapshots)
}

func renderLayerAsBitmap() -> UIImage {
renderer().image {
layer.render(in: $0.cgContext)
}
renderer().image(actions: renderLayerActions(_:))
}

func renderLayerAsPNG() -> Data {
renderer().pngData(actions: renderLayerActions(_:))
}

func renderLayerActions(_ context: UIGraphicsImageRendererContext) {
configureContext(context)
layer.render(in: context.cgContext)
}

func configureContext(_ context: UIGraphicsImageRendererContext) {
context.cgContext.setFlatness(0.01)
context.cgContext.setShouldAntialias(false)
context.cgContext.setAllowsAntialiasing(false)
context.cgContext.setAllowsFontSubpixelPositioning(false)
context.cgContext.setShouldSubpixelPositionFonts(false)
context.cgContext.setShouldSmoothFonts(false)
context.cgContext.setAllowsFontSubpixelQuantization(false)
context.cgContext.setShouldSubpixelQuantizeFonts(false)
}

func renderHierarchyAsPNG() -> Data {
renderer().pngData(actions: drawHierarchyActions(_:))
}

func drawHierarchyActions(_ context: UIGraphicsImageRendererContext) {
configureContext(context)
XCTAssertTrue(drawHierarchy(in: bounds, afterScreenUpdates: true),
"unable to take snapshot of the view")
}

func renderHierarchyOnScreen() -> UIImage {
renderer().image { _ in
drawHierarchy(in: bounds, afterScreenUpdates: true)
}
renderer().image(actions: drawHierarchyActions(_:))
}
}
33 changes: 28 additions & 5 deletions Sources/Private/maxColorDiff.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import UIKit
import CoreImage
import CoreImage.CIFilterBuiltins

extension CIImage {
func settingAlphaOne() -> CIImage {
settingAlphaOne(in: extent)
}
}

func diff(_ old: UIImage, _ new: UIImage) -> UIImage {
let differenceFilter = diff(
old.cgImage!,
Expand All @@ -21,15 +27,15 @@ func diff(_ old: CGImage, _ new: CGImage) -> CICompositeOperation {

func diff(_ old: CIImage, _ new: CIImage) -> CICompositeOperation {
let differenceFilter: CICompositeOperation = CIFilter.differenceBlendMode()
differenceFilter.inputImage = old
differenceFilter.backgroundImage = new
differenceFilter.inputImage = old.settingAlphaOne()
differenceFilter.backgroundImage = new.settingAlphaOne()
return differenceFilter
}

func histogramData(_ ciImage: CIImage) -> Data {
let hist = CIFilter.areaHistogram()
hist.inputImage = ciImage
hist.setValue(CIVector(cgRect: ciImage.extent), forKey: kCIInputExtentKey)
hist.extent = ciImage.extent
return hist.value(forKey: "outputData") as! Data
}

Expand All @@ -55,8 +61,25 @@ func histogram(ciImage: CIImage) -> [UInt32] {
}

func compare(_ left: UIImage, _ right: UIImage) -> ImageComparisonResult {
let image1 = CIImage(image: left)!
let image2 = CIImage(image: right)!
let image1 = CIImage(image: left)!.premultiplyingAlpha()
let image2 = CIImage(image: right)!.premultiplyingAlpha()
let diffOperation = diff(image1, image2)
return ImageComparisonResult(difference: diffOperation.outputImage!)
}

let workColorSpace = CGColorSpace(name: CGColorSpace.displayP3)!
//let workColorSpace = CGColorSpace(name: CGColorSpace.extendedSRGB)!
//let workColorSpace = CGColorSpace(name: CGColorSpace.extendedDisplayP3)!

func compare(_ left: Data, _ right: Data) -> ImageComparisonResult {
let options: [CIImageOption : Any] = [
.colorSpace: workColorSpace,
.nearestSampling: NSNumber(booleanLiteral: true)
]
let image1 = CIImage(data: left, options: options)!
.premultiplyingAlpha()
let image2 = CIImage(data: right, options: options)!
.premultiplyingAlpha()
let diffOperation = diff(image1, image2)
return ImageComparisonResult(difference: diffOperation.outputImage!)
}
Expand Down
37 changes: 22 additions & 15 deletions Sources/verifySnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,14 @@ func ensureFolder(url: URL) throws {

public func verifySnapshot<V: View>(_ view: V, _ name: String? = nil, colorAccuracy: Float = 0.02,
file: StaticString = #filePath, line: UInt = #line) {
guard let image = try? inWindowView(view, block: {
$0.renderLayerAsBitmap()
guard let pngData = try? inWindowView(view, block: {
$0.renderHierarchyAsPNG()
}) else {
XCTFail("failed to get snapshot of view")
return
}
let isRunningOnCI = ProcessInfo.processInfo.environment.keys.contains("CI")
let shouldOverwriteExpected = !isRunningOnCI
guard let pngData = image.pngData() else {
XCTFail("failed to get image data")
return
}
let viewName = name ?? "\(V.self)"
let fileName = viewName + ".png"
let url = folderUrl(String(describing: file)).appendingPathComponent(fileName)
Expand All @@ -53,13 +49,14 @@ public func verifySnapshot<V: View>(_ view: V, _ name: String? = nil, colorAccur
}, onFailure, file: file, line: line)
}

if let expectedData = try? Data(contentsOf: url), let expectedImage = UIImage(data: expectedData) {
if let expectedData = try? Data(contentsOf: url) {
XCTContext.runActivity(named: viewName) {
let actualImage = XCTAttachment(data: pngData, uniformTypeIdentifier: UTType.png.identifier)
actualImage.name = "actual image"
$0.add(actualImage)
let diff = compare(image, expectedImage)
if diff.maxColorDifference() > colorAccuracy {
let diff = compare(pngData, expectedData)
let actualDifference = diff.maxColorDifference()
if actualDifference > colorAccuracy {
if shouldOverwriteExpected {
writeActual(onFailure: "failed to record actual image")
}
Expand All @@ -74,11 +71,18 @@ public func verifySnapshot<V: View>(_ view: V, _ name: String? = nil, colorAccur
)
}
let ciImage = diff.difference
guard let diffImage = CIContext().createCGImage(ciImage, from: ciImage.extent) else {
XCTFail("failed to get image of difference")
return
}
let diffAttachment = XCTAttachment(image: UIImage(cgImage: diffImage))
let context = CIContext(options: [
.workingColorSpace : workColorSpace,
.allowLowPower: NSNumber(booleanLiteral: false),
.highQualityDownsample: NSNumber(booleanLiteral: true),
.outputColorSpace: workColorSpace,
.useSoftwareRenderer: NSNumber(booleanLiteral: true),
.cacheIntermediates: NSNumber(booleanLiteral: false),
.priorityRequestLow: NSNumber(booleanLiteral: false),
.name: "difference"
])
let data = context.pngRepresentation(of: ciImage.premultiplyingAlpha(), format: .RGBA8, colorSpace: workColorSpace)!
let diffAttachment = XCTAttachment(data: data, uniformTypeIdentifier: UTType.png.identifier)
diffAttachment.name = "difference"
$0.add(diffAttachment)
}
Expand Down Expand Up @@ -107,7 +111,7 @@ func folderUrl(_ filePath: String = #filePath) -> URL {
see: https://github.com/paulz/SwiftUI-snapshot-testing/issues/11
*/
func allowAppearanceTransition() {
RunLoop.current.run(until: .init(timeIntervalSinceNow: 0))
RunLoop.current.run(until: .init(timeIntervalSinceNow: 0.01))
}

func inWindowView<V: View, T>(_ swiftUIView: V, block: (UIView) -> T) throws -> T {
Expand All @@ -127,15 +131,18 @@ func inWindowView<V: View, T>(_ swiftUIView: V, block: (UIView) -> T) throws ->
if size == .zero {
size = layoutFrame.size
}
view.backgroundColor = .clear
let safeOrigin = layoutFrame.origin
rootController.addChild(controller)
allowAppearanceTransition()
view.frame = .init(origin: safeOrigin, size: size)
rootController.view.addSubview(controller.view)
view.frame = .init(origin: safeOrigin, size: size)
XCTAssertEqual(view.bounds.size, size)
defer {
view.removeFromSuperview()
controller.removeFromParent()
allowAppearanceTransition()
}
return block(view)
}
12 changes: 12 additions & 0 deletions Tests/CompareTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@testable import ViewSnapshotTesting
import XCTest

class CompareTest: XCTestCase {
func testSameDataShouldHave0Difference() throws {
let expectedData = try Data(
contentsOf: folderUrl().appendingPathComponent("SampleView.png")
)
let result = compare(expectedData, expectedData)
XCTAssertEqual(result.maxColorDifference(), 0)
}
}
Loading

0 comments on commit 5cd4166

Please sign in to comment.